diff --git a/.vscode/launch.json b/.vscode/launch.json index 20706b262..175e9dd74 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,36 +9,33 @@ "request": "launch", "name": "Debug server", "runtimeExecutable": "npm", - "args": [ - "run", - "dev" - ], - "skipFiles": [ - "/**" - ] + "cwd": "${workspaceFolder}/client", + "args": ["run", "dev"], + "skipFiles": ["/**"] }, + { + "type": "node", + "request": "launch", + "name": "Prod server", + "runtimeExecutable": "npm", + "args": ["run", "prod"], + "skipFiles": ["/**"] + }, + { "type": "node", "request": "launch", "name": "Debug client (nuxt)", "runtimeExecutable": "npm", - "args": [ - "run", - "dev" - ], + "args": ["run", "dev"], "cwd": "${workspaceFolder}/client", - "skipFiles": [ - "${workspaceFolder}//**" - ] + "skipFiles": ["${workspaceFolder}//**"] } ], "compounds": [ { "name": "Debug server and client (nuxt)", - "configurations": [ - "Debug server", - "Debug client (nuxt)" - ] + "configurations": ["Debug server", "Debug client (nuxt)"] } ] -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index 99415bd9c..af2d40d12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "p-throttle": "^4.1.1", "passport": "^0.6.0", "passport-jwt": "^4.0.1", + "rewire": "^9.0.0", "semver": "^7.6.3", "sequelize": "^6.35.2", "socket.io": "^4.5.4", @@ -38,7 +39,7 @@ "mocha": "^10.2.0", "nodemon": "^2.0.20", "nyc": "^15.1.0", - "sinon": "^17.0.1" + "sinon": "^21.0.0" } }, "node_modules/@ampproject/remapping": { @@ -509,12 +510,280 @@ "node": ">=6.9.0" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/@eslint/eslintrc/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz", + "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "optional": true }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -613,49 +882,46 @@ } }, "node_modules/@sinonjs/commons": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", - "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "type-detect": "4.0.8" } }, "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^3.0.0" + "@sinonjs/commons": "^3.0.1" } }, "node_modules/@sinonjs/samsam": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", - "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^2.0.0", - "lodash.get": "^4.4.2", - "type-detect": "^4.0.8" + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" } }, - "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", - "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", "dev": true, - "dependencies": { - "type-detect": "4.0.8" + "license": "MIT", + "engines": { + "node": ">=4" } }, - "node_modules/@sinonjs/text-encoding": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", - "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", - "dev": true - }, "node_modules/@socket.io/component-emitter": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", @@ -691,6 +957,18 @@ "@types/ms": "*" } }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, "node_modules/@types/ms": { "version": "0.7.31", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", @@ -724,6 +1002,27 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -809,6 +1108,22 @@ "node": ">=8" } }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-colors": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", @@ -831,7 +1146,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -919,8 +1233,7 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "devOptional": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/base64-js": { "version": "1.5.1", @@ -1006,7 +1319,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "devOptional": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1167,6 +1479,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", @@ -1218,7 +1539,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -1234,7 +1554,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } @@ -1243,7 +1562,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -1336,7 +1654,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -1347,8 +1664,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/color-support": { "version": "1.1.3", @@ -1379,8 +1695,7 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "devOptional": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/console-control-strings": { "version": "1.1.0", @@ -1459,10 +1774,10 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -1528,6 +1843,12 @@ "node": ">=4.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "license": "MIT" + }, "node_modules/default-require-extensions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", @@ -1823,6 +2144,204 @@ "node": ">=0.8.0" } }, + "node_modules/eslint": { + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz", + "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.33.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -1836,6 +2355,48 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1935,6 +2496,36 @@ "node": ">= 0.6" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -2009,6 +2600,25 @@ "flat": "cli.js" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "license": "ISC" + }, "node_modules/follow-redirects": { "version": "1.15.5", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", @@ -2442,17 +3052,50 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", "dev": true }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "devOptional": true, "engines": { "node": ">=0.8.19" } @@ -2531,7 +3174,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -2549,7 +3191,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -2620,17 +3261,10 @@ "node": ">=0.10.0" } }, - "node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "dev": true - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -2845,6 +3479,24 @@ "node": ">=4" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2883,12 +3535,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, - "node_modules/just-extend": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", - "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", - "dev": true - }, "node_modules/jwa": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", @@ -2908,6 +3554,28 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -2931,12 +3599,6 @@ "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", "dev": true }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "dev": true - }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -2967,6 +3629,12 @@ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -3135,7 +3803,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "devOptional": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -3559,6 +4226,12 @@ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "license": "MIT" }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -3567,37 +4240,6 @@ "node": ">= 0.6" } }, - "node_modules/nise": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.5.tgz", - "integrity": "sha512-VJuPIfUFaXNRzETTQEEItTOP8Y171ijr+JLq42wHes3DiryR8vT+1TXQW/Rx8JNUhyYYWyIvjXTU6dOhJcs9Nw==", - "dev": true, - "dependencies": { - "@sinonjs/commons": "^2.0.0", - "@sinonjs/fake-timers": "^10.0.2", - "@sinonjs/text-encoding": "^0.7.1", - "just-extend": "^4.0.2", - "path-to-regexp": "^1.7.0" - } - }, - "node_modules/nise/node_modules/@sinonjs/commons": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", - "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", - "dev": true, - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/nise/node_modules/path-to-regexp": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", - "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", - "dev": true, - "dependencies": { - "isarray": "0.0.1" - } - }, "node_modules/node-abi": { "version": "3.74.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz", @@ -3958,11 +4600,27 @@ "node": ">=10" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -4050,6 +4708,18 @@ "node": ">=8" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -4096,7 +4766,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "engines": { "node": ">=8" } @@ -4114,7 +4783,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -4199,6 +4867,15 @@ "node": ">=10" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/process-on-spawn": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", @@ -4258,6 +4935,15 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -4410,6 +5096,15 @@ "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.0.4.tgz", "integrity": "sha512-XgmCoxKWkDofwH8WddD0w85ZfqYz+ZHlr5yo+3YUCfycWawU56T5ckWXsScsj5B8tqUcIG67DxXByo3VUgiAdA==" }, + "node_modules/rewire": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/rewire/-/rewire-9.0.0.tgz", + "integrity": "sha512-c21+Rzev3vAdtCpXD3dT4ZVw9w99E3dppyi94HlCmSyn+OSx94iJZWCdnhqBYXO+E8Sm9p3Ky6gduYOroAypvw==", + "license": "MIT", + "dependencies": { + "eslint": "^9.30" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -4621,7 +5316,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -4633,7 +5327,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } @@ -4724,16 +5417,16 @@ } }, "node_modules/sinon": { - "version": "17.0.1", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", - "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.0.tgz", + "integrity": "sha512-TOgRcwFPbfGtpqvZw+hyqJDvqfapr1qUlOizROIk4bBLjlsjlB00Pg6wMFXNtJRpu+eCZuVOaLatG7M8105kAw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^3.0.0", - "@sinonjs/fake-timers": "^11.2.2", - "@sinonjs/samsam": "^8.0.0", - "diff": "^5.1.0", - "nise": "^5.1.5", + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.5", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", "supports-color": "^7.2.0" }, "funding": { @@ -4741,20 +5434,12 @@ "url": "https://opencollective.com/sinon" } }, - "node_modules/sinon/node_modules/@sinonjs/fake-timers": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", - "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", - "dev": true, - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, "node_modules/sinon/node_modules/diff": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", - "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } @@ -5059,7 +5744,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, "engines": { "node": ">=8" }, @@ -5209,6 +5893,18 @@ "node": "*" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -5321,6 +6017,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5362,7 +6067,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, "dependencies": { "isexe": "^2.0.0" }, @@ -5396,6 +6100,15 @@ "@types/node": "*" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/workerpool": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", @@ -5580,7 +6293,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, "engines": { "node": ">=10" }, diff --git a/package.json b/package.json index 34abc60ef..17a51d03a 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "mocha": "^10.2.0", "nodemon": "^2.0.20", "nyc": "^15.1.0", - "sinon": "^17.0.1" + "sinon": "^21.0.0", + "rewire": "^9.0.0" } } diff --git a/server/Watcher.js b/server/Watcher.js index c5f64917b..ec41659c0 100644 --- a/server/Watcher.js +++ b/server/Watcher.js @@ -18,14 +18,14 @@ class FolderWatcher extends EventEmitter { constructor() { super() - /** @type {{id:string, name:string, libraryFolders:import('./models/Folder')[], paths:string[], watcher:Watcher[]}[]} */ + /** @type {{id:string, name:string, libraryFolders:import('./models/LibraryFolder')[], paths:string[], watcher:Watcher[]}[]} */ this.libraryWatchers = [] /** @type {PendingFileUpdate[]} */ this.pendingFileUpdates = [] this.pendingDelay = 10000 - /** @type {NodeJS.Timeout} */ + /** @type {NodeJS.Timeout | null} */ this.pendingTimeout = null - /** @type {Task} */ + /** @type {Task | null} */ this.pendingTask = null this.filesBeingAdded = new Set() @@ -36,7 +36,7 @@ class FolderWatcher extends EventEmitter { this.ignoreDirs = [] /** @type {string[]} */ this.pendingDirsToRemoveFromIgnore = [] - /** @type {NodeJS.Timeout} */ + /** @type {NodeJS.Timeout | null} */ this.removeFromIgnoreTimer = null this.disabled = false diff --git a/server/managers/LogManager.js b/server/managers/LogManager.js index 731dfe70a..d93fcef36 100644 --- a/server/managers/LogManager.js +++ b/server/managers/LogManager.js @@ -102,20 +102,20 @@ class LogManager { } /** - * - * @param {string} filename + * + * @param {string} filename */ async removeLogFile(filename) { const fullPath = Path.join(this.DailyLogPath, filename) const exists = await fs.pathExists(fullPath) if (!exists) { Logger.error(TAG, 'Invalid log dne ' + fullPath) - this.dailyLogFiles = this.dailyLogFiles.filter(dlf => dlf !== filename) + this.dailyLogFiles = this.dailyLogFiles.filter((dlf) => dlf !== filename) } else { try { await fs.unlink(fullPath) Logger.info(TAG, 'Removed daily log: ' + filename) - this.dailyLogFiles = this.dailyLogFiles.filter(dlf => dlf !== filename) + this.dailyLogFiles = this.dailyLogFiles.filter((dlf) => dlf !== filename) } catch (error) { Logger.error(TAG, 'Failed to unlink log file ' + fullPath) } @@ -123,8 +123,8 @@ class LogManager { } /** - * - * @param {LogObject} logObj + * + * @param {LogObject} logObj */ async logToFile(logObj) { // Fatal crashes get logged to a separate file @@ -152,8 +152,8 @@ class LogManager { } /** - * - * @param {LogObject} logObj + * + * @param {LogObject} logObj */ async logCrashToFile(logObj) { const line = JSON.stringify(logObj) + '\n' @@ -161,18 +161,18 @@ class LogManager { const logsDir = Path.join(global.MetadataPath, 'logs') await fs.ensureDir(logsDir) const crashLogPath = Path.join(logsDir, 'crash_logs.txt') - return fs.writeFile(crashLogPath, line, { flag: "a+" }).catch((error) => { + return fs.writeFile(crashLogPath, line, { flag: 'a+' }).catch((error) => { console.log('[LogManager] Appended crash log', error) }) } /** * Most recent 5000 daily logs - * - * @returns {string} + * + * @returns {LogObject[]} */ getMostRecentCurrentDailyLogs() { - return this.currentDailyLog?.logs.slice(-5000) || '' + return this.currentDailyLog?.logs.slice(-5000) || [] } } -module.exports = LogManager \ No newline at end of file +module.exports = LogManager diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 0fcbe6754..a23e44d93 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -16,3 +16,4 @@ Please add a record of every database migration that you create to this file. Th | v2.19.1 | v2.19.1-copy-title-to-library-items | Copies title and titleIgnorePrefix to the libraryItems table, creates update triggers and indices | | v2.19.4 | v2.19.4-improve-podcast-queries | Adds numEpisodes to podcasts, adds podcastId to mediaProgresses, copies podcast title to libraryItems | | v2.20.0 | v2.20.0-improve-author-sort-queries | Adds AuthorNames(FirstLast\|LastFirst) to libraryItems to improve author sort queries | +| v2.30.0 | v2.30.0-add-deviceId | Adds deviceId to libraryItems table to uniquely identify files in a filesystem | diff --git a/server/migrations/v2.30.0-add-deviceId.js b/server/migrations/v2.30.0-add-deviceId.js new file mode 100644 index 000000000..39cfc90fd --- /dev/null +++ b/server/migrations/v2.30.0-add-deviceId.js @@ -0,0 +1,193 @@ +const util = require('util') +const { Sequelize, DataTypes } = require('sequelize') +const fileUtils = require('../utils/fileUtils') +const LibraryItem = require('../models/LibraryItem') + +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a sequelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.30.0' +const migrationName = `${migrationVersion}-add-deviceId` +const loggerPrefix = `[${migrationVersion} migration]` + +// Migration constants +const libraryItemsTableName = 'libraryItems' +const columns = [{ name: 'deviceId', spec: { type: DataTypes.STRING, allowNull: true } }] +const columnNames = columns.map((column) => column.name).join(', ') + +/** + * This upward migration adds a deviceId column to the libraryItems table and populates it. + * It also creates an index on the ino, deviceId columns. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + const helper = new MigrationHelper(queryInterface, logger) + + // Upwards migration script + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + // Add authorNames columns to libraryItems table + await helper.addColumns() + + // Populate authorNames columns with the author names for each libraryItem + // TODO + await helper.populateColumnsFromSource() + + // Create indexes on the authorNames columns + await helper.addIndexes() + + // Add index on ino and deviceId to the podcastEpisodes table + await helper.addIndex('libraryItems', ['ino', 'deviceId']) + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration removes a deviceId column to the libraryItems table, * + * It also removes the index on ino and deviceId from the libraryItems table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + const helper = new MigrationHelper(queryInterface, logger) + + // Remove index on publishedAt from the podcastEpisodes table + await helper.removeIndex('libraryItems', ['ino', 'deviceId']) + + // Remove indexes on the authorNames columns + await helper.removeIndexes() + + // Remove authorNames columns from libraryItems table + await helper.removeColumns() + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +class MigrationHelper { + constructor(queryInterface, logger) { + this.queryInterface = queryInterface + this.logger = logger + } + + async addColumn(table, column, options) { + this.logger.info(`${loggerPrefix} adding column "${column}" to table "${table}"`) + const tableDescription = await this.queryInterface.describeTable(table) + if (!tableDescription[column]) { + await this.queryInterface.addColumn(table, column, options) + this.logger.info(`${loggerPrefix} added column "${column}" to table "${table}"`) + } else { + this.logger.info(`${loggerPrefix} column "${column}" already exists in table "${table}"`) + } + } + + async addColumns() { + this.logger.info(`${loggerPrefix} adding ${columnNames} columns to ${libraryItemsTableName} table`) + for (const column of columns) { + await this.addColumn(libraryItemsTableName, column.name, column.spec) + } + this.logger.info(`${loggerPrefix} added ${columnNames} columns to ${libraryItemsTableName} table`) + } + + async removeColumn(table, column) { + this.logger.info(`${loggerPrefix} removing column "${column}" from table "${table}"`) + const tableDescription = await this.queryInterface.describeTable(table) + if (tableDescription[column]) { + await this.queryInterface.sequelize.query(`ALTER TABLE ${table} DROP COLUMN ${column}`) + this.logger.info(`${loggerPrefix} removed column "${column}" from table "${table}"`) + } else { + this.logger.info(`${loggerPrefix} column "${column}" does not exist in table "${table}"`) + } + } + + async removeColumns() { + this.logger.info(`${loggerPrefix} removing ${columnNames} columns from ${libraryItemsTableName} table`) + for (const column of columns) { + await this.removeColumn(libraryItemsTableName, column.name) + } + this.logger.info(`${loggerPrefix} removed ${columnNames} columns from ${libraryItemsTableName} table`) + } + // populate from existing files on filesystem + async populateColumnsFromSource() { + this.logger.info(`${loggerPrefix} populating ${columnNames} columns in ${libraryItemsTableName} table`) + + // list all libraryItems + /** @type {[[LibraryItem], any]} */ + const [libraryItems, metadata] = await this.queryInterface.sequelize.query('SELECT * FROM libraryItems') + // load file stats for all libraryItems + libraryItems.forEach(async (item) => { + const deviceId = await fileUtils.getDeviceId(item.path) + // set deviceId for each libraryItem + await this.queryInterface.sequelize.query( + `UPDATE :libraryItemsTableName + SET (deviceId) = (:deviceId) + WHERE id = :id`, + { + replacements: { + libraryItemsTableName: libraryItemsTableName, + deviceId: deviceId, + id: item.id + } + } + ) + }) + + this.logger.info(`${loggerPrefix} populated ${columnNames} columns in ${libraryItems} table`) + } + + async addIndex(tableName, columns) { + const columnString = columns.map((column) => util.inspect(column)).join(', ') + const indexName = convertToSnakeCase(`${tableName}_${columns.map((column) => (typeof column === 'string' ? column : column.name)).join('_')}`) + try { + this.logger.info(`${loggerPrefix} adding index on [${columnString}] to table ${tableName}. index name: ${indexName}"`) + await this.queryInterface.addIndex(tableName, columns) + this.logger.info(`${loggerPrefix} added index on [${columnString}] to table ${tableName}. index name: ${indexName}"`) + } catch (error) { + if (error.name === 'SequelizeDatabaseError' && error.message.includes('already exists')) { + this.logger.info(`${loggerPrefix} index [${columnString}] for table "${tableName}" already exists`) + } else { + throw error + } + } + } + + async addIndexes() { + for (const column of columns) { + await this.addIndex(libraryItemsTableName, ['libraryId', 'mediaType', { name: column.name, collate: 'NOCASE' }]) + } + } + + async removeIndex(tableName, columns) { + this.logger.info(`${loggerPrefix} removing index [${columns.join(', ')}] from table "${tableName}"`) + await this.queryInterface.removeIndex(tableName, columns) + this.logger.info(`${loggerPrefix} removed index [${columns.join(', ')}] from table "${tableName}"`) + } + + async removeIndexes() { + for (const column of columns) { + await this.removeIndex(libraryItemsTableName, ['libraryId', 'mediaType', column.name]) + } + } +} +/** + * Utility function to convert a string to snake case, e.g. "titleIgnorePrefix" -> "title_ignore_prefix" + * + * @param {string} str - the string to convert to snake case. + * @returns {string} - the string in snake case. + */ +function convertToSnakeCase(str) { + return str.replace(/([A-Z])/g, '_$1').toLowerCase() +} + +module.exports = { up, down, migrationName } diff --git a/server/models/Book.js b/server/models/Book.js index 96371f3a2..cb426585b 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -8,10 +8,11 @@ const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilter /** * @typedef EBookFileObject * @property {string} ino + * @property {string} deviceId * @property {string} ebookFormat * @property {number} addedAt * @property {number} updatedAt - * @property {{filename:string, ext:string, path:string, relPath:strFing, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata + * @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata */ /** @@ -45,6 +46,7 @@ const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilter * @typedef AudioFileObject * @property {number} index * @property {string} ino + * @property {string} deviceId * @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata * @property {number} addedAt * @property {number} updatedAt diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 16a521615..982c26d9a 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -11,6 +11,7 @@ const Podcast = require('./Podcast') /** * @typedef LibraryFileObject * @property {string} ino + * @property {string} deviceId * @property {boolean} isSupplementary * @property {number} addedAt * @property {number} updatedAt @@ -33,6 +34,8 @@ class LibraryItem extends Model { /** @type {string} */ this.ino /** @type {string} */ + this.deviceId + /** @type {string} */ this.path /** @type {string} */ this.relPath @@ -237,7 +240,7 @@ class LibraryItem extends Model { * @param {import('sequelize').WhereOptions} where * @param {import('sequelize').BindOrReplacements} [replacements] * @param {import('sequelize').IncludeOptions} [include] - * @returns {Promise} + * @returns {Promise} */ static async findOneExpanded(where, replacements = null, include = null) { const libraryItem = await this.findOne({ @@ -289,7 +292,7 @@ class LibraryItem extends Model { * @param {import('./Library')} library * @param {import('./User')} user * @param {object} options - * @returns {{ libraryItems:Object[], count:number }} + * @returns {Promise<{ libraryItems:Object[], count:number }>} */ static async getByFilterAndSort(library, user, options) { let start = Date.now() @@ -670,6 +673,7 @@ class LibraryItem extends Model { primaryKey: true }, ino: DataTypes.STRING, + deviceId: DataTypes.STRING, path: DataTypes.STRING, relPath: DataTypes.STRING, mediaId: DataTypes.UUID, @@ -694,6 +698,9 @@ class LibraryItem extends Model { sequelize, modelName: 'libraryItem', indexes: [ + { + fields: ['ino', 'deviceId'] + }, { fields: ['createdAt'] }, diff --git a/server/objects/Task.js b/server/objects/Task.js index e6fb39636..b146b7bfd 100644 --- a/server/objects/Task.js +++ b/server/objects/Task.js @@ -113,7 +113,7 @@ class Task { /** * Set task as finished * - * @param {TaskString} [newDescriptionString] update description + * @param {TaskString | null} [newDescriptionString] update description * @param {boolean} [clearDescription] clear description */ setFinished(newDescriptionString = null, clearDescription = false) { diff --git a/server/objects/files/AudioFile.js b/server/objects/files/AudioFile.js index c0c425ba3..b5e3e2fd0 100644 --- a/server/objects/files/AudioFile.js +++ b/server/objects/files/AudioFile.js @@ -6,6 +6,7 @@ class AudioFile { constructor(data) { this.index = null this.ino = null + this.deviceId = null /** @type {FileMetadata} */ this.metadata = null this.addedAt = null @@ -44,6 +45,7 @@ class AudioFile { return { index: this.index, ino: this.ino, + deviceId: this.deviceId, metadata: this.metadata.toJSON(), addedAt: this.addedAt, updatedAt: this.updatedAt, @@ -69,9 +71,13 @@ class AudioFile { } } + /** + * @param {{ index: any; ino: any; deviceId: any; metadata: any; addedAt: any; updatedAt: any; manuallyVerified: any; exclude: any; error: null; trackNumFromMeta: any; discNumFromMeta: any; trackNumFromFilename: any; cdNumFromFilename: undefined; discNumFromFilename: any; format: any; duration: any; bitRate: any; language: any; codec: null; timeBase: any; channels: any; channelLayout: any; chapters: any[]; embeddedCoverArt: null; metaTags: any; }} data + */ construct(data) { this.index = data.index this.ino = data.ino + this.deviceId = data.deviceId this.metadata = new FileMetadata(data.metadata || {}) this.addedAt = data.addedAt this.updatedAt = data.updatedAt @@ -112,6 +118,7 @@ class AudioFile { // New scanner creates AudioFile from AudioFileScanner setDataFromProbe(libraryFile, probeData) { this.ino = libraryFile.ino || null + this.deviceId = libraryFile.deviceId || null if (libraryFile.metadata instanceof FileMetadata) { this.metadata = libraryFile.metadata.clone() @@ -137,7 +144,7 @@ class AudioFile { syncChapters(updatedChapters) { if (this.chapters.length !== updatedChapters.length) { - this.chapters = updatedChapters.map(ch => ({ ...ch })) + this.chapters = updatedChapters.map((ch) => ({ ...ch })) return true } else if (updatedChapters.length === 0) { if (this.chapters.length > 0) { @@ -154,7 +161,7 @@ class AudioFile { } } if (hasUpdates) { - this.chapters = updatedChapters.map(ch => ({ ...ch })) + this.chapters = updatedChapters.map((ch) => ({ ...ch })) } return hasUpdates } @@ -164,8 +171,8 @@ class AudioFile { } /** - * - * @param {AudioFile} scannedAudioFile + * + * @param {AudioFile} scannedAudioFile * @returns {boolean} true if updates were made */ updateFromScan(scannedAudioFile) { @@ -196,4 +203,4 @@ class AudioFile { return hasUpdated } } -module.exports = AudioFile \ No newline at end of file +module.exports = AudioFile diff --git a/server/objects/files/EBookFile.js b/server/objects/files/EBookFile.js index ae40dc884..28ac5c16b 100644 --- a/server/objects/files/EBookFile.js +++ b/server/objects/files/EBookFile.js @@ -1,8 +1,12 @@ const FileMetadata = require('../metadata/FileMetadata') class EBookFile { + /** + * @param {{ ino: any; deviceId: any; isSupplementary?: boolean; addedAt?: number; updatedAt?: number; metadata?: { filename: string; ext: string; path: string; relPath: string; size: number; mtimeMs: number; ctimeMs: number; birthtimeMs: number; }; libraryFolderId?: any; libraryId?: any; mediaType?: any; mtimeMs?: any; ctimeMs?: any; birthtimeMs?: any; path?: any; relPath?: any; isFile?: any; mediaMetadata?: any; libraryFiles?: any; }} file + */ constructor(file) { this.ino = null + this.deviceId = null this.metadata = null this.ebookFormat = null this.addedAt = null @@ -13,8 +17,12 @@ class EBookFile { } } + /** + * @param {{ ino: any; deviceId: any; isSupplementary?: boolean | undefined; addedAt: any; updatedAt: any; metadata: any; libraryFolderId?: any; libraryId?: any; mediaType?: any; mtimeMs?: any; ctimeMs?: any; birthtimeMs?: any; path?: any; relPath?: any; isFile?: any; mediaMetadata?: any; libraryFiles?: any; ebookFormat?: any; }} file + */ construct(file) { this.ino = file.ino + this.deviceId = file.deviceId this.metadata = new FileMetadata(file.metadata) this.ebookFormat = file.ebookFormat || this.metadata.format this.addedAt = file.addedAt @@ -24,6 +32,7 @@ class EBookFile { toJSON() { return { ino: this.ino, + deviceId: this.deviceId, metadata: this.metadata.toJSON(), ebookFormat: this.ebookFormat, addedAt: this.addedAt, @@ -37,6 +46,7 @@ class EBookFile { setData(libraryFile) { this.ino = libraryFile.ino + this.deviceId = libraryFile.deviceId this.metadata = libraryFile.metadata.clone() this.ebookFormat = libraryFile.metadata.format this.addedAt = Date.now() @@ -58,4 +68,4 @@ class EBookFile { return hasUpdated } } -module.exports = EBookFile \ No newline at end of file +module.exports = EBookFile diff --git a/server/objects/files/LibraryFile.js b/server/objects/files/LibraryFile.js index 8669e3876..f58da0914 100644 --- a/server/objects/files/LibraryFile.js +++ b/server/objects/files/LibraryFile.js @@ -1,11 +1,15 @@ const Path = require('path') -const { getFileTimestampsWithIno, filePathToPOSIX } = require('../../utils/fileUtils') +const fileUtils = require('../../utils/fileUtils') const globals = require('../../utils/globals') const FileMetadata = require('../metadata/FileMetadata') class LibraryFile { + /** + * @param {{ ino: any; deviceId: any; metadata?: { filename: any; ext: any; path: any; relPath: any; size: any; mtimeMs: any; ctimeMs: any; birthtimeMs: any; } | { filename: string; ext: string; path: string; relPath: string; size: number; mtimeMs: number; ctimeMs: number; birthtimeMs: number; } | null; isSupplementary?: any; addedAt?: any; updatedAt?: any; fileType?: string; libraryFolderId?: any; libraryId?: any; mediaType?: any; mtimeMs?: any; ctimeMs?: any; birthtimeMs?: any; path?: any; relPath?: any; isFile?: any; mediaMetadata?: any; libraryFiles?: any; } | undefined} [file] + */ constructor(file) { this.ino = null + this.deviceId = null this.metadata = null this.isSupplementary = null this.addedAt = null @@ -18,6 +22,7 @@ class LibraryFile { construct(file) { this.ino = file.ino + this.deviceId = file.deviceId this.metadata = new FileMetadata(file.metadata) this.isSupplementary = file.isSupplementary === undefined ? null : file.isSupplementary this.addedAt = file.addedAt @@ -27,7 +32,8 @@ class LibraryFile { toJSON() { return { ino: this.ino, - metadata: this.metadata.toJSON(), + deviceId: this.deviceId, + metadata: this.metadata ? this.metadata.toJSON() : null, isSupplementary: this.isSupplementary, addedAt: this.addedAt, updatedAt: this.updatedAt, @@ -40,11 +46,13 @@ class LibraryFile { } get fileType() { - if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image' - if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio' - if (globals.SupportedEbookTypes.includes(this.metadata.format)) return 'ebook' - if (globals.TextFileTypes.includes(this.metadata.format)) return 'text' - if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata' + if (this.metadata) { + if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image' + if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio' + if (globals.SupportedEbookTypes.includes(this.metadata.format)) return 'ebook' + if (globals.TextFileTypes.includes(this.metadata.format)) return 'text' + if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata' + } return 'unknown' } @@ -61,14 +69,15 @@ class LibraryFile { } async setDataFromPath(path, relPath) { - var fileTsData = await getFileTimestampsWithIno(path) + var fileTsData = await fileUtils.getFileTimestampsWithIno(path) var fileMetadata = new FileMetadata() fileMetadata.setData(fileTsData) fileMetadata.filename = Path.basename(relPath) - fileMetadata.path = filePathToPOSIX(path) - fileMetadata.relPath = filePathToPOSIX(relPath) + fileMetadata.path = fileUtils.filePathToPOSIX(path) + fileMetadata.relPath = fileUtils.filePathToPOSIX(relPath) fileMetadata.ext = Path.extname(relPath) this.ino = fileTsData.ino + this.deviceId = fileTsData.dev this.metadata = fileMetadata this.addedAt = Date.now() this.updatedAt = Date.now() diff --git a/server/scanner/LibraryItemScanData.js b/server/scanner/LibraryItemScanData.js index d5a4a7a29..c6183a830 100644 --- a/server/scanner/LibraryItemScanData.js +++ b/server/scanner/LibraryItemScanData.js @@ -2,14 +2,17 @@ const packageJson = require('../../package.json') const { LogLevel } = require('../utils/constants') const LibraryItem = require('../models/LibraryItem') const globals = require('../utils/globals') +const LibraryFile = require('../objects/files/LibraryFile') +const LibraryScan = require('./LibraryScan') +const ScanLogger = require('./ScanLogger') class LibraryItemScanData { /** - * @typedef LibraryFileModifiedObject + * @typedef {Object} LibraryFileModifiedObject * @property {LibraryItem.LibraryFileObject} old * @property {LibraryItem.LibraryFileObject} new + * @param {{ libraryFolderId: any; libraryId: any; mediaType: any; ino: any; deviceId: any; mtimeMs: any; ctimeMs: any; birthtimeMs: any; path: any; relPath: any; isFile: any; mediaMetadata: any; libraryFiles: any; }} data */ - constructor(data) { /** @type {string} */ this.libraryFolderId = data.libraryFolderId @@ -19,6 +22,8 @@ class LibraryItemScanData { this.mediaType = data.mediaType /** @type {string} */ this.ino = data.ino + /** @type {string} */ + this.deviceId = data.deviceId /** @type {number} */ this.mtimeMs = data.mtimeMs /** @type {number} */ @@ -54,9 +59,10 @@ class LibraryItemScanData { */ get libraryItemObject() { let size = 0 - this.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) + this.libraryFiles.forEach((lf) => (size += !isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) return { ino: this.ino, + deviceId: this.deviceId, path: this.path, relPath: this.relPath, mediaType: this.mediaType, @@ -80,107 +86,107 @@ class LibraryItemScanData { /** @type {boolean} */ get hasAudioFileChanges() { - return (this.audioLibraryFilesRemoved.length + this.audioLibraryFilesAdded.length + this.audioLibraryFilesModified.length) > 0 + return this.audioLibraryFilesRemoved.length + this.audioLibraryFilesAdded.length + this.audioLibraryFilesModified.length > 0 } /** @type {LibraryFileModifiedObject[]} */ get audioLibraryFilesModified() { - return this.libraryFilesModified.filter(lf => globals.SupportedAudioTypes.includes(lf.old.metadata.ext?.slice(1).toLowerCase() || '')) + return this.libraryFilesModified.filter((lf) => globals.SupportedAudioTypes.includes(lf.old.metadata.ext?.slice(1).toLowerCase() || '')) } /** @type {LibraryItem.LibraryFileObject[]} */ get audioLibraryFilesRemoved() { - return this.libraryFilesRemoved.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) + return this.libraryFilesRemoved.filter((lf) => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) } /** @type {LibraryItem.LibraryFileObject[]} */ get audioLibraryFilesAdded() { - return this.libraryFilesAdded.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) + return this.libraryFilesAdded.filter((lf) => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) } /** @type {LibraryItem.LibraryFileObject[]} */ get audioLibraryFiles() { - return this.libraryFiles.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) + return this.libraryFiles.filter((lf) => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) } /** @type {LibraryFileModifiedObject[]} */ get imageLibraryFilesModified() { - return this.libraryFilesModified.filter(lf => globals.SupportedImageTypes.includes(lf.old.metadata.ext?.slice(1).toLowerCase() || '')) + return this.libraryFilesModified.filter((lf) => globals.SupportedImageTypes.includes(lf.old.metadata.ext?.slice(1).toLowerCase() || '')) } /** @type {LibraryItem.LibraryFileObject[]} */ get imageLibraryFilesRemoved() { - return this.libraryFilesRemoved.filter(lf => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) + return this.libraryFilesRemoved.filter((lf) => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) } /** @type {LibraryItem.LibraryFileObject[]} */ get imageLibraryFilesAdded() { - return this.libraryFilesAdded.filter(lf => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) + return this.libraryFilesAdded.filter((lf) => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) } /** @type {LibraryItem.LibraryFileObject[]} */ get imageLibraryFiles() { - return this.libraryFiles.filter(lf => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) + return this.libraryFiles.filter((lf) => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) } /** @type {LibraryFileModifiedObject[]} */ get ebookLibraryFilesModified() { - return this.libraryFilesModified.filter(lf => globals.SupportedEbookTypes.includes(lf.old.metadata.ext?.slice(1).toLowerCase() || '')) + return this.libraryFilesModified.filter((lf) => globals.SupportedEbookTypes.includes(lf.old.metadata.ext?.slice(1).toLowerCase() || '')) } /** @type {LibraryItem.LibraryFileObject[]} */ get ebookLibraryFilesRemoved() { - return this.libraryFilesRemoved.filter(lf => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) + return this.libraryFilesRemoved.filter((lf) => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) } /** @type {LibraryItem.LibraryFileObject[]} */ get ebookLibraryFilesAdded() { - return this.libraryFilesAdded.filter(lf => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) + return this.libraryFilesAdded.filter((lf) => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) } /** @type {LibraryItem.LibraryFileObject[]} */ get ebookLibraryFiles() { - return this.libraryFiles.filter(lf => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) + return this.libraryFiles.filter((lf) => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) } /** @type {LibraryItem.LibraryFileObject} */ get descTxtLibraryFile() { - return this.libraryFiles.find(lf => lf.metadata.filename === 'desc.txt') + return this.libraryFiles.find((lf) => lf.metadata.filename === 'desc.txt') } /** @type {LibraryItem.LibraryFileObject} */ get readerTxtLibraryFile() { - return this.libraryFiles.find(lf => lf.metadata.filename === 'reader.txt') + return this.libraryFiles.find((lf) => lf.metadata.filename === 'reader.txt') } /** @type {LibraryItem.LibraryFileObject} */ get metadataAbsLibraryFile() { - return this.libraryFiles.find(lf => lf.metadata.filename === 'metadata.abs') + return this.libraryFiles.find((lf) => lf.metadata.filename === 'metadata.abs') } /** @type {LibraryItem.LibraryFileObject} */ get metadataJsonLibraryFile() { - return this.libraryFiles.find(lf => lf.metadata.filename === 'metadata.json') + return this.libraryFiles.find((lf) => lf.metadata.filename === 'metadata.json') } /** @type {LibraryItem.LibraryFileObject} */ get metadataOpfLibraryFile() { - return this.libraryFiles.find(lf => lf.metadata.ext.toLowerCase() === '.opf') + return this.libraryFiles.find((lf) => lf.metadata.ext.toLowerCase() === '.opf') } /** @type {LibraryItem.LibraryFileObject} */ get metadataNfoLibraryFile() { - return this.libraryFiles.find(lf => lf.metadata.ext.toLowerCase() === '.nfo') + return this.libraryFiles.find((lf) => lf.metadata.ext.toLowerCase() === '.nfo') } /** - * - * @param {LibraryItem} existingLibraryItem - * @param {import('./LibraryScan')} libraryScan - * @returns {boolean} true if changes found + * + * @param {LibraryItem} existingLibraryItem + * @param {import('./LibraryScan') | import('./ScanLogger')} libraryScan + * @returns {Promise} true if changes found */ async checkLibraryItemData(existingLibraryItem, libraryScan) { - const keysToCompare = ['libraryFolderId', 'ino', 'path', 'relPath', 'isFile'] + const keysToCompare = ['libraryFolderId', 'ino', 'deviceId', 'path', 'relPath', 'isFile'] this.hasChanges = false this.hasPathChange = false for (const key of keysToCompare) { @@ -219,28 +225,23 @@ class LibraryItemScanData { this.libraryFilesRemoved = [] this.libraryFilesModified = [] - let libraryFilesAdded = this.libraryFiles.map(lf => lf) + let libraryFilesAdded = this.libraryFiles.map((lf) => lf) for (const existingLibraryFile of existingLibraryItem.libraryFiles) { // Find matching library file using path first and fallback to using inode value - let matchingLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === existingLibraryFile.metadata.path) - if (!matchingLibraryFile) { - matchingLibraryFile = this.libraryFiles.find(lf => lf.ino === existingLibraryFile.ino) - if (matchingLibraryFile) { - libraryScan.addLog(LogLevel.INFO, `Library file with path "${existingLibraryFile.metadata.path}" not found, but found file with matching inode value "${existingLibraryFile.ino}" at path "${matchingLibraryFile.metadata.path}"`) - } - } + let matchingLibraryFile = this.findMatchingLibraryFileByPathOrInodeAndDeviceId(existingLibraryFile, libraryScan) - if (!matchingLibraryFile) { // Library file removed + if (!matchingLibraryFile) { + // Library file removed libraryScan.addLog(LogLevel.INFO, `Library file "${existingLibraryFile.metadata.path}" was removed from library item "${existingLibraryItem.relPath}"`) this.libraryFilesRemoved.push(existingLibraryFile) - existingLibraryItem.libraryFiles = existingLibraryItem.libraryFiles.filter(lf => lf !== existingLibraryFile) + existingLibraryItem.libraryFiles = existingLibraryItem.libraryFiles.filter((lf) => lf !== existingLibraryFile) this.hasChanges = true } else { - libraryFilesAdded = libraryFilesAdded.filter(lf => lf !== matchingLibraryFile) + libraryFilesAdded = libraryFilesAdded.filter((lf) => lf !== matchingLibraryFile) let existingLibraryFileBefore = structuredClone(existingLibraryFile) - if (this.compareUpdateLibraryFile(existingLibraryItem.path, existingLibraryFile, matchingLibraryFile, libraryScan)) { - this.libraryFilesModified.push({old: existingLibraryFileBefore, new: existingLibraryFile}) + if (LibraryItemScanData.compareUpdateLibraryFile(existingLibraryItem.path, existingLibraryFile, matchingLibraryFile, libraryScan)) { + this.libraryFilesModified.push({ old: existingLibraryFileBefore, new: existingLibraryFile }) this.hasChanges = true } } @@ -263,7 +264,7 @@ class LibraryItemScanData { if (this.hasChanges) { existingLibraryItem.size = 0 - existingLibraryItem.libraryFiles.forEach((lf) => existingLibraryItem.size += lf.metadata.size) + existingLibraryItem.libraryFiles.forEach((lf) => (existingLibraryItem.size += lf.metadata.size)) existingLibraryItem.lastScan = Date.now() existingLibraryItem.lastScanVersion = packageJson.version @@ -274,25 +275,25 @@ class LibraryItemScanData { existingLibraryItem.changed('libraryFiles', true) } await existingLibraryItem.save() - return true } - return false + return this.hasChanges } /** * Update existing library file with scanned in library file data * @param {string} libraryItemPath - * @param {LibraryItem.LibraryFileObject} existingLibraryFile - * @param {import('../objects/files/LibraryFile')} scannedLibraryFile - * @param {import('./LibraryScan')} libraryScan + * @param {LibraryItem.LibraryFileObject} existingLibraryFile + * @param {import('../objects/files/LibraryFile')} scannedLibraryFile + * @param {import('./LibraryScan') | import('./ScanLogger')} libraryScan * @returns {boolean} false if no changes */ - compareUpdateLibraryFile(libraryItemPath, existingLibraryFile, scannedLibraryFile, libraryScan) { + static compareUpdateLibraryFile(libraryItemPath, existingLibraryFile, scannedLibraryFile, libraryScan) { let hasChanges = false - if (existingLibraryFile.ino !== scannedLibraryFile.ino) { + if (existingLibraryFile.ino !== scannedLibraryFile.ino && existingLibraryFile.deviceId !== scannedLibraryFile.deviceId) { existingLibraryFile.ino = scannedLibraryFile.ino + existingLibraryFile.deviceId = scannedLibraryFile.deviceId hasChanges = true } @@ -315,40 +316,57 @@ class LibraryItemScanData { return hasChanges } + /** + * @returns {LibraryFile | undefined} if [existingLibraryFile] matches an existing libraryFile + * @param {LibraryItem.LibraryFileObject} [existingLibraryFile] + * @param {LibraryScan | ScanLogger} [libraryScan] + */ + findMatchingLibraryFileByPathOrInodeAndDeviceId(existingLibraryFile, libraryScan) { + if (!existingLibraryFile) return + let matchingLibraryFile = this.libraryFiles.find((lf) => lf.metadata.path === existingLibraryFile.metadata.path) + if (!matchingLibraryFile) { + matchingLibraryFile = this.libraryFiles.find((lf) => lf.ino === existingLibraryFile.ino && lf.deviceId === existingLibraryFile.deviceId) + if (matchingLibraryFile) { + libraryScan && libraryScan.addLog(LogLevel.INFO, `Library file with path "${existingLibraryFile.metadata.path}" not found, but found file with matching inode value "${existingLibraryFile.ino}" at path "${matchingLibraryFile.metadata.path}"`) + } + } + return matchingLibraryFile + } + /** * Check if existing audio file on Book was removed - * @param {import('../models/Book').AudioFileObject} existingAudioFile + * @param {import('../models/Book').AudioFileObject} existingAudioFile * @returns {boolean} true if audio file was removed */ checkAudioFileRemoved(existingAudioFile) { if (!this.audioLibraryFilesRemoved.length) return false // First check exact path - if (this.audioLibraryFilesRemoved.some(af => af.metadata.path === existingAudioFile.metadata.path)) { + if (this.audioLibraryFilesRemoved.some((af) => af.metadata.path === existingAudioFile.metadata.path)) { return true } // Fallback to check inode value - return this.audioLibraryFilesRemoved.some(af => af.ino === existingAudioFile.ino) + return this.audioLibraryFilesRemoved.some((af) => af.ino === existingAudioFile.ino && af.deviceId === existingAudioFile.deviceId) } /** * Check if existing ebook file on Book was removed - * @param {import('../models/Book').EBookFileObject} ebookFile + * @param {import('../models/Book').EBookFileObject} ebookFile * @returns {boolean} true if ebook file was removed */ checkEbookFileRemoved(ebookFile) { - if (!this.ebookLibraryFiles.length) return true + if (!this.ebookLibraryFilesRemoved.length) return false - if (this.ebookLibraryFiles.some(lf => lf.metadata.path === ebookFile.metadata.path)) { - return false + if (this.ebookLibraryFilesRemoved.some((lf) => lf.metadata.path === ebookFile.metadata.path)) { + return true } - return !this.ebookLibraryFiles.some(lf => lf.ino === ebookFile.ino) + return this.ebookLibraryFilesRemoved.some((lf) => lf.ino === ebookFile.ino && lf.deviceId === ebookFile.deviceId) } /** * Set data parsed from filenames - * - * @param {Object} bookMetadata + * + * @param {Object} bookMetadata */ setBookMetadataFromFilenames(bookMetadata) { const keysToMap = ['title', 'subtitle', 'publishedYear', 'asin'] @@ -374,4 +392,4 @@ class LibraryItemScanData { } } } -module.exports = LibraryItemScanData \ No newline at end of file +module.exports = LibraryItemScanData diff --git a/server/scanner/LibraryItemScanner.js b/server/scanner/LibraryItemScanner.js index 501df4274..c9bb787aa 100644 --- a/server/scanner/LibraryItemScanner.js +++ b/server/scanner/LibraryItemScanner.js @@ -23,7 +23,7 @@ class LibraryItemScanner { * Scan single library item * * @param {string} libraryItemId - * @param {{relPath:string, path:string}} [updateLibraryItemDetails] used by watcher when item folder was renamed + * @param {{relPath:string, path:string, isFile: boolean}} [updateLibraryItemDetails] used by watcher when item folder was renamed * @returns {number} ScanResult */ async scanLibraryItem(libraryItemId, updateLibraryItemDetails = null) { @@ -139,24 +139,11 @@ class LibraryItemScanner { const newLibraryFile = new LibraryFile() // fileItem.path is the relative path await newLibraryFile.setDataFromPath(fileItem.fullpath, fileItem.path) + // TODO: BUGBUG - this is pushing the object, not a JSON string of the object like elsewhere libraryFiles.push(newLibraryFile) } - const libraryItemStats = await fileUtils.getFileTimestampsWithIno(libraryItemData.path) - return new LibraryItemScanData({ - libraryFolderId: folder.id, - libraryId: library.id, - mediaType: library.mediaType, - ino: libraryItemStats.ino, - mtimeMs: libraryItemStats.mtimeMs || 0, - ctimeMs: libraryItemStats.ctimeMs || 0, - birthtimeMs: libraryItemStats.birthtimeMs || 0, - path: libraryItemData.path, - relPath: libraryItemData.relPath, - isFile: isSingleMediaItem, - mediaMetadata: libraryItemData.mediaMetadata || null, - libraryFiles - }) + return await buildLibraryItemScanData(libraryItemData, folder, library, isSingleMediaItem, libraryFiles) } /** @@ -201,7 +188,7 @@ class LibraryItemScanner { * @param {import('../models/Library')} library * @param {import('../models/LibraryFolder')} folder * @param {boolean} isSingleMediaItem - * @returns {Promise} ScanResult + * @returns {Promise} ScanResult */ async scanPotentialNewLibraryItem(libraryItemPath, library, folder, isSingleMediaItem) { const libraryItemScanData = await this.getLibraryItemScanData(libraryItemPath, library, folder, isSingleMediaItem) @@ -219,3 +206,29 @@ class LibraryItemScanner { } } module.exports = new LibraryItemScanner() + +/** + * @param {{ path?: any; relPath?: any; mediaMetadata?: any; }} libraryItemData + * @param {import("../models/LibraryFolder")} folder + * @param {import("../models/Library")} library + * @param {boolean} isSingleMediaItem + * @param {LibraryFile[]} libraryFiles + */ +async function buildLibraryItemScanData(libraryItemData, folder, library, isSingleMediaItem, libraryFiles) { + const libraryItemStats = await fileUtils.getFileTimestampsWithIno(libraryItemData.path) + return new LibraryItemScanData({ + libraryFolderId: folder.id, + libraryId: library.id, + mediaType: library.mediaType, + ino: libraryItemStats.ino, + deviceId: libraryItemStats.dev, + mtimeMs: libraryItemStats.mtimeMs || 0, + ctimeMs: libraryItemStats.ctimeMs || 0, + birthtimeMs: libraryItemStats.birthtimeMs || 0, + path: libraryItemData.path, + relPath: libraryItemData.relPath, + isFile: isSingleMediaItem, + mediaMetadata: libraryItemData.mediaMetadata || null, + libraryFiles + }) +} diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index 640c82d76..e9dacc9bd 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -297,7 +297,7 @@ class LibraryScanner { * Get scan data for library folder * @param {import('../models/Library')} library * @param {import('../models/LibraryFolder')} folder - * @returns {LibraryItemScanData[]} + * @returns {Promise} */ async scanFolder(library, folder) { const folderPath = fileUtils.filePathToPOSIX(folder.path) @@ -344,22 +344,7 @@ class LibraryScanner { continue } - items.push( - new LibraryItemScanData({ - libraryFolderId: folder.id, - libraryId: folder.libraryId, - mediaType: library.mediaType, - ino: libraryItemFolderStats.ino, - mtimeMs: libraryItemFolderStats.mtimeMs || 0, - ctimeMs: libraryItemFolderStats.ctimeMs || 0, - birthtimeMs: libraryItemFolderStats.birthtimeMs || 0, - path: libraryItemData.path, - relPath: libraryItemData.relPath, - isFile, - mediaMetadata: libraryItemData.mediaMetadata || null, - libraryFiles: fileObjs - }) - ) + items.push(createLibraryItemScanData(folder, library, libraryItemFolderStats, libraryItemData, isFile, fileObjs)) } return items } @@ -642,12 +627,25 @@ class LibraryScanner { } module.exports = new LibraryScanner() +/** + * @param {import("../models/LibraryItem") | LibraryItemScanData} libraryItem1 + * @param {import("../models/LibraryItem") | LibraryItemScanData} libraryItem2 + */ function ItemToFileInoMatch(libraryItem1, libraryItem2) { - return libraryItem1.isFile && libraryItem2.libraryFiles.some((lf) => lf.ino === libraryItem1.ino) + return ( + libraryItem1.isFile && + libraryItem2.libraryFiles.some((lf) => { + return lf.ino === libraryItem1.ino && lf.deviceId === libraryItem1.deviceId + }) + ) } +/** + * @param {LibraryItemScanData} libraryItem1 + * @param {import("../models/LibraryItem")} libraryItem2 + */ function ItemToItemInoMatch(libraryItem1, libraryItem2) { - return libraryItem1.ino === libraryItem2.ino + return libraryItem1.ino === libraryItem2.ino && libraryItem1.deviceId === libraryItem2.deviceId } function hasAudioFiles(fileUpdateGroup, itemDir) { @@ -658,54 +656,111 @@ function isSingleMediaFile(fileUpdateGroup, itemDir) { return itemDir === fileUpdateGroup[itemDir] } +/** + * @param {UUIDV4} libraryId + * @param {string} fullPath + * @returns {Promise} library item that matches + */ async function findLibraryItemByItemToItemInoMatch(libraryId, fullPath) { const ino = await fileUtils.getIno(fullPath) + const deviceId = await fileUtils.getDeviceId(fullPath) if (!ino) return null const existingLibraryItem = await Database.libraryItemModel.findOneExpanded({ libraryId: libraryId, - ino: ino + ino: ino, + deviceId: deviceId }) if (existingLibraryItem) Logger.debug(`[LibraryScanner] Found library item with matching inode "${ino}" at path "${existingLibraryItem.path}"`) return existingLibraryItem } +/** + * @param {UUIDV4} libraryId + * @param {string} fullPath + * @param {boolean} isSingleMedia + * @returns {Promise} library item that matches + */ async function findLibraryItemByItemToFileInoMatch(libraryId, fullPath, isSingleMedia) { if (!isSingleMedia) return null // check if it was moved from another folder by comparing the ino to the library files const ino = await fileUtils.getIno(fullPath) + const deviceId = await fileUtils.getDeviceId(fullPath) if (!ino) return null const existingLibraryItem = await Database.libraryItemModel.findOneExpanded( [ { libraryId: libraryId }, - sequelize.where(sequelize.literal('(SELECT count(*) FROM json_each(libraryFiles) WHERE json_valid(json_each.value) AND json_each.value->>"$.ino" = :inode)'), { + sequelize.where(sequelize.literal('(SELECT count(*) FROM json_each(libraryFiles) WHERE json_valid(json_each.value) AND json_each.value->>"$.ino" = :inode AND json_each.value->>"$.deviceId" = :deviceId)'), { [sequelize.Op.gt]: 0 }) ], { - inode: ino + inode: ino, + deviceId: deviceId } ) if (existingLibraryItem) Logger.debug(`[LibraryScanner] Found library item with a library file matching inode "${ino}" at path "${existingLibraryItem.path}"`) return existingLibraryItem } +/** + * @param {UUIDV4} libraryId + * @param {string} fullPath + * @param {boolean} isSingleMedia + * @param {string[]} itemFiles + * @returns {Promise} library item that matches + */ async function findLibraryItemByFileToItemInoMatch(libraryId, fullPath, isSingleMedia, itemFiles) { if (isSingleMedia) return null - // check if it was moved from the root folder by comparing the ino to the ino of the scanned files + // check if it was moved from the root folder by comparing the ino and deviceId to the ino and deviceId of the scanned files let itemFileInos = [] for (const itemFile of itemFiles) { const ino = await fileUtils.getIno(Path.posix.join(fullPath, itemFile)) - if (ino) itemFileInos.push(ino) + const deviceId = await fileUtils.getDeviceId(Path.posix.join(fullPath, itemFile)) + if (ino && deviceId) itemFileInos.push({ ino: ino, deviceId: deviceId }) } if (!itemFileInos.length) return null - const existingLibraryItem = await Database.libraryItemModel.findOneExpanded({ - libraryId: libraryId, - ino: { - [sequelize.Op.in]: itemFileInos + /** @type {import('../models/LibraryItem').LibraryItemExpanded | null} */ + let existingLibraryItem = null + for (let item in itemFileInos) { + existingLibraryItem = await Database.libraryItemModel.findOneExpanded({ + libraryId: libraryId, + [sequelize.Op.or]: itemFileInos + }) + + if (existingLibraryItem) { + break } - }) + } + if (existingLibraryItem) Logger.debug(`[LibraryScanner] Found library item with inode matching one of "${itemFileInos.join(',')}" at path "${existingLibraryItem.path}"`) return existingLibraryItem } + +/** + * @param {{ id: any; libraryId: any; }} folder + * @param {{ mediaType: any; }} library + * @param {{ ino: any; dev: any; mtimeMs: any; ctimeMs: any; birthtimeMs: any; }} libraryItemFolderStats + * @param {{ path: any; relPath: any; mediaMetadata: any; }} libraryItemData + * @param {any} isFile + * @param {any} fileObjs + * @returns {LibraryItemScanData} new object + */ +function createLibraryItemScanData(folder, library, libraryItemFolderStats, libraryItemData, isFile, fileObjs) { + return new LibraryItemScanData({ + libraryFolderId: folder.id, + libraryId: folder.libraryId, + mediaType: library.mediaType, + ino: libraryItemFolderStats.ino, + deviceId: libraryItemFolderStats.dev, + mtimeMs: libraryItemFolderStats.mtimeMs || 0, + ctimeMs: libraryItemFolderStats.ctimeMs || 0, + birthtimeMs: libraryItemFolderStats.birthtimeMs || 0, + path: libraryItemData.path, + relPath: libraryItemData.relPath, + isFile, + mediaMetadata: libraryItemData.mediaMetadata || null, + libraryFiles: fileObjs + }) +} diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js index c9569c3ad..19554715d 100644 --- a/server/scanner/PodcastScanner.js +++ b/server/scanner/PodcastScanner.js @@ -366,7 +366,7 @@ class PodcastScanner { * @param {PodcastEpisode[]} podcastEpisodes Not the models for new podcasts * @param {import('./LibraryItemScanData')} libraryItemData * @param {import('./LibraryScan')} libraryScan - * @param {string} [existingLibraryItemId] + * @param {string | null} [existingLibraryItemId] * @returns {Promise} */ async getPodcastMetadataFromScanData(podcastEpisodes, libraryItemData, libraryScan, existingLibraryItemId = null) { diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 9a349bd54..14d8e1320 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -47,6 +47,10 @@ function getFileStat(path) { } } +/** + * @param {string} path + * @returns {Promise} + */ async function getFileTimestampsWithIno(path) { try { var stat = await fs.stat(path, { bigint: true }) @@ -55,11 +59,12 @@ async function getFileTimestampsWithIno(path) { mtimeMs: Number(stat.mtimeMs), ctimeMs: Number(stat.ctimeMs), birthtimeMs: Number(stat.birthtimeMs), - ino: String(stat.ino) + ino: String(stat.ino), + deviceId: String(stat.dev) } } catch (err) { Logger.error(`[fileUtils] Failed to getFileTimestampsWithIno for path "${path}"`, err) - return false + return null } } module.exports.getFileTimestampsWithIno = getFileTimestampsWithIno @@ -92,7 +97,7 @@ module.exports.getFileMTimeMs = async (path) => { /** * * @param {string} filepath - * @returns {boolean} + * @returns {Promise} isFile */ async function checkPathIsFile(filepath) { try { @@ -104,6 +109,10 @@ async function checkPathIsFile(filepath) { } module.exports.checkPathIsFile = checkPathIsFile +/** + * @param {string} path + * @returns {string | null} inode + */ function getIno(path) { return fs .stat(path, { bigint: true }) @@ -115,10 +124,25 @@ function getIno(path) { } module.exports.getIno = getIno +/** + * @param {string} path + * @returns {Promise} deviceId + */ +async function getDeviceId(path) { + try { + var data = await fs.stat(path) + return String(data.dev) + } catch (error) { + Logger.error(`[Utils] Failed to get device Id for path "${path}": ${error}`) + return null + } +} +module.exports.getDeviceId = getDeviceId + /** * Read contents of file * @param {string} path - * @returns {string} + * @returns {Promise} file contents */ async function readTextFile(path) { try { @@ -135,7 +159,7 @@ module.exports.readTextFile = readTextFile * Check if file or directory should be ignored. Returns a string of the reason to ignore, or null if not ignored * * @param {string} path - * @returns {string} + * @returns {string | null} reason to ignore */ module.exports.shouldIgnoreFile = (path) => { // Check if directory or file name starts with "." @@ -178,8 +202,8 @@ module.exports.shouldIgnoreFile = (path) => { /** * Get array of files inside dir * @param {string} path - * @param {string} [relPathToReplace] - * @returns {FilePathItem[]} + * @param {string | null} [relPathToReplace] + * @returns {Promise} */ module.exports.recurseFiles = async (path, relPathToReplace = null) => { path = filePathToPOSIX(path) @@ -219,6 +243,8 @@ module.exports.recurseFiles = async (path, relPathToReplace = null) => { item.fullname = filePathToPOSIX(item.fullname) item.path = filePathToPOSIX(item.path) + // BUGBUG: This is broken with symlinked directory /tmp -> /private/tmp. when library is in /tmp/testLibrary, it tries to replace /tmp/testLibrary with '' but in a canonical path (non-symlinked) + // TODO: find the commit that added relPathToReplace and figure out what it's trying to do and make it do that properly const relpath = item.fullname.replace(relPathToReplace, '') let reldirname = Path.dirname(relpath) if (reldirname === '.') reldirname = '' @@ -292,7 +318,7 @@ module.exports.getFilePathItemFromFileUpdate = (fileUpdate) => { * * @param {string} url * @param {string} filepath path to download the file to - * @param {Function} [contentTypeFilter] validate content type before writing + * @param {Function | null} [contentTypeFilter] validate content type before writing * @returns {Promise} */ module.exports.downloadFile = (url, filepath, contentTypeFilter = null) => { diff --git a/server/utils/migrations/absMetadataMigration.js b/server/utils/migrations/absMetadataMigration.js index 0d9f909a9..910ff77c1 100644 --- a/server/utils/migrations/absMetadataMigration.js +++ b/server/utils/migrations/absMetadataMigration.js @@ -5,17 +5,17 @@ const fileUtils = require('../fileUtils') const LibraryFile = require('../../objects/files/LibraryFile') /** - * - * @param {import('../../models/LibraryItem')} libraryItem + * + * @param {import('../../models/LibraryItem')} libraryItem * @returns {Promise} false if failed */ async function writeMetadataFileForItem(libraryItem) { const storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem && !libraryItem.isFile const metadataPath = storeMetadataWithItem ? libraryItem.path : Path.join(global.MetadataPath, 'items', libraryItem.id) const metadataFilepath = fileUtils.filePathToPOSIX(Path.join(metadataPath, 'metadata.json')) - if ((await fsExtra.pathExists(metadataFilepath))) { + if (await fsExtra.pathExists(metadataFilepath)) { // Metadata file already exists do nothing - return null + return false } Logger.info(`[absMetadataMigration] metadata file not found at "${metadataFilepath}" - creating`) @@ -27,20 +27,24 @@ async function writeMetadataFileForItem(libraryItem) { const metadataJson = libraryItem.media.getAbsMetadataJson() // Save to file - const success = await fsExtra.writeFile(metadataFilepath, JSON.stringify(metadataJson, null, 2)).then(() => true).catch((error) => { - Logger.error(`[absMetadataMigration] failed to save metadata file at "${metadataFilepath}"`, error.message || error) - return false - }) + const success = await fsExtra + .writeFile(metadataFilepath, JSON.stringify(metadataJson, null, 2)) + .then(() => true) + .catch((error) => { + Logger.error(`[absMetadataMigration] failed to save metadata file at "${metadataFilepath}"`, error.message || error) + return false + }) if (!success) return false if (!storeMetadataWithItem) return true // No need to do anything else // Safety check to make sure library file with the same path isnt already there - libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== metadataFilepath) + libraryItem.libraryFiles = libraryItem.libraryFiles.filter((lf) => lf.metadata.path !== metadataFilepath) // Put new library file in library item const newLibraryFile = new LibraryFile() await newLibraryFile.setDataFromPath(metadataFilepath, 'metadata.json') + // TODO: BUGBUG - this shouldn't be JSON and it may not be the right type LibraryFileObject libraryItem.libraryFiles.push(newLibraryFile.toJSON()) // Update library item timestamps and total size @@ -49,20 +53,23 @@ async function writeMetadataFileForItem(libraryItem) { libraryItem.mtime = libraryItemDirTimestamps.mtimeMs libraryItem.ctime = libraryItemDirTimestamps.ctimeMs let size = 0 - libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) + libraryItem.libraryFiles.forEach((lf) => (size += !isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) libraryItem.size = size } libraryItem.changed('libraryFiles', true) - return libraryItem.save().then(() => true).catch((error) => { - Logger.error(`[absMetadataMigration] failed to save libraryItem "${libraryItem.id}"`, error.message || error) - return false - }) + return libraryItem + .save() + .then(() => true) + .catch((error) => { + Logger.error(`[absMetadataMigration] failed to save libraryItem "${libraryItem.id}"`, error.message || error) + return false + }) } /** - * - * @param {import('../../Database')} Database + * + * @param {import('../../Database')} Database * @param {number} [offset=0] * @param {number} [totalCreated=0] */ @@ -83,11 +90,11 @@ async function runMigration(Database, offset = 0, totalCreated = 0) { } /** - * - * @param {import('../../Database')} Database + * + * @param {import('../../Database')} Database */ module.exports.migrate = async (Database) => { Logger.info(`[absMetadataMigration] Starting metadata.json migration`) const totalCreated = await runMigration(Database) Logger.info(`[absMetadataMigration] Finished metadata.json migration (${totalCreated} files created)`) -} \ No newline at end of file +} diff --git a/test/server/MockDatabase.js b/test/server/MockDatabase.js new file mode 100644 index 000000000..55b126421 --- /dev/null +++ b/test/server/MockDatabase.js @@ -0,0 +1,172 @@ +const Database = require('../../server/Database') +const { Sequelize } = require('sequelize') +const LibraryFile = require('../../server/objects/files/LibraryFile') +const fileUtils = require('../../server/utils/fileUtils') +const FileMetadata = require('../../server/objects/metadata/FileMetadata') +const Path = require('path') +const sinon = require('sinon') + +async function loadTestDatabase(mockFileInfo) { + let libraryItem1Id, libraryItem2Id + + let fileInfo = mockFileInfo || getMockFileInfo() + // mapping the keys() iterable to an explicit array so reduce() should work consistently. + let bookLibraryFiles = [...fileInfo.keys()].reduce((acc, key) => { + let bookfile = new LibraryFile() + bookfile.setDataFromPath(key, key) + acc.push(bookfile) + return acc + }, []) + + global.ServerSettings = {} + Database.sequelize = new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', + // Choose one of the logging options + logging: (...msg) => console.log(msg), + logQueryParameters: true + }) + Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') + await Database.buildModels() + + const newLibrary = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) + const newLibraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: newLibrary.id }) + const newLibraryFolder2 = await Database.libraryFolderModel.create({ path: '/mnt/drive', libraryId: newLibrary.id }) + + const newBook = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + const newLibraryItem = await Database.libraryItemModel.create(buildBookLibraryItemParams(bookLibraryFiles[0], newBook.id, newLibrary.id, newLibraryFolder.id)) + libraryItem1Id = newLibraryItem.id + + const newBook2 = await Database.bookModel.create({ title: 'Test Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + const newLibraryItem2 = await Database.libraryItemModel.create(buildBookLibraryItemParams(bookLibraryFiles[1], newBook2.id, newLibrary.id, newLibraryFolder2.id)) + libraryItem2Id = newLibraryItem2.id + + return newLibrary +} +exports.loadTestDatabase = loadTestDatabase + +/** @returns {Map} */ +function getMockFileInfo() { + // @ts-ignore + return new Map([ + ['/test/file.pdf', { path: '/test/file.pdf', isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '100' }], + ['/mnt/drive/file-same-ino-different-dev.pdf', { path: '/mnt/drive/file-same-ino-different-dev.pdf', isDirectory: () => false, size: 42, mtimeMs: Date.now(), ino: '1', dev: '200' }] + ]) +} + +exports.getMockFileInfo = getMockFileInfo +/** @returns {Map} */ +// this has the same data as above except one file has been renamed +function getRenamedMockFileInfo() { + // @ts-ignore + return new Map([ + ['/test/file-renamed.pdf', { path: '/test/file-renamed.pdf', isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '100' }], + ['/mnt/drive/file-same-ino-different-dev.pdf', { path: '/mnt/drive/file-same-ino-different-dev.pdf', isDirectory: () => false, size: 42, mtimeMs: Date.now(), ino: '1', dev: '200' }] + ]) +} +exports.getRenamedMockFileInfo = getRenamedMockFileInfo + +/** + * @param {LibraryFile} libraryFile + * @param {any} bookId + * @param {string} libraryId + * @param {any} libraryFolderId + */ +function buildBookLibraryItemParams(libraryFile, bookId, libraryId, libraryFolderId) { + return { + path: libraryFile.metadata?.path, + isFile: true, + ino: libraryFile.ino, + deviceId: libraryFile.deviceId, + libraryFiles: [libraryFile.toJSON()], + mediaId: bookId, + mediaType: 'book', + libraryId: libraryId, + libraryFolderId: libraryFolderId + } +} +exports.buildBookLibraryItemParams = buildBookLibraryItemParams + +function stubFileUtils(mockFileInfo = getMockFileInfo()) { + let getInoStub, getDeviceIdStub, getFileTimestampsWithInoStub + getInoStub = sinon.stub(fileUtils, 'getIno') + getInoStub.callsFake((path) => { + const normalizedPath = fileUtils.filePathToPOSIX(path).replace(/\/$/, '') + const stats = mockFileInfo.get(normalizedPath) + if (stats) { + return stats.ino + } else { + return null + } + }) + + getDeviceIdStub = sinon.stub(fileUtils, 'getDeviceId') + getDeviceIdStub.callsFake(async (path) => { + const normalizedPath = fileUtils.filePathToPOSIX(path).replace(/\/$/, '') + const stats = mockFileInfo.get(normalizedPath) + if (stats) { + return stats.dev + } else { + return null + } + }) + + getFileTimestampsWithInoStub = sinon.stub(fileUtils, 'getFileTimestampsWithIno') + getFileTimestampsWithInoStub.callsFake(async (path) => { + const normalizedPath = fileUtils.filePathToPOSIX(path).replace(/\/$/, '') + const stats = mockFileInfo.get(normalizedPath) + if (stats) { + return stats + } else { + return null + } + }) +} +exports.stubFileUtils = stubFileUtils + +/** @returns {{ libraryFolderId: any; libraryId: any; mediaType: any; ino: any; deviceId: any; mtimeMs: any; ctimeMs: any; birthtimeMs: any; path: any; relPath: any; isFile: any; mediaMetadata: any; libraryFiles: any; }} */ +function buildFileProperties(path = '/tmp/foo.epub', ino = '12345', deviceId = '9876', libraryFiles = []) { + const metadata = new FileMetadata() + metadata.filename = Path.basename(path) + metadata.path = path + metadata.relPath = path + metadata.ext = Path.extname(path) + + return { + ino: ino, + deviceId: deviceId, + metadata: metadata, + isSupplementary: false, + addedAt: Date.now(), + updatedAt: Date.now(), + libraryFiles: [...libraryFiles.map((lf) => lf.toJSON())] + } +} +exports.buildFileProperties = buildFileProperties + +/** + * @returns {import('../../server/models/LibraryItem').LibraryFileObject} + * @param {string} [path] + * @param {string} [ino] + * @param {string} [deviceId] + */ +function buildLibraryFileProperties(path, ino, deviceId) { + return { + ino: ino, + deviceId: deviceId, + isSupplementary: false, + addedAt: 0, + updatedAt: 0, + metadata: { + filename: Path.basename(path), + ext: Path.extname(path), + path: path, + relPath: path, + size: 0, + mtimeMs: 0, + ctimeMs: 0, + birthtimeMs: 0 + } + } +} +exports.buildLibraryFileProperties = buildLibraryFileProperties diff --git a/test/server/migrations/v2.30.0-add-deviceId.test.js b/test/server/migrations/v2.30.0-add-deviceId.test.js new file mode 100644 index 000000000..571ed2ec8 --- /dev/null +++ b/test/server/migrations/v2.30.0-add-deviceId.test.js @@ -0,0 +1,178 @@ +const chai = require('chai') +const sinon = require('sinon') +const { expect } = chai + +const { DataTypes, Sequelize } = require('sequelize') +const Logger = require('../../../server/Logger') + +const { up, down, migrationName } = require('../../../server/migrations/v2.30.0-add-deviceId') +const { stubFileUtils, getMockFileInfo } = require('../MockDatabase') + +const normalizeWhitespaceAndBackticks = (str) => str.replace(/\s+/g, ' ').trim().replace(/`/g, '') + +describe(`Migration ${migrationName}`, () => { + let sequelize + let queryInterface + let loggerInfoStub + let mockFileInfo, file1stats, file2stats + + beforeEach(async () => { + sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + queryInterface = sequelize.getQueryInterface() + loggerInfoStub = sinon.stub(Logger, 'info') + + mockFileInfo = getMockFileInfo() + file1stats = mockFileInfo.get('/test/file.pdf') + file2stats = mockFileInfo.get('/mnt/drive/file-same-ino-different-dev.pdf') + + stubFileUtils(mockFileInfo) + + await queryInterface.createTable('libraryItems', { + id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true }, + ino: { type: DataTypes.STRING }, + path: { type: DataTypes.STRING }, + mediaId: { type: DataTypes.INTEGER, allowNull: false }, + mediaType: { type: DataTypes.STRING, allowNull: false }, + libraryId: { type: DataTypes.INTEGER, allowNull: false } + }) + + await queryInterface.createTable('authors', { + id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true }, + name: { type: DataTypes.STRING, allowNull: false }, + lastFirst: { type: DataTypes.STRING, allowNull: false } + }) + + await queryInterface.createTable('bookAuthors', { + id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true }, + bookId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'libraryItems', key: 'id', onDelete: 'CASCADE' } }, + authorId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'authors', key: 'id', onDelete: 'CASCADE' } }, + createdAt: { type: DataTypes.DATE, allowNull: false } + }) + + await queryInterface.createTable('podcastEpisodes', { + id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true }, + publishedAt: { type: DataTypes.DATE, allowNull: true } + }) + + await queryInterface.bulkInsert('libraryItems', [ + { id: 1, ino: file1stats.ino, mediaId: 1, path: file1stats.path, mediaType: 'book', libraryId: 1 }, + { id: 2, ino: file2stats.ino, mediaId: 2, path: file2stats.path, mediaType: 'book', libraryId: 1 } + ]) + + await queryInterface.bulkInsert('authors', [ + { id: 1, name: 'John Doe', lastFirst: 'Doe, John' }, + { id: 2, name: 'Jane Smith', lastFirst: 'Smith, Jane' }, + { id: 3, name: 'John Smith', lastFirst: 'Smith, John' } + ]) + + await queryInterface.bulkInsert('bookAuthors', [ + { id: 1, bookId: 1, authorId: 1, createdAt: '2025-01-01 00:00:00.000 +00:00' }, + { id: 2, bookId: 2, authorId: 2, createdAt: '2025-01-02 00:00:00.000 +00:00' }, + { id: 3, bookId: 1, authorId: 3, createdAt: '2024-12-31 00:00:00.000 +00:00' } + ]) + + await queryInterface.bulkInsert('podcastEpisodes', [ + { id: 1, publishedAt: '2025-01-01 00:00:00.000 +00:00' }, + { id: 2, publishedAt: '2025-01-02 00:00:00.000 +00:00' }, + { id: 3, publishedAt: '2025-01-03 00:00:00.000 +00:00' } + ]) + }) + + afterEach(() => { + sinon.restore() + }) + + describe('up', () => { + it('should add the deviceId column to the libraryItems table', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const libraryItems = await queryInterface.describeTable('libraryItems') + expect(libraryItems.deviceId).to.exist + }) + + it('should populate the deviceId columns from the filesystem for each libraryItem', async function () { + this.timeout(0) + await up({ context: { queryInterface, logger: Logger } }) + + const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems') + expect(libraryItems).to.deep.equal([ + { id: 1, ino: file1stats.ino, deviceId: file1stats.dev, mediaId: 1, path: file1stats.path, mediaType: 'book', libraryId: 1 }, + { id: 2, ino: file2stats.ino, deviceId: file2stats.dev, mediaId: 2, path: file2stats.path, mediaType: 'book', libraryId: 1 } + ]) + }) + + it('should add an index on ino and deviceId to the libraryItems table', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const indexes = await queryInterface.sequelize.query(`SELECT * FROM sqlite_master WHERE type='index'`) + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_ino_device_id'`) + expect(count).to.equal(1) + + const [[{ sql }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='index' AND name='library_items_ino_device_id'`) + expect(normalizeWhitespaceAndBackticks(sql)).to.equal( + normalizeWhitespaceAndBackticks(` + CREATE INDEX library_items_ino_device_id ON libraryItems (ino, deviceId) + `) + ) + }) + + it('should be idempotent', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await up({ context: { queryInterface, logger: Logger } }) + + const libraryItemsTable = await queryInterface.describeTable('libraryItems') + expect(libraryItemsTable.deviceId).to.exist + + const [[{ count: count6 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_ino_device_id'`) + expect(count6).to.equal(1) + + const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`) + expect(libraryItems).to.deep.equal([ + { id: 1, ino: file1stats.ino, deviceId: file1stats.dev, path: file1stats.path, mediaId: 1, mediaType: 'book', libraryId: 1 }, + { id: 2, ino: file2stats.ino, deviceId: file2stats.dev, path: file2stats.path, mediaId: 2, mediaType: 'book', libraryId: 1 } + ]) + }) + }) + + describe('down', () => { + it('should remove the deviceId from the libraryItems table', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const libraryItemsTable = await queryInterface.describeTable('libraryItems') + expect(libraryItemsTable.deviceId).to.not.exist + + const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`) + expect(libraryItems).to.deep.equal([ + { id: 1, ino: file1stats.ino, mediaId: 1, path: file1stats.path, mediaType: 'book', libraryId: 1 }, + { id: 2, ino: file2stats.ino, mediaId: 2, path: file2stats.path, mediaType: 'book', libraryId: 1 } + ]) + }) + + it('should remove the index on ino, deviceId from the libraryItems table', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_ino_device_id'`) + expect(count).to.equal(0) + }) + + it('should be idempotent', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const libraryItemsTable = await queryInterface.describeTable('libraryItems') + expect(libraryItemsTable.libraryItems).to.not.exist + + const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`) + expect(libraryItems).to.deep.equal([ + { id: 1, ino: file1stats.ino, path: file1stats.path, mediaId: 1, mediaType: 'book', libraryId: 1 }, + { id: 2, ino: file2stats.ino, path: file2stats.path, mediaId: 2, mediaType: 'book', libraryId: 1 } + ]) + + const [[{ count: count6 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_ino_device_id'`) + expect(count6).to.equal(0) + }) + }) +}) diff --git a/test/server/objects/LibraryItemScanData.test.js b/test/server/objects/LibraryItemScanData.test.js new file mode 100644 index 000000000..69b971abd --- /dev/null +++ b/test/server/objects/LibraryItemScanData.test.js @@ -0,0 +1,183 @@ +const chai = require('chai') +const expect = chai.expect +const Path = require('path') + +const { buildFileProperties, buildLibraryFileProperties } = require('../MockDatabase') + +const LibraryItemScanData = require('../../../server/scanner/LibraryItemScanData') +const LibraryFile = require('../../../server/objects/files/LibraryFile') +const LibraryScan = require('../../../server/scanner/LibraryScan') +const ScanLogger = require('../../../server/scanner/ScanLogger') +describe('LibraryItemScanData', () => { + // compareUpdateLibraryFile - returns false if no changes; true if changes + describe('compareUpdateLibraryFileWithDeviceId', () => { + it('fileChangeDetectedWhenInodeAndDeviceIdPairDiffers', () => { + const existing_lf = buildLibraryFileProperties('/tmp/file.pdf', '4432', '300') + const scanned_lf = new LibraryFile({ + ino: '1', + deviceId: '100' + }) + + expect(existing_lf.ino).to.not.equal(scanned_lf.ino) + expect(existing_lf.deviceId).to.not.equal(scanned_lf.deviceId) + const changeDetected = LibraryItemScanData.compareUpdateLibraryFile('/file/path.pdf', existing_lf, scanned_lf, new LibraryScan()) + expect(changeDetected).to.be.true + }) + + it('fileChangeNotDetectedWhenInodeSameButDeviceIdDiffers', () => { + // Same inode on different deviceId does NOT mean these are the same file + const existing_lf = buildLibraryFileProperties('/tmp/file.pdf', '4432', '300') + const scanned_lf = new LibraryFile(buildLibraryFileProperties('/tmp/file.pdf', '4432', '100')) + + expect(existing_lf.ino).to.equal(scanned_lf.ino) + expect(existing_lf.deviceId).to.not.equal(scanned_lf.deviceId) + const changeDetected = LibraryItemScanData.compareUpdateLibraryFile('/file/path.pdf', existing_lf, scanned_lf, new LibraryScan()) + expect(changeDetected).to.be.false + }) + }) + + describe('findMatchingLibraryFileByPathOrInodeAndDeviceId', () => { + it('isMatchWhenInodeAndDeviceIdPairIsSame', () => { + const lisd = new LibraryItemScanData(buildFileProperties('/library/book/file.epub', '1', '1000', [new LibraryFile(buildLibraryFileProperties('/library/book/file.epub', '1', '1000'))])) + + const scanned_lf_properties = buildLibraryFileProperties('/tmp/file.epub', '1', '1000') + + const matchingFile = lisd.findMatchingLibraryFileByPathOrInodeAndDeviceId(scanned_lf_properties, new ScanLogger()) + + // don't want match based on filename + expect(lisd.path).to.not.equal(scanned_lf_properties.metadata.path) + expect(matchingFile).to.not.be.undefined + expect(matchingFile?.ino).to.equal(lisd.ino) + expect(matchingFile?.deviceId).to.equal(lisd.deviceId) + }) + it('isNotMatchWhenInodeSameButDeviceIdDiffers', () => { + const lisd = new LibraryItemScanData(buildFileProperties('/library/book/file.epub', '1', '1000', [new LibraryFile(buildLibraryFileProperties('/library/book/file.epub', '1', '1000'))])) + + const scanned_lf_properties = buildLibraryFileProperties('/tmp/file.epub', '1', '500') + + // don't want match based on filename + expect(lisd.path).to.not.equal(scanned_lf_properties.metadata.path) + expect(lisd.deviceId).to.not.equal(scanned_lf_properties.ino) + + const matchingFile = lisd.findMatchingLibraryFileByPathOrInodeAndDeviceId(scanned_lf_properties, new ScanLogger()) + + expect(matchingFile).to.be.undefined + }) + }) + + describe('checkAudioFileRemoved', function () { + this.timeout(0) + it('doesNotDetectFileRemovedWhenInodeIsSameButDeviceIdDiffers', () => { + const lisd = new LibraryItemScanData(buildFileProperties('/library/book/file.mp3', '1', '1000')) + lisd.libraryFilesRemoved.push(buildLibraryFileProperties('/library/book/file.mp3', '1', '1000')) + const af_obj = buildAudioFileObject('/library/someotherbook/chapter1.mp3', '1', '200') + + const fileRemoved = lisd.checkAudioFileRemoved(af_obj) + + expect(fileRemoved).to.be.false + }) + + it('detectsFileRemovedWhenNameDoesNotMatchButInodeAndDeviceIdMatch', () => { + const lisd = new LibraryItemScanData(buildFileProperties('/library/book/file.mp3', '1', '1000')) + lisd.libraryFilesRemoved.push(buildLibraryFileProperties('/library/book/file.mp3', '1', '1000')) + const af_obj = buildAudioFileObject('/library/someotherbook/chapter1.mp3', '1', '1000') + + expect(lisd.path).to.not.equal(af_obj.metadata.path) + const fileRemoved = lisd.checkAudioFileRemoved(af_obj) + + expect(fileRemoved).to.be.true + }) + }) + + // checkEbookFileRemoved + describe('checkEbookFileRemoved', () => { + it('doesNotDetectFileRemovedWhenInodeIsSameButDeviceIdDiffers', () => { + const lisd = new LibraryItemScanData(buildFileProperties('/library/book/file.epub', '1', '1000', [new LibraryFile(buildLibraryFileProperties('/library/book/file.epub', '1', '1000'))])) + lisd.libraryFilesRemoved.push(buildLibraryFileProperties('/library/book/file.epub', '1', '1000')) // This is the file that was removed + const ebook_obj = buildEbookFileObject('/library/someotherbook/chapter1.epub', '1', '200') // this file was NOT removed + + expect(lisd.path).to.not.equal(ebook_obj.metadata.path) + const fileRemoved = lisd.checkEbookFileRemoved(ebook_obj) + + expect(fileRemoved).to.be.false + }) + + it('detectsFileRemovedWhenInodeAndDeviceIdIsSame', () => { + const lisd = new LibraryItemScanData(buildFileProperties('/library/book/file.epub', '1', '1000', [new LibraryFile(buildLibraryFileProperties('/library/book/file.epub', '1', '1000'))])) + lisd.libraryFilesRemoved.push(buildLibraryFileProperties('/library/book/file.epub', '1', '1000')) // This is the file that was removed + const ebook_obj = buildEbookFileObject('/library/someotherbook/chapter1.epub', '1', '1000') // this file was removed + + expect(lisd.path).to.not.equal(ebook_obj.metadata.path) + const fileRemoved = lisd.checkEbookFileRemoved(ebook_obj) + + expect(fileRemoved).to.be.true + }) + }) + + // libraryItemObject() + describe('libraryItemObject', () => { + it('setsDeviceIdOnLibraryObject', () => { + const lisd = new LibraryItemScanData(buildFileProperties('/library/book/file.epub', '1', '1000', [new LibraryFile(buildLibraryFileProperties('/library/book/file.epub', '1', '1000'))])) + expect(lisd.libraryItemObject.ino).to.equal(lisd.ino) + expect(lisd.libraryItemObject.deviceId).to.equal(lisd.deviceId) + }) + }) +}) + +/** @returns {import('../../../server/models/Book').AudioFileObject} */ +function buildAudioFileObject(path = '/library/somebook/file.mp3', ino = '1', deviceId = '1000') { + return { + index: 0, + ino: ino, + deviceId: deviceId, + metadata: { + filename: Path.basename(path), + ext: Path.extname(path), + path: path, + relPath: path, + size: 0, + mtimeMs: 0, + ctimeMs: 0, + birthtimeMs: 0 + }, + addedAt: 0, + updatedAt: 0, + trackNumFromMeta: 0, + discNumFromMeta: 0, + trackNumFromFilename: 0, + discNumFromFilename: 0, + manuallyVerified: false, + format: '', + duration: 0, + bitRate: 0, + language: '', + codec: '', + timeBase: '', + channels: 0, + channelLayout: '', + chapters: [], + metaTags: undefined, + mimeType: '' + } +} + +/** @returns {import('../../../server/models/Book').EBookFileObject} */ +function buildEbookFileObject(path = '/library/somebook/file.epub', ino = '100', deviceId = '1000') { + return { + ino: ino, + deviceId: deviceId, + ebookFormat: Path.extname(path), + addedAt: 0, + updatedAt: 0, + metadata: { + filename: Path.basename(path), + ext: Path.extname(path), + path: path, + relPath: path, + size: 0, + mtimeMs: 0, + ctimeMs: 0, + birthtimeMs: 0 + } + } +} diff --git a/test/server/objects/SimilarLibraryFileObjects.test.js b/test/server/objects/SimilarLibraryFileObjects.test.js new file mode 100644 index 000000000..8c1a273e7 --- /dev/null +++ b/test/server/objects/SimilarLibraryFileObjects.test.js @@ -0,0 +1,97 @@ +const chai = require('chai') +const expect = chai.expect +const sinon = require('sinon') + +const Path = require('path') +const Database = require('../../../server/Database') +const { loadTestDatabase, stubFileUtils, getMockFileInfo, buildFileProperties } = require('../MockDatabase') + +// TODO: all of these classes duplicate each other. +const LibraryFile = require('../../../server/objects/files/LibraryFile') +const EBookFile = require('../../../server/objects/files/EBookFile') +const AudioFile = require('../../../server/objects/files/AudioFile') +const LibraryItemScanData = require('../../../server/scanner/LibraryItemScanData') + +const fileProperties = buildFileProperties() +const lf = new LibraryFile(fileProperties) +const ebf = new EBookFile(fileProperties) +const af = new AudioFile(fileProperties) + +describe('SimilarLibraryFileObjects', () => { + describe('ObjectSetsDeviceIdWhenConstructed', function () { + this.timeout(0) + beforeEach(async () => { + stubFileUtils() + await loadTestDatabase() + }) + + afterEach(() => { + sinon.restore() + }) + + const lisd = new LibraryItemScanData(fileProperties) + + const objects = [lf, ebf, af, lisd] + + objects.forEach((obj) => { + it(`${obj.constructor.name}SetsDeviceIdWhenConstructed`, () => { + expect(obj.ino).to.equal(fileProperties.ino) + expect(obj.deviceId).to.equal(fileProperties.deviceId) + }) + }) + + it('LibraryItemSetsDeviceIdWhenConstructed', async () => { + const mockFileInfo = getMockFileInfo().get('/test/file.pdf') + + /** @type {import('../../../server/models/LibraryItem') | null} */ + const li = await Database.libraryItemModel.findOneExpanded({ + path: '/test/file.pdf' + }) + + expect(li?.ino).to.equal(mockFileInfo?.ino) + expect(li?.deviceId).to.equal(mockFileInfo?.dev) + }) + + it('LibraryFileJSONHasDeviceId', async () => { + const mockFileInfo = getMockFileInfo().get('/test/file.pdf') + + /** @type {import('../../../server/models/LibraryItem') | null} */ + const li = await Database.libraryItemModel.findOneExpanded({ + path: '/test/file.pdf' + }) + + const lf_json = li?.libraryFiles[0] + expect(lf_json).to.not.be.null + expect(lf_json?.deviceId).to.equal(mockFileInfo?.dev) + }) + }) + + describe('ObjectSetsDeviceIdWhenSerialized', () => { + const objects = [lf, ebf, af] + objects.forEach((obj) => { + it(`${obj.constructor.name}SetsDeviceIdWhenSerialized`, () => { + const obj_json = obj.toJSON() + expect(obj_json.ino).to.equal(fileProperties.ino) + expect(obj_json.deviceId).to.equal(fileProperties.deviceId) + }) + }) + }) +}) + +function buildLibraryItemProperties(fileProperties) { + return { + id: '7792E90F-D526-4636-8A38-EA8342E71FEE', + path: fileProperties.path, + relPath: fileProperties.path, + isFile: true, + ino: fileProperties.ino, + deviceId: fileProperties.dev, + libraryFiles: [], + mediaId: '7195803A-9974-46E4-A7D1-7A6E1AD7FD4B', + mediaType: 'book', + libraryId: '907DA361-67E4-47CF-9C67-C8E2E5CA1B15', + libraryFolderId: 'E2216F60-8ABF-4E55-BA83-AD077EB907F3', + createdAt: Date.now(), + updatedAt: Date.now() + } +} diff --git a/test/server/scanner/LibraryItemScanner.test.js b/test/server/scanner/LibraryItemScanner.test.js new file mode 100644 index 000000000..24bc7c0ad --- /dev/null +++ b/test/server/scanner/LibraryItemScanner.test.js @@ -0,0 +1,72 @@ +const chai = require('chai') +const expect = chai.expect +const sinon = require('sinon') +const rewire = require('rewire') +const Path = require('path') + +const { stubFileUtils, getMockFileInfo, loadTestDatabase } = require('../MockDatabase') + +const LibraryFile = require('../../../server/objects/files/LibraryFile') +const FileMetadata = require('../../../server/objects/metadata/FileMetadata') +const LibraryFolder = require('../../../server/models/LibraryFolder') + +describe('LibraryItemScanner', () => { + describe('buildLibraryItemScanData', () => { + let testLibrary = null + beforeEach(async () => { + stubFileUtils() + testLibrary = await loadTestDatabase() + }) + + afterEach(() => { + sinon.restore() + }) + + it('setsDeviceId', async () => { + const libraryItemScanner = rewire('../../../server/scanner/LibraryItemScanner') + + /** + * @param {{ path?: any; relPath?: any; mediaMetadata?: any; }} libraryItemData + * @param {import("../../../server/models/LibraryFolder")} folder + * @param {import("../../../server/models/Library")} library + * @param {boolean} isSingleMediaItem + * @param {LibraryFile[]} libraryFiles + * @return {import('../../../server/scanner/LibraryItemScanData') | null} + * */ + const buildLibraryItemScanData = libraryItemScanner.__get__('buildLibraryItemScanData') + + const mockFileInfo = getMockFileInfo().get('/test/file.pdf') + const lf = new LibraryFile() + var fileMetadata = new FileMetadata() + fileMetadata.setData(mockFileInfo) + fileMetadata.filename = Path.basename(mockFileInfo?.path) + fileMetadata.path = mockFileInfo?.path + fileMetadata.relPath = mockFileInfo?.path + fileMetadata.ext = Path.extname(mockFileInfo?.path) + lf.ino = mockFileInfo?.ino + lf.deviceId = mockFileInfo?.dev + lf.metadata = fileMetadata + lf.addedAt = Date.now() + lf.updatedAt = Date.now() + lf.metadata = fileMetadata + + const libraryItemData = { + path: mockFileInfo?.path, // full path + relPath: mockFileInfo?.path, // only filename + mediaMetadata: { + title: Path.basename(mockFileInfo?.path, Path.extname(mockFileInfo?.path)) + } + } + + const scanData = await buildLibraryItemScanData(libraryItemData, buildLibraryFolder(), testLibrary, true, [lf.toJSON()]) + + expect(scanData).to.not.be.null + expect(scanData.deviceId).to.equal(mockFileInfo?.dev) + }) + }) +}) + +/** @return {import("../../../server/models/LibraryFolder")} folder */ +function buildLibraryFolder() { + return new LibraryFolder() +} diff --git a/test/server/scanner/LibraryScanner.test.js b/test/server/scanner/LibraryScanner.test.js new file mode 100644 index 000000000..a33872412 --- /dev/null +++ b/test/server/scanner/LibraryScanner.test.js @@ -0,0 +1,328 @@ +const chai = require('chai') +const expect = chai.expect +const sinon = require('sinon') +const rewire = require('rewire') +const fileUtils = require('../../../server/utils/fileUtils') +const LibraryFile = require('../../../server/objects/files/LibraryFile') +const LibraryItem = require('../../../server/models/LibraryItem') +const FileMetadata = require('../../../server/objects/metadata/FileMetadata') +const Path = require('path') +const Database = require('../../../server/Database') +const { stubFileUtils, loadTestDatabase, getMockFileInfo, getRenamedMockFileInfo, buildBookLibraryItemParams, buildFileProperties, buildLibraryFileProperties } = require('../MockDatabase') +const libraryScannerInstance = require('../../../server/scanner/LibraryScanner') +const LibraryScan = require('../../../server/scanner/LibraryScan') + +describe('LibraryScanner', () => { + let LibraryScanner, testLibrary + + beforeEach(async () => { + stubFileUtils() + + LibraryScanner = rewire('../../../server/scanner/LibraryScanner') + }) + + afterEach(() => { + sinon.restore() + }) + + it('findsByInodeAndDeviceId', async function () { + // this.timeout(50000) // Note: don't use arrow function or timeout for debugging doesn't work + let findLibraryItemByItemToFileInoMatch = LibraryScanner.__get__('findLibraryItemByItemToFileInoMatch') + let fullPath = '/test/file.pdf' + + let mockFileInfo = getMockFileInfo() + testLibrary = await loadTestDatabase(mockFileInfo) + + const fileInfo = mockFileInfo.get(fullPath) + + /** @type {Promise} */ + const result = await findLibraryItemByItemToFileInoMatch(testLibrary.id, fullPath, true) + expect(result).to.not.be.null + expect(result.libraryFiles[0].metadata.path).to.equal(fullPath) + expect(result.libraryFiles[0].deviceId).to.equal(fileInfo.dev) + }) + + it('findsTheCorrectItemByInodeAndDeviceIdWhenThereAreDuplicateInodes', async () => { + let findLibraryItemByItemToFileInoMatch = LibraryScanner.__get__('findLibraryItemByItemToFileInoMatch') + let fullPath = '/mnt/drive/file-same-ino-different-dev.pdf' + + let mockFileInfo = getMockFileInfo() + testLibrary = await loadTestDatabase(mockFileInfo) + + const fileInfo = mockFileInfo.get(fullPath) + + /** @type {Promise} */ + const result = await findLibraryItemByItemToFileInoMatch(testLibrary.id, fullPath, true) + expect(result).to.not.be.null + expect(result.libraryFiles[0].metadata.path).to.equal(fullPath) + expect(result.libraryFiles[0].deviceId).to.equal(fileInfo.dev) + }) + + it('findLibraryItemByItemToItemInoMatch', async function () { + this.timeout(0) + // findLibraryItemByItemToItemInoMatch(libraryId, fullPath) + let findLibraryItemByItemToItemInoMatch = LibraryScanner.__get__('findLibraryItemByItemToItemInoMatch') + + let fullPath = '/test/file.pdf' + + let mockFileInfo = getMockFileInfo() + testLibrary = await loadTestDatabase(mockFileInfo) + + const fileInfo = mockFileInfo.get(fullPath) + + /** @returns {Promise} */ + const result = await findLibraryItemByItemToItemInoMatch(testLibrary.id, fullPath) + expect(result).to.not.be.null + expect(result.libraryFiles[0].metadata.path).to.equal(fullPath) + expect(result.libraryFiles[0].deviceId).to.equal(fileInfo.dev) + }) + + it('findLibraryItemByFileToItemInoMatch-matchesRenamedFileByInoAndDeviceId', async function () { + this.timeout(0) + let mockFileInfo = getMockBookFileInfo() + sinon.restore() + stubFileUtils(mockFileInfo) + testLibrary = await loadTestDatabase(mockFileInfo) + + // findLibraryItemByFileToItemInoMatch(libraryId, fullPath, isSingleMedia, itemFiles) + let findLibraryItemByItemToItemInoMatch = LibraryScanner.__get__('findLibraryItemByFileToItemInoMatch') + + let bookFolderPath = '/test/bookfolder' + + /** + * @param {UUIDV4} libraryId + * @param {string} fullPath + * @param {boolean} isSingleMedia + * @param {string[]} itemFiles + * @returns {Promise} library item that matches + */ + const existingItem = await findLibraryItemByItemToItemInoMatch(testLibrary.id, bookFolderPath, false, ['file.epub', 'file-renamed.epub', 'file.opf']) + + expect(existingItem).to.not.be.null + expect(existingItem.ino).to.equal('1') + expect(existingItem.deviceId).to.equal('100') + }) + + it('findLibraryItemByFileToItemInoMatch-DoesNotMatchByInoAndDifferentDeviceId', async function () { + this.timeout(0) + testLibrary = await loadTestDatabase() + + // findLibraryItemByFileToItemInoMatch(libraryId, fullPath, isSingleMedia, itemFiles) + let findLibraryItemByItemToItemInoMatch = LibraryScanner.__get__('findLibraryItemByFileToItemInoMatch') + + let bookFolderPath = '/test/bookfolder' + + /** + * @param {UUIDV4} libraryId + * @param {string} fullPath + * @param {boolean} isSingleMedia + * @param {string[]} itemFiles + * @returns {Promise} library item that matches + */ + const existingItem = await findLibraryItemByItemToItemInoMatch(testLibrary.id, bookFolderPath, false, ['file.epub', 'different-file.epub', 'file.opf']) + + expect(existingItem).to.be.null + }) + + /** @returns {Map} */ + function getMockBookFileInfo() { + // @ts-ignore + return new Map([ + ['/test/bookfolder/file-renamed.epub', { path: '/test/bookfolder/file-renamed.epub', isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '100' }], + ['/test/bookfolder/file.epub', { path: '/test/bookfolder/file.epub', isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '100' }], + ['/test/bookfolder/different-file.epub', { path: '/test/bookfolder/different-file.epub', isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '200' }], + ['/test/bookfolder/file.opf', { path: '/test/bookfolder/file.opf', isDirectory: () => false, size: 42, mtimeMs: Date.now(), ino: '2', dev: '100' }] + ]) + } + + // ItemToFileInoMatch + it('ItemToFileInoMatch-ItemMatchesSelf', async function () { + this.timeout(0) + /** + * @param {import("../../../server/models/LibraryItem") | import("../../../server/scanner/LibraryItemScanData")} libraryItem1 + * @param {import("../../../server/models/LibraryItem") | import("../../../server/scanner/LibraryItemScanData")} libraryItem2 + */ + let ItemToFileInoMatch = LibraryScanner.__get__('ItemToFileInoMatch') + + // this compares the inode from the first library item to the second library item's library file inode + let mockFileInfo = getMockFileInfo() + testLibrary = await loadTestDatabase(mockFileInfo) + + const fileInfo = mockFileInfo.get('/test/file.pdf') + + let item1 = await Database.libraryItemModel.findOneExpanded({ + libraryId: testLibrary.id, + // @ts-ignore + ino: fileInfo.ino + }) + + expect(ItemToFileInoMatch(item1, item1)).to.be.true + }) + + it('ItemToFileInoMatch-TwoItemsWithSameInoButDifferentDeviceShouldNotMatch', async function () { + this.timeout(0) + /** + * @param {import("../../../server/models/LibraryItem") | import("../../../server/scanner/LibraryItemScanData")} libraryItem1 + * @param {import("../../../server/models/LibraryItem") | import("../../../server/scanner/LibraryItemScanData")} libraryItem2 + */ + let ItemToFileInoMatch = LibraryScanner.__get__('ItemToFileInoMatch') + + let mockFileInfo = getMockFileInfo() + testLibrary = await loadTestDatabase(mockFileInfo) + + // this compares the inode from the first library item to the second library item's library file inode + const item1 = await Database.libraryItemModel.findOneExpanded({ + libraryId: testLibrary.id, + path: '/test/file.pdf' + }) + + const item2 = await Database.libraryItemModel.findOneExpanded({ + libraryId: testLibrary.id, + path: '/mnt/drive/file-same-ino-different-dev.pdf' + }) + + expect(item1.path).to.not.equal(item2.path) + + expect(ItemToFileInoMatch(item1, item2)).to.be.false + }) + + it('ItemToFileInoMatch-RenamedFileShouldMatch', async function () { + this.timeout(0) + /** + * @param {import("../../../server/models/LibraryItem") | import("../../../server/scanner/LibraryItemScanData")} libraryItem1 + * @param {import("../../../server/models/LibraryItem") | import("../../../server/scanner/LibraryItemScanData")} libraryItem2 + */ + let ItemToFileInoMatch = LibraryScanner.__get__('ItemToFileInoMatch') + + let mockFileInfo = getMockFileInfo() + testLibrary = await loadTestDatabase(mockFileInfo) + + // this compares the inode from the first library item to the second library item's library file inode + const original = await Database.libraryItemModel.findOneExpanded({ + libraryId: testLibrary.id, + path: '/test/file.pdf' + }) + + const renamedMockFileInfo = getRenamedMockFileInfo().get('/test/file-renamed.pdf') + const renamedFile = new LibraryFile() + var fileMetadata = new FileMetadata() + fileMetadata.setData(renamedMockFileInfo) + fileMetadata.filename = Path.basename(renamedMockFileInfo.path) + fileMetadata.path = fileUtils.filePathToPOSIX(renamedMockFileInfo.path) + fileMetadata.relPath = fileUtils.filePathToPOSIX(renamedMockFileInfo.path) + fileMetadata.ext = Path.extname(renamedMockFileInfo.path) + renamedFile.ino = renamedMockFileInfo.ino + renamedFile.deviceId = renamedMockFileInfo.dev + renamedFile.metadata = fileMetadata + renamedFile.addedAt = Date.now() + renamedFile.updatedAt = Date.now() + renamedFile.metadata = fileMetadata + + const renamedItem = new LibraryItem(buildBookLibraryItemParams(renamedFile, null, testLibrary.id, null)) + + expect(ItemToFileInoMatch(original, renamedItem)).to.be.true + }) + + // ItemToItemInoMatch + it('ItemToItemInoMatch-ItemMatchesSelf', async function () { + this.timeout(0) + /** + * @param {import("../../../server/models/LibraryItem") | import("../../../server/scanner/LibraryItemScanData")} libraryItem1 + * @param {import("../../../server/models/LibraryItem") | import("../../../server/scanner/LibraryItemScanData")} libraryItem2 + */ + let ItemToItemInoMatch = LibraryScanner.__get__('ItemToItemInoMatch') + + // this compares the inode from the first library item to the second library item's library file inode + let mockFileInfo = getMockFileInfo() + testLibrary = await loadTestDatabase(mockFileInfo) + + const fileInfo = mockFileInfo.get('/test/file.pdf') + + let item1 = await Database.libraryItemModel.findOneExpanded({ + libraryId: testLibrary.id, + // @ts-ignore + ino: fileInfo.ino + }) + + expect(ItemToItemInoMatch(item1, item1)).to.be.true + }) + + it('ItemToItemInoMatch-TwoItemsWithSameInoButDifferentDeviceShouldNotMatch', async () => { + let ItemToItemInoMatch = LibraryScanner.__get__('ItemToItemInoMatch') + + let mockFileInfo = getMockFileInfo() + testLibrary = await loadTestDatabase(mockFileInfo) + + // this compares the inode from the first library item to the second library item's library file inode + const item1 = await Database.libraryItemModel.findOneExpanded({ + libraryId: testLibrary.id, + path: '/test/file.pdf' + }) + + const item2 = await Database.libraryItemModel.findOneExpanded({ + libraryId: testLibrary.id, + path: '/mnt/drive/file-same-ino-different-dev.pdf' + }) + + expect(item1.path).to.not.equal(item2.path) + + expect(ItemToItemInoMatch(item1, item2)).to.be.false + }) + + it('ItemToItemInoMatch-RenamedFileShouldMatch', async () => { + let ItemToItemInoMatch = LibraryScanner.__get__('ItemToItemInoMatch') + + let mockFileInfo = getMockFileInfo() + testLibrary = await loadTestDatabase(mockFileInfo) + + // this compares the inode from the first library item to the second library item's library file inode + const original = await Database.libraryItemModel.findOneExpanded({ + libraryId: testLibrary.id, + path: '/test/file.pdf' + }) + + const renamedMockFileInfo = getRenamedMockFileInfo().get('/test/file-renamed.pdf') + const renamedFile = new LibraryFile() + var fileMetadata = new FileMetadata() + fileMetadata.setData(renamedMockFileInfo) + fileMetadata.filename = Path.basename(renamedMockFileInfo.path) + fileMetadata.path = fileUtils.filePathToPOSIX(renamedMockFileInfo.path) + fileMetadata.relPath = fileUtils.filePathToPOSIX(renamedMockFileInfo.path) + fileMetadata.ext = Path.extname(renamedMockFileInfo.path) + renamedFile.ino = renamedMockFileInfo.ino + renamedFile.deviceId = renamedMockFileInfo.dev + renamedFile.metadata = fileMetadata + renamedFile.addedAt = Date.now() + renamedFile.updatedAt = Date.now() + renamedFile.metadata = fileMetadata + + const renamedItem = new LibraryItem(buildBookLibraryItemParams(renamedFile, null, testLibrary.id, null)) + + expect(ItemToItemInoMatch(original, renamedItem)).to.be.true + }) + + describe('createLibraryItemScanData', () => { + it('createLibraryItemScanDataSetsDeviceId', async () => { + /** + * @param {{ id: any; libraryId: any; }} folder + * @param {{ mediaType: any; }} library + * @param {{ ino: any; dev: any; mtimeMs: any; ctimeMs: any; birthtimeMs: any; }} libraryItemFolderStats + * @param {{ path: any; relPath: any; mediaMetadata: any; }} libraryItemData + * @param {any} isFile + * @param {any} fileObjs + * @returns {LibraryItemScanData} new object + */ + const createLibraryItemScanData = LibraryScanner.__get__('createLibraryItemScanData') + + const liFolderStats = { path: '/library/book/file.epub', isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '1000' } + const lf_properties = buildLibraryFileProperties('/library/book/file.epub', '1', '1000') + const libraryFile = new LibraryFile(lf_properties) + + const lisd = createLibraryItemScanData({ id: 'foo', libraryId: 'bar' }, { mediaType: 'ebook' }, liFolderStats, lf_properties, true, [libraryFile.toJSON()]) + + expect(lisd).to.not.be.null + expect(lisd.ino).to.equal(liFolderStats.ino) + expect(lisd.deviceId).to.equal(liFolderStats.dev) + }) + }) +}) diff --git a/test/server/utils/fileUtils.test.js b/test/server/utils/fileUtils.test.js index b57a6fb86..77b088897 100644 --- a/test/server/utils/fileUtils.test.js +++ b/test/server/utils/fileUtils.test.js @@ -3,8 +3,13 @@ const expect = chai.expect const sinon = require('sinon') const fileUtils = require('../../../server/utils/fileUtils') const fs = require('fs') +const fsextra = require('../../../server/libs/fsExtra') const Logger = require('../../../server/Logger') +/** + * @typedef {import('../../../server/libs/fsExtra').fsExtra} fsextra + */ + describe('fileUtils', () => { it('shouldIgnoreFile', () => { global.isWin = process.platform === 'win32' @@ -39,6 +44,46 @@ describe('fileUtils', () => { }) }) + describe('fsextra', () => { + let statStub + + beforeEach(() => { + // two files with same indoe but different device ID + const mockStats = new Map([ + ['/test/file1.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '100' }], + ['/mnt/other/file2.txt', { isDirectory: () => false, size: 512, mtimeMs: Date.now(), ino: '1', dev: '200' }] + ]) + + statStub = sinon.stub(fsextra, 'stat') + statStub.callsFake((path) => { + const normalizedPath = fileUtils.filePathToPOSIX(path).replace(/\/$/, '') + const stats = mockStats.get(normalizedPath) + if (stats) { + return stats + } else { + new Error(`ENOENT: no such file or directory, stat '${normalizedPath}'`) + } + }) + }) + + after(() => { + fsextra.stat.restore() + sinon.restore() + }) + + it('shouldGetDeviceIdForFile', async () => { + const id = await fileUtils.getDeviceId('/test/file1.mp3') + + expect(id).to.be.an('string') + + const id2 = await fileUtils.getDeviceId('/mnt/other/file2.txt') + + expect(id2).to.be.an('string') + + expect(id).to.not.equal(id2) + }) + }) + describe('recurseFiles', () => { let readdirStub, realpathStub, statStub @@ -53,7 +98,7 @@ describe('fileUtils', () => { ]) const mockStats = new Map([ - ['/test/file1.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1' }], + ['/test/file1.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '100' }], ['/test/subfolder', { isDirectory: () => true, size: 0, mtimeMs: Date.now(), ino: '2' }], ['/test/subfolder/file2.m4b', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '3' }], ['/test/ignoreme', { isDirectory: () => true, size: 0, mtimeMs: Date.now(), ino: '4' }], @@ -98,6 +143,9 @@ describe('fileUtils', () => { }) afterEach(() => { + fs.stat.restore() + fs.realpath.restore() + fs.readdir.restore() sinon.restore() }) @@ -105,6 +153,7 @@ describe('fileUtils', () => { const files = await fileUtils.recurseFiles('/test') expect(files).to.be.an('array') expect(files).to.have.lengthOf(3) + expect(statStub.called).to.be.true expect(files[0]).to.deep.equal({ name: 'file1.mp3',