diff --git a/.gitignore b/.gitignore index 5a86d2a..f29d127 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ coverage/ *.log .env* !.env.example +/docs diff --git a/package-lock.json b/package-lock.json index c1c4fde..3458ce7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,13 +31,68 @@ "devDependencies": { "@types/express": "^4.17.21", "@types/node": "^22.14.0", + "@vitest/ui": "^4.1.4", "autoprefixer": "^10.4.21", + "jsdom": "^29.0.2", "tailwindcss": "^4.1.14", "tsx": "^4.21.0", + "typedoc": "^0.28.19", "typescript": "~5.8.2", - "vite": "^6.2.0" + "vite": "^6.2.0", + "vitest": "^4.1.4" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.10.tgz", + "integrity": "sha512-KyOb19eytNSELkmdqzZZUXWCU25byIlOld5qVFg0RYdS0T3tt7jeDByxk9hIAC73frclD8GKrHttr0SUjKCCdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -535,6 +590,159 @@ } } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.60.2", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.60.2.tgz", @@ -1096,6 +1304,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -1143,6 +1369,20 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@gerrit0/mini-shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.23.0.tgz", + "integrity": "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-oniguruma": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", + "@shikijs/types": "^3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, "node_modules/@google/genai": { "version": "1.48.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.48.0.tgz", @@ -1736,6 +1976,13 @@ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "license": "MIT" }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -2137,6 +2384,55 @@ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "license": "MIT" }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@sindresorhus/merge-streams": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", @@ -2149,6 +2445,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", @@ -2469,6 +2772,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -2479,6 +2793,13 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2511,6 +2832,16 @@ "@types/send": "*" } }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -2593,6 +2924,13 @@ "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -2619,6 +2957,141 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.4.tgz", + "integrity": "sha512-EgFR7nlj5iTDYZYCvavjFokNYwr3c3ry0sFiCg+N7B233Nwp+NNx7eoF/XvMWDCKY71xXAG3kFkt97ZHBJVL8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "fflate": "^0.8.2", + "flatted": "^3.4.2", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.4" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -2713,6 +3186,16 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -2803,6 +3286,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -2996,6 +3489,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -3290,6 +3793,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3311,6 +3828,20 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3328,6 +3859,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -3520,6 +4058,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -3556,6 +4107,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3638,6 +4196,16 @@ "node": ">=4" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -3694,6 +4262,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -3866,6 +4444,13 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -3926,6 +4511,13 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -4280,6 +4872,19 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -4515,6 +5120,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -4617,6 +5229,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4965,6 +5628,16 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/log-symbols": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", @@ -5017,6 +5690,13 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5026,6 +5706,37 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5035,6 +5746,20 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -5221,6 +5946,16 @@ "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", "license": "MIT" }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5428,6 +6163,17 @@ "node": ">= 10" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -5568,6 +6314,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5598,6 +6357,13 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5759,6 +6525,26 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -6130,6 +6916,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6349,6 +7148,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -6361,6 +7167,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -6385,6 +7206,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -6394,6 +7222,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -6482,6 +7317,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", @@ -6535,6 +7377,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -6551,6 +7410,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.28", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", @@ -6590,6 +7459,16 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", @@ -6602,6 +7481,19 @@ "node": ">=16" } }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-morph": { "version": "26.0.0", "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", @@ -6689,6 +7581,30 @@ "node": ">= 0.6" } }, + "node_modules/typedoc": { + "version": "0.28.19", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.19.tgz", + "integrity": "sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gerrit0/mini-shiki": "^3.23.0", + "lunr": "^2.3.9", + "markdown-it": "^14.1.1", + "minimatch": "^10.2.5", + "yaml": "^2.8.3" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 18", + "pnpm": ">= 10" + }, + "peerDependencies": { + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" + } + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -6703,6 +7619,23 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -7351,6 +8284,109 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -7360,6 +8396,41 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", @@ -7375,6 +8446,23 @@ "node": "^16.13.0 || >=18.0.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -7473,6 +8561,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -7488,6 +8593,22 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "devOptional": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 5c8e400..a62cc2c 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,12 @@ "build": "vite build", "preview": "vite preview", "clean": "rm -rf dist", - "lint": "tsc --noEmit" + "lint": "tsc --noEmit", + "test": "vitest", + "test:ui": "vitest --ui", + "test:run": "vitest run", + "docs": "typedoc", + "docs:serve": "typedoc && npx serve docs" }, "dependencies": { "@base-ui/react": "^1.3.0", @@ -34,10 +39,14 @@ "devDependencies": { "@types/express": "^4.17.21", "@types/node": "^22.14.0", + "@vitest/ui": "^4.1.4", "autoprefixer": "^10.4.21", + "jsdom": "^29.0.2", "tailwindcss": "^4.1.14", "tsx": "^4.21.0", + "typedoc": "^0.28.19", "typescript": "~5.8.2", - "vite": "^6.2.0" + "vite": "^6.2.0", + "vitest": "^4.1.4" } } diff --git a/src/lib/__tests__/jellyfinImporter.test.ts b/src/lib/__tests__/jellyfinImporter.test.ts new file mode 100644 index 0000000..7566523 --- /dev/null +++ b/src/lib/__tests__/jellyfinImporter.test.ts @@ -0,0 +1,453 @@ +/** + * Tests for Jellyfin Importer + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { importFromJellyfin, fetchJellyfinLibraries, JellyfinConfig, JellyfinImportOptions, ImportProgress } from '../jellyfinImporter'; + +// Mock global fetch +global.fetch = vi.fn(); + +describe('jellyfinImporter', () => { + const mockConfig: JellyfinConfig = { + url: 'http://localhost:8096', + apiKey: 'test-api-key' + }; + + const mockOptions: JellyfinImportOptions = { + importMovies: true, + importSeries: true, + importMusic: false, + importCast: false, + updateExisting: false + }; + + const mockLogCallback = vi.fn(); + const mockProgressCallback = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetch).mockClear(); + }); + + describe('fetchJellyfinLibraries', () => { + it('should successfully fetch libraries from Jellyfin', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + Items: [ + { Id: 'lib-1', Name: 'Movies', Type: 'CollectionFolder', CollectionType: 'movies' }, + { Id: 'lib-2', Name: 'TV Shows', Type: 'CollectionFolder', CollectionType: 'tvshows' } + ], + TotalRecordCount: 2 + }) + } as Response); + + const libraries = await fetchJellyfinLibraries(mockConfig); + + expect(libraries).toHaveLength(2); + expect(libraries[0].Name).toBe('Movies'); + expect(libraries[1].Name).toBe('TV Shows'); + }); + + it('should handle connection errors', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed')); + + await expect(fetchJellyfinLibraries(mockConfig)).rejects.toThrow('Connection failed'); + }); + + it('should handle API response errors', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + statusText: 'Unauthorized' + } as Response); + + await expect(fetchJellyfinLibraries(mockConfig)).rejects.toThrow('Failed to fetch libraries from Jellyfin: Unauthorized'); + }); + + it('should handle empty library list', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ Items: [], TotalRecordCount: 0 }) + } as Response); + + const libraries = await fetchJellyfinLibraries(mockConfig); + + expect(libraries).toHaveLength(0); + }); + }); + + describe('importFromJellyfin', () => { + it('should successfully import movies from Jellyfin', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ([{ Id: 'user-1' }]) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + Items: [ + { + Id: 'movie-1', + Name: 'Test Movie', + Type: 'Movie', + ProductionYear: 2024, + CommunityRating: 8.5, + Overview: 'A test movie', + Genres: ['Action'], + Studios: [{ Name: 'Test Studio', Id: 'studio-1' }], + People: [ + { Name: 'Actor 1', Type: 'Actor' }, + { Name: 'Director 1', Type: 'Director' } + ], + ImageTags: { Primary: 'tag-1' } + } + ], + TotalRecordCount: 1 + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'media-1' }) + } as Response); + + const result = await importFromJellyfin( + mockConfig, + mockOptions, + mockLogCallback, + mockProgressCallback + ); + + expect(result.stage).toBe('complete'); + expect(result.moviesImported).toBe(1); + expect(result.errors).toHaveLength(0); + expect(mockLogCallback).toHaveBeenCalledWith('Starting Jellyfin import...'); + }); + + it('should successfully import series from Jellyfin', async () => { + const seriesOptions: JellyfinImportOptions = { + ...mockOptions, + importMovies: false, + importSeries: true + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ([{ Id: 'user-1' }]) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + Items: [ + { + Id: 'series-1', + Name: 'Test Series', + Type: 'Series', + ProductionYear: 2024, + CommunityRating: 9.0, + Overview: 'A test series', + Genres: ['Drama'], + Studios: [{ Name: 'Test Studio', Id: 'studio-1' }], + People: [ + { Name: 'Actor 1', Type: 'Actor' } + ], + ImageTags: { Primary: 'tag-1' } + } + ], + TotalRecordCount: 1 + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + Items: [] + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'media-1' }) + } as Response); + + const result = await importFromJellyfin( + mockConfig, + seriesOptions, + mockLogCallback, + mockProgressCallback + ); + + expect(result.stage).toBe('complete'); + expect(result.seriesImported).toBe(1); + }); + + it('should handle connection errors', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed')); + + const result = await importFromJellyfin( + mockConfig, + mockOptions, + mockLogCallback, + mockProgressCallback + ); + + expect(result.stage).toBe('error'); + expect(result.errors).toContain('Connection failed'); + }); + + it('should skip existing items when updateExisting is false', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + items: [ + { id: 'media-1', title: 'Test Movie' } + ] + } + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ([{ Id: 'user-1' }]) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + Items: [ + { + Id: 'movie-1', + Name: 'Test Movie', + Type: 'Movie' + } + ], + TotalRecordCount: 1 + }) + } as Response); + + const result = await importFromJellyfin( + mockConfig, + mockOptions, + mockLogCallback, + mockProgressCallback + ); + + expect(result.moviesImported).toBe(0); + expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped movie: Test Movie (already exists, updateExisting is false)'); + }); + + it('should update existing items when updateExisting is true', async () => { + const updateOptions: JellyfinImportOptions = { + ...mockOptions, + updateExisting: true + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + items: [ + { id: 'media-1', title: 'Test Movie' } + ] + } + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ([{ Id: 'user-1' }]) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + Items: [ + { + Id: 'movie-1', + Name: 'Test Movie', + Type: 'Movie' + } + ], + TotalRecordCount: 1 + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'media-1' }) + } as Response); + + const result = await importFromJellyfin( + mockConfig, + updateOptions, + mockLogCallback, + mockProgressCallback + ); + + expect(result.moviesImported).toBe(1); + }); + + it('should respect library mappings and skip libraries marked as skip', async () => { + const optionsWithMapping: JellyfinImportOptions = { + ...mockOptions, + libraryMappings: [ + { libraryName: 'Movies', category: 'skip' } + ] + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ([{ Id: 'user-1' }]) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + Items: [ + { + Id: 'movie-1', + Name: 'Test Movie', + Type: 'Movie', + ParentId: 'lib-1' + } + ], + TotalRecordCount: 1 + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + Items: [ + { Id: 'lib-1', Name: 'Movies', Type: 'CollectionFolder', CollectionType: 'movies' } + ] + }) + } as Response); + + const result = await importFromJellyfin( + mockConfig, + optionsWithMapping, + mockLogCallback, + mockProgressCallback + ); + + expect(result.moviesImported).toBe(0); + }); + }); + + describe('JellyfinConfig', () => { + it('should accept valid configuration', () => { + const config: JellyfinConfig = { + url: 'http://localhost:8096', + apiKey: 'test-api-key' + }; + + expect(config.url).toBe('http://localhost:8096'); + expect(config.apiKey).toBe('test-api-key'); + }); + }); + + describe('JellyfinImportOptions', () => { + it('should accept valid options', () => { + const options: JellyfinImportOptions = { + importMovies: true, + importSeries: true, + importMusic: false, + importCast: false, + limit: 100, + updateExisting: false + }; + + expect(options.importMovies).toBe(true); + expect(options.importSeries).toBe(true); + expect(options.importMusic).toBe(false); + expect(options.importCast).toBe(false); + expect(options.limit).toBe(100); + expect(options.updateExisting).toBe(false); + }); + + it('should accept library mappings', () => { + const options: JellyfinImportOptions = { + libraryMappings: [ + { libraryName: 'Movies', category: 'Movies' }, + { libraryName: 'TV Shows', category: 'TV Series' }, + { libraryName: 'Anime', category: 'Anime' }, + { libraryName: 'Music', category: 'Music' }, + { libraryName: 'Unwanted', category: 'skip' } + ] + }; + + expect(options.libraryMappings).toHaveLength(5); + expect(options.libraryMappings![4].category).toBe('skip'); + }); + }); + + describe('ImportProgress', () => { + it('should have correct structure', () => { + const progress: ImportProgress = { + current: 5, + total: 10, + stage: 'importing', + message: 'Importing...', + moviesImported: 3, + seriesImported: 2, + musicImported: 0, + castImported: 5, + errors: [] + }; + + expect(progress.current).toBe(5); + expect(progress.total).toBe(10); + expect(progress.stage).toBe('importing'); + expect(progress.moviesImported).toBe(3); + expect(progress.seriesImported).toBe(2); + expect(progress.musicImported).toBe(0); + expect(progress.castImported).toBe(5); + expect(progress.errors).toHaveLength(0); + }); + }); +}); diff --git a/src/lib/__tests__/playniteImporter.test.ts b/src/lib/__tests__/playniteImporter.test.ts new file mode 100644 index 0000000..24aac09 --- /dev/null +++ b/src/lib/__tests__/playniteImporter.test.ts @@ -0,0 +1,364 @@ +/** + * Tests for Playnite Importer + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { importFromPlaynite, PlayniteConfig, ImportProgress } from '../playniteImporter'; + +// Mock global fetch +global.fetch = vi.fn(); + +describe('playniteImporter', () => { + const mockConfig: PlayniteConfig = { + ip: '192.168.1.100', + apiToken: 'test-token', + port: 19821, + updateExisting: false + }; + + const mockLogCallback = vi.fn(); + const mockProgressCallback = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetch).mockClear(); + }); + + describe('importFromPlaynite', () => { + it('should successfully import games from Playnite', async () => { + // Mock existing media check + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + // Mock games list fetch + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + total: 1, + offset: 0, + limit: 5000, + games: [ + { + id: 'game-1', + name: 'Test Game', + description: 'A test game', + genres: ['Action'], + developers: ['Test Dev'], + publishers: ['Test Pub'], + releaseDate: '2024-01-01' + } + ] + }) + } as Response); + + // Mock game detail fetch + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 'game-1', + name: 'Test Game', + description: 'A test game', + genres: ['Action'], + developers: ['Test Dev'], + publishers: ['Test Pub'], + releaseDate: '2024-01-01' + }) + } as Response); + + // Mock media creation + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'media-1' }) + } as Response); + + const result = await importFromPlaynite( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.stage).toBe('complete'); + expect(result.gamesImported).toBe(1); + expect(result.errors).toHaveLength(0); + expect(mockLogCallback).toHaveBeenCalledWith('Starting Playnite import...'); + }); + + it('should handle connection errors', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed')); + + const result = await importFromPlaynite( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.stage).toBe('error'); + expect(result.errors).toContain('Connection failed'); + }); + + it('should handle API response errors', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + statusText: 'Unauthorized' + } as Response); + + const result = await importFromPlaynite( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.stage).toBe('error'); + expect(result.errors).toContain('Failed to connect to Playnite API: Unauthorized'); + }); + + it('should skip existing games when updateExisting is false', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + items: [ + { id: 'media-1', title: 'Test Game' } + ] + } + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + total: 1, + offset: 0, + limit: 5000, + games: [ + { + id: 'game-1', + name: 'Test Game', + description: 'A test game' + } + ] + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 'game-1', + name: 'Test Game', + description: 'A test game' + }) + } as Response); + + const result = await importFromPlaynite( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.gamesImported).toBe(0); + expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped game: Test Game (already exists, updateExisting is false)'); + }); + + it('should update existing games when updateExisting is true', async () => { + const configWithUpdate: PlayniteConfig = { + ...mockConfig, + updateExisting: true + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + items: [ + { id: 'media-1', title: 'Test Game' } + ] + } + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + total: 1, + offset: 0, + limit: 5000, + games: [ + { + id: 'game-1', + name: 'Test Game', + description: 'A test game' + } + ] + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 'game-1', + name: 'Test Game', + description: 'A test game' + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'media-1' }) + } as Response); + + const result = await importFromPlaynite( + configWithUpdate, + mockLogCallback, + mockProgressCallback + ); + + expect(result.gamesImported).toBe(1); + }); + + it('should convert ratings from 0-100 scale to 0-5 scale', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + total: 1, + offset: 0, + limit: 5000, + games: [ + { + id: 'game-1', + name: 'Test Game', + userScore: 80, + communityScore: 90, + criticScore: 85 + } + ] + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 'game-1', + name: 'Test Game', + userScore: 80, + communityScore: 90, + criticScore: 85 + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'media-1' }) + } as Response); + + const result = await importFromPlaynite( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.gamesImported).toBe(1); + }); + + it('should convert playtime from seconds to minutes', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + total: 1, + offset: 0, + limit: 5000, + games: [ + { + id: 'game-1', + name: 'Test Game', + playtime: 3600 // 1 hour in seconds + } + ] + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 'game-1', + name: 'Test Game', + playtime: 3600 + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'media-1' }) + } as Response); + + const result = await importFromPlaynite( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.gamesImported).toBe(1); + }); + }); + + describe('PlayniteConfig', () => { + it('should accept valid configuration', () => { + const config: PlayniteConfig = { + ip: '192.168.1.100', + apiToken: 'test-token' + }; + + expect(config.ip).toBe('192.168.1.100'); + expect(config.apiToken).toBe('test-token'); + expect(config.port).toBeUndefined(); + expect(config.updateExisting).toBeUndefined(); + }); + + it('should accept configuration with optional fields', () => { + const config: PlayniteConfig = { + ip: '192.168.1.100', + apiToken: 'test-token', + port: 19821, + updateExisting: true + }; + + expect(config.port).toBe(19821); + expect(config.updateExisting).toBe(true); + }); + }); + + describe('ImportProgress', () => { + it('should have correct structure', () => { + const progress: ImportProgress = { + current: 5, + total: 10, + stage: 'importing', + message: 'Importing...', + gamesImported: 5, + errors: [] + }; + + expect(progress.current).toBe(5); + expect(progress.total).toBe(10); + expect(progress.stage).toBe('importing'); + expect(progress.gamesImported).toBe(5); + expect(progress.errors).toHaveLength(0); + }); + }); +}); diff --git a/src/lib/__tests__/stashappImporter.test.ts b/src/lib/__tests__/stashappImporter.test.ts new file mode 100644 index 0000000..169cfaa --- /dev/null +++ b/src/lib/__tests__/stashappImporter.test.ts @@ -0,0 +1,431 @@ +/** + * Tests for StashAPP Importer + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { importFromStashAPP, updateActorsFromStashAPP, StashAPPConfig, ImportProgress } from '../stashappImporter'; + +// Mock global fetch +global.fetch = vi.fn(); + +describe('stashappImporter', () => { + const mockConfig: StashAPPConfig = { + url: 'http://localhost:9999', + apiKey: 'test-api-key', + blacklist: ['/AI/', 'temp'], + updateExisting: false + }; + + const mockLogCallback = vi.fn(); + const mockProgressCallback = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetch).mockClear(); + }); + + describe('importFromStashAPP', () => { + it('should successfully import scenes and performers from StashAPP', async () => { + // Mock existing media and cast check + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + // Mock scenes fetch + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + findScenes: { + scenes: [ + { + id: 'scene-1', + title: 'Test Scene', + details: 'A test scene', + date: '2024-01-01', + rating100: 80, + paths: { + screenshot: 'http://example.com/screenshot.jpg' + }, + files: [ + { + size: 1000000, + duration: 1800, + video_codec: 'h264', + audio_codec: 'aac', + width: 1920, + height: 1080, + path: '/videos/test.mp4' + } + ], + performers: [] + } + ], + count: 1 + } + } + }) + } as Response); + + // Mock media creation + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'media-1' }) + } as Response); + + const result = await importFromStashAPP( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.stage).toBe('complete'); + expect(result.videosImported).toBe(1); + expect(result.errors).toHaveLength(0); + expect(mockLogCallback).toHaveBeenCalledWith('Starting StashAPP import...'); + }); + + it('should handle connection errors', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed')); + + const result = await importFromStashAPP( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.stage).toBe('error'); + expect(result.errors).toContain('Connection failed'); + }); + + it('should handle API response errors', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + statusText: 'Unauthorized' + } as Response); + + const result = await importFromStashAPP( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.stage).toBe('error'); + expect(result.errors).toContain('Failed to connect to StashAPP: Unauthorized'); + }); + + it('should skip blacklisted scenes', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + findScenes: { + scenes: [ + { + id: 'scene-1', + title: 'Test Scene', + paths: { screenshot: 'http://example.com/screenshot.jpg' }, + files: [ + { + path: '/videos/AI/test.mp4', + size: 1000000, + duration: 1800 + } + ], + performers: [] + } + ], + count: 1 + } + } + }) + } as Response); + + const result = await importFromStashAPP( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.videosImported).toBe(0); + expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped blacklisted scene: Test Scene'); + }); + + it('should convert rating from 0-100 scale to 0-5 scale', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + findScenes: { + scenes: [ + { + id: 'scene-1', + title: 'Test Scene', + rating100: 80, + paths: { screenshot: 'http://example.com/screenshot.jpg' }, + files: [{ path: '/videos/test.mp4', size: 1000000, duration: 1800 }], + performers: [] + } + ], + count: 1 + } + } + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'media-1' }) + } as Response); + + const result = await importFromStashAPP( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.videosImported).toBe(1); + }); + + it('should determine aspect ratio from file dimensions', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + findScenes: { + scenes: [ + { + id: 'scene-1', + title: 'Test Scene', + paths: { screenshot: 'http://example.com/screenshot.jpg' }, + files: [ + { + path: '/videos/test.mp4', + size: 1000000, + duration: 1800, + width: 1920, + height: 1080 + } + ], + performers: [] + } + ], + count: 1 + } + } + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'media-1' }) + } as Response); + + const result = await importFromStashAPP( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.videosImported).toBe(1); + }); + }); + + describe('updateActorsFromStashAPP', () => { + it('should successfully update actors from StashAPP', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + findPerformers: { + performers: [ + { + id: 'performer-1', + name: 'Test Performer', + image_path: 'http://example.com/photo.jpg', + details: 'A test performer', + birthdate: '1990-01-01', + country: 'USA' + } + ], + count: 1 + } + } + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'cast-1' }) + } as Response); + + const result = await updateActorsFromStashAPP( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.stage).toBe('complete'); + expect(result.actorsImported).toBe(1); + expect(result.errors).toHaveLength(0); + expect(mockLogCallback).toHaveBeenCalledWith('Starting StashAPP actor update...'); + }); + + it('should update existing actors', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + items: [ + { id: 'cast-1', name: 'Test Performer', photo: 'old-photo.jpg' } + ] + } + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + findPerformers: { + performers: [ + { + id: 'performer-1', + name: 'Test Performer', + image_path: 'http://example.com/new-photo.jpg', + details: 'Updated bio' + } + ], + count: 1 + } + } + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'cast-1' }) + } as Response); + + const result = await updateActorsFromStashAPP( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.actorsImported).toBe(1); + expect(mockLogCallback).toHaveBeenCalledWith('✓ Updated actor: Test Performer'); + }); + + it('should handle connection errors', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed')); + + const result = await updateActorsFromStashAPP( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.stage).toBe('error'); + expect(result.errors).toContain('Connection failed'); + }); + }); + + describe('StashAPPConfig', () => { + it('should accept valid configuration', () => { + const config: StashAPPConfig = { + url: 'http://localhost:9999', + apiKey: 'test-api-key' + }; + + expect(config.url).toBe('http://localhost:9999'); + expect(config.apiKey).toBe('test-api-key'); + expect(config.blacklist).toBeUndefined(); + expect(config.updateExisting).toBeUndefined(); + }); + + it('should accept configuration with optional fields', () => { + const config: StashAPPConfig = { + url: 'http://localhost:9999', + apiKey: 'test-api-key', + blacklist: ['/AI/', 'temp'], + updateExisting: true + }; + + expect(config.blacklist).toEqual(['/AI/', 'temp']); + expect(config.updateExisting).toBe(true); + }); + }); + + describe('ImportProgress', () => { + it('should have correct structure', () => { + const progress: ImportProgress = { + current: 5, + total: 10, + stage: 'importing', + message: 'Importing...', + videosImported: 5, + actorsImported: 3, + errors: [] + }; + + expect(progress.current).toBe(5); + expect(progress.total).toBe(10); + expect(progress.stage).toBe('importing'); + expect(progress.videosImported).toBe(5); + expect(progress.actorsImported).toBe(3); + expect(progress.errors).toHaveLength(0); + }); + }); +}); diff --git a/src/lib/__tests__/xbvrImporter.test.ts b/src/lib/__tests__/xbvrImporter.test.ts new file mode 100644 index 0000000..7bb48cc --- /dev/null +++ b/src/lib/__tests__/xbvrImporter.test.ts @@ -0,0 +1,524 @@ +/** + * Tests for XBVR Importer + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { importFromXBVR, XBVRConfig, ImportProgress } from '../xbvrImporter'; + +// Mock global fetch +global.fetch = vi.fn(); + +describe('xbvrImporter', () => { + const mockConfig: XBVRConfig = { + url: 'http://localhost:9999', + apiKey: 'test-api-key', + updateExisting: false + }; + + const mockLogCallback = vi.fn(); + const mockProgressCallback = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetch).mockClear(); + }); + + describe('importFromXBVR', () => { + it('should successfully import videos and actors from XBVR', async () => { + // Mock existing media and cast check + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + // Mock scene list fetch + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + scenes: [ + { + name: 'Recent', + list: [ + { + title: 'Test Video', + videoLength: 1800, + thumbnailUrl: 'http://example.com/thumb.jpg', + video_url: 'http://example.com/api/video/1' + } + ] + }, + { + name: 'Favorites', + list: [] + } + ] + }) + } as Response); + + // Mock video detail fetch + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 1, + title: 'Test Video', + description: 'A test VR video', + date: 1704067200, // 2024-01-01 + thumbnailUrl: 'http://example.com/thumb.jpg', + rating_avg: 8.5, + screenType: '180', + stereoMode: 'sbs', + videoLength: 1800, + paysite: { name: 'Test Studio' }, + actors: [ + { id: 1, name: 'Actor 1' }, + { id: 2, name: 'Actor 2' } + ], + categories: [ + { tag: { name: 'VR' } }, + { tag: { name: '180°' } } + ] + }) + } as Response); + + // Mock actor creation + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'cast-1' }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'cast-2' }) + } as Response); + + // Mock media creation + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'media-1' }) + } as Response); + + const result = await importFromXBVR( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.stage).toBe('complete'); + expect(result.videosImported).toBe(1); + expect(result.actorsImported).toBe(2); + expect(result.errors).toHaveLength(0); + expect(mockLogCallback).toHaveBeenCalledWith('Starting DeoVR import...'); + }); + + it('should handle connection errors', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection failed')); + + const result = await importFromXBVR( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.stage).toBe('error'); + expect(result.errors).toContain('Connection failed'); + }); + + it('should handle API response errors', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + statusText: 'Unauthorized' + } as Response); + + const result = await importFromXBVR( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.stage).toBe('error'); + expect(result.errors).toContain('Failed to connect to DeoVR API: Unauthorized'); + }); + + it('should skip videos starting with aka:', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + scenes: [ + { + name: 'Recent', + list: [ + { + title: 'aka: Test Video', + videoLength: 1800, + thumbnailUrl: 'http://example.com/thumb.jpg', + video_url: 'http://example.com/api/video/1' + } + ] + } + ] + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 1, + title: 'aka: Test Video', + date: 1704067200, + videoLength: 1800, + actors: [], + categories: [] + }) + } as Response); + + const result = await importFromXBVR( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.videosImported).toBe(0); + expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped \'aka:\' video: aka: Test Video'); + }); + + it('should skip actors containing aka:', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + scenes: [ + { + name: 'Recent', + list: [ + { + title: 'Test Video', + videoLength: 1800, + thumbnailUrl: 'http://example.com/thumb.jpg', + video_url: 'http://example.com/api/video/1' + } + ] + } + ] + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 1, + title: 'Test Video', + date: 1704067200, + videoLength: 1800, + actors: [ + { id: 1, name: 'Actor 1' }, + { id: 2, name: 'aka: Actor 2' } + ], + categories: [] + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'cast-1' }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'media-1' }) + } as Response); + + const result = await importFromXBVR( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.actorsImported).toBe(1); + expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped \'aka:\' actor: aka: Actor 2'); + }); + + it('should skip existing videos when updateExisting is false', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + items: [ + { id: 'media-1', title: 'Test Video' } + ] + } + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + scenes: [ + { + name: 'Recent', + list: [ + { + title: 'Test Video', + videoLength: 1800, + thumbnailUrl: 'http://example.com/thumb.jpg', + video_url: 'http://example.com/api/video/1' + } + ] + } + ] + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 1, + title: 'Test Video', + date: 1704067200, + videoLength: 1800, + actors: [], + categories: [] + }) + } as Response); + + const result = await importFromXBVR( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.videosImported).toBe(0); + expect(mockLogCallback).toHaveBeenCalledWith('⊘ Skipped duplicate: Test Video (updateExisting is false)'); + }); + + it('should determine aspect ratio based on screenType and stereoMode', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + scenes: [ + { + name: 'Recent', + list: [ + { + title: '360 Video', + videoLength: 1800, + thumbnailUrl: 'http://example.com/thumb.jpg', + video_url: 'http://example.com/api/video/1' + } + ] + } + ] + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 1, + title: '360 Video', + date: 1704067200, + videoLength: 1800, + screenType: '360', + stereoMode: 'sbs', + actors: [], + categories: [] + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'media-1' }) + } as Response); + + const result = await importFromXBVR( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.videosImported).toBe(1); + }); + + it('should convert Unix timestamp to date', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + scenes: [ + { + name: 'Recent', + list: [ + { + title: 'Test Video', + videoLength: 1800, + thumbnailUrl: 'http://example.com/thumb.jpg', + video_url: 'http://example.com/api/video/1' + } + ] + } + ] + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 1, + title: 'Test Video', + date: 1704067200, // 2024-01-01 + videoLength: 1800, + actors: [], + categories: [] + }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'media-1' }) + } as Response); + + const result = await importFromXBVR( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.videosImported).toBe(1); + }); + + it('should handle missing Recent scene group', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { items: [] } }) + } as Response); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + scenes: [ + { + name: 'Favorites', + list: [] + } + ] + }) + } as Response); + + const result = await importFromXBVR( + mockConfig, + mockLogCallback, + mockProgressCallback + ); + + expect(result.videosImported).toBe(0); + expect(result.actorsImported).toBe(0); + expect(mockLogCallback).toHaveBeenCalledWith('Found 0 videos in \'Recent\' scene group'); + }); + }); + + describe('XBVRConfig', () => { + it('should accept valid configuration', () => { + const config: XBVRConfig = { + url: 'http://localhost:9999', + apiKey: 'test-api-key' + }; + + expect(config.url).toBe('http://localhost:9999'); + expect(config.apiKey).toBe('test-api-key'); + expect(config.updateExisting).toBeUndefined(); + }); + + it('should accept configuration with optional fields', () => { + const config: XBVRConfig = { + url: 'http://localhost:9999', + apiKey: 'test-api-key', + updateExisting: true + }; + + expect(config.updateExisting).toBe(true); + }); + }); + + describe('ImportProgress', () => { + it('should have correct structure', () => { + const progress: ImportProgress = { + current: 5, + total: 10, + stage: 'importing', + message: 'Importing...', + videosImported: 5, + actorsImported: 3, + errors: [] + }; + + expect(progress.current).toBe(5); + expect(progress.total).toBe(10); + expect(progress.stage).toBe('importing'); + expect(progress.videosImported).toBe(5); + expect(progress.actorsImported).toBe(3); + expect(progress.errors).toHaveLength(0); + }); + }); +}); diff --git a/src/lib/jellyfinImporter.ts b/src/lib/jellyfinImporter.ts index dbf8fdc..a117218 100644 --- a/src/lib/jellyfinImporter.ts +++ b/src/lib/jellyfinImporter.ts @@ -1,38 +1,82 @@ +/** + * Jellyfin Importer Module + * + * This module provides functionality to import media from a Jellyfin media server into the Kyoo media database. + * It supports importing movies, TV series (including episodes), music albums, and cast members. + * The module handles library mapping to categorize content appropriately and supports both new imports + * and updates to existing entries. + * + * @module jellyfinImporter + */ + const BASE_URL = import.meta.env.VITE_API_URL; // Import the source mapping and types import { SOURCE_CATEGORY_MAPPING, Media, Staff, Episode, Track } from '@/types'; +/** + * Configuration for connecting to a Jellyfin instance + */ export interface JellyfinConfig { + /** URL of the Jellyfin server */ url: string; + /** API key for authentication with Jellyfin */ apiKey: string; } +/** + * Mapping configuration for Jellyfin libraries to Kyoo categories + */ export interface LibraryMapping { + /** Name of the Jellyfin library */ libraryName: string; + /** Category to map this library to (use 'skip' to exclude the library) */ category: 'TV Series' | 'Anime' | 'Movies' | 'Music' | 'skip'; - pathSegments?: string[]; // Additional path segments that map to this library + /** Additional path segments that map to this library */ + pathSegments?: string[]; } +/** + * Options for controlling the Jellyfin import process + */ export interface JellyfinImportOptions { + /** Whether to import movies */ importMovies?: boolean; + /** Whether to import TV series */ importSeries?: boolean; + /** Whether to import music */ importMusic?: boolean; + /** Whether to import cast members */ importCast?: boolean; + /** Maximum number of items to import (optional) */ limit?: number; + /** Library to category mappings */ libraryMappings?: LibraryMapping[]; - updateExisting?: boolean; // If true, update existing items; if false, only import new items + /** If true, update existing items; if false, only import new items */ + updateExisting?: boolean; } +/** + * Progress tracking for the import operation + */ export interface ImportProgress { + /** Current number of items processed */ current: number; + /** Total number of items to process */ total: number; + /** Current stage of the import process */ stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error'; + /** Human-readable status message */ message: string; + /** Number of movies successfully imported */ moviesImported: number; + /** Number of series successfully imported */ seriesImported: number; + /** Number of music items successfully imported */ musicImported: number; + /** Number of cast members successfully imported */ castImported: number; + /** Array of error messages encountered during import */ errors: string[]; } @@ -127,10 +171,23 @@ export interface JellyfinTrack { Artists?: string[]; } +/** + * Callback function for logging import progress messages + * @param message - The log message to display + */ export type LogCallback = (message: string) => void; + +/** + * Callback function for updating import progress + * @param progress - Partial progress object with updated fields + */ export type ProgressCallback = (progress: Partial) => void; -// Helper function to normalize URL (avoid double slashes) +/** + * Normalizes a URL by removing trailing slashes + * @param url - The URL to normalize + * @returns The normalized URL + */ function normalizeUrl(url: string): string { return url.replace(/\/+$/, ''); } @@ -157,12 +214,20 @@ function getJellyfinImageUrl(config: JellyfinConfig, itemId: string, imageTag: s return `${normalizeUrl(config.url)}/Items/${itemId}/Images/${imageType}?tag=${imageTag}`; } -// Helper function to convert ticks to minutes +/** + * Converts Jellyfin ticks (100ns units) to minutes + * @param ticks - Time in ticks (100 nanosecond units) + * @returns Time in minutes + */ function ticksToMinutes(ticks: number): number { return Math.floor(ticks / 600000000); } -// Helper function to format date +/** + * Formats a date string to ISO format (YYYY-MM-DD) + * @param dateString - The date string to format + * @returns Formatted date string or null if invalid + */ function formatDate(dateString?: string): string | null { if (!dateString) return null; try { @@ -173,7 +238,11 @@ function formatDate(dateString?: string): string | null { } } -// Helper function to get year from date +/** + * Extracts the year from a date string + * @param dateString - The date string to extract year from + * @returns The year as a number + */ function getYear(dateString?: string): number { if (!dateString) return new Date().getFullYear(); try { @@ -241,7 +310,11 @@ async function fetchWithAuth(url: string, apiKey: string, options: RequestInit = return fetch(url, { ...options, headers }); } -// Fetch libraries from Jellyfin +/** + * Fetches all libraries from a Jellyfin instance + * @param config - Configuration for connecting to Jellyfin + * @returns Promise resolving to an array of library information + */ export async function fetchJellyfinLibraries(config: JellyfinConfig): Promise> { const userId = await getJellyfinUserId(config); @@ -764,7 +837,34 @@ function convertJellyfinPersonToCast(person: JellyfinPerson, config: JellyfinCon }; } -// Main import function +/** + * Imports media from a Jellyfin instance into the Kyoo media database + * + * This function performs the following steps: + * 1. Fetches existing media and cast from Kyoo to check for duplicates + * 2. Fetches Jellyfin libraries for category mapping (if library mappings are provided) + * 3. Imports movies (if enabled) + * 4. Imports TV series with episodes (if enabled) + * 5. Imports music albums with tracks (if enabled) + * 6. Imports cast members (if enabled) + * + * @param config - Configuration for connecting to Jellyfin + * @param options - Import options to control behavior + * @param logCallback - Callback function for logging progress messages + * @param progressCallback - Callback function for updating import progress + * @returns Promise resolving to the final import progress state + * + * @example + * ```typescript + * const progress = await importFromJellyfin( + * { url: 'http://localhost:8096', apiKey: 'your-api-key' }, + * { importMovies: true, importSeries: true, libraryMappings: [...] }, + * (msg) => console.log(msg), + * (prog) => updateUI(prog) + * ); + * console.log(`Imported ${progress.moviesImported} movies and ${progress.seriesImported} series`); + * ``` + */ export async function importFromJellyfin( config: JellyfinConfig, options: JellyfinImportOptions, diff --git a/src/lib/playniteImporter.ts b/src/lib/playniteImporter.ts index c0447ba..a104da8 100644 --- a/src/lib/playniteImporter.ts +++ b/src/lib/playniteImporter.ts @@ -1,72 +1,151 @@ +/** + * Playnite Importer Module + * + * This module provides functionality to import games from a Playnite library into the Kyoo media database. + * It fetches game data from the Playnite API, converts it to the Kyoo media format, and handles both + * new imports and updates to existing entries. + * + * @module playniteImporter + */ + const BASE_URL = import.meta.env.VITE_API_URL; // Import the source mapping and types import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types'; +/** + * Configuration for connecting to a Playnite instance + */ export interface PlayniteConfig { + /** IP address of the Playnite server */ ip: string; + /** API token for authentication with Playnite */ apiToken: string; + /** Port number of the Playnite API (default: 19821) */ port?: number; + /** If true, update existing media entries; if false, only import new entries */ updateExisting?: boolean; } +/** + * Progress tracking for the import operation + */ export interface ImportProgress { + /** Current number of items processed */ current: number; + /** Total number of items to process */ total: number; + /** Current stage of the import process */ stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error'; + /** Human-readable status message */ message: string; + /** Number of games successfully imported */ gamesImported: number; + /** Array of error messages encountered during import */ errors: string[]; } +/** + * Game data structure as returned by the Playnite API + */ export interface PlayniteGame { + /** Unique identifier for the game */ id: string; + /** Game name */ name: string; + /** Alternate name for sorting purposes */ sortingName?: string; + /** Game description */ description?: string; + /** User notes */ notes?: string; + /** Game version */ version?: string; + /** Whether the game is hidden */ hidden?: boolean; + /** Whether the game is marked as favorite */ favorite?: boolean; + /** User rating (0-100) */ userScore?: number; + /** Community rating (0-100) */ communityScore?: number; + /** Critic rating (0-100) */ criticScore?: number; + /** Release date in ISO format */ releaseDate?: string; + /** Completion status (e.g., 'Completed', 'Playing', 'Abandoned') */ completionStatus?: string; + /** Game categories */ categories?: string[]; + /** Game tags */ tags?: string[]; + /** Game features */ features?: string[]; + /** Game genres */ genres?: string[]; + /** Developer names */ developers?: string[]; + /** Publisher names */ publishers?: string[]; + /** Series name */ series?: string[]; + /** Platform names */ platforms?: string[]; + /** Age rating names */ ageRatings?: string[]; + /** Region names */ regions?: string[]; + /** External links */ links?: Array<{ name: string; url: string; }>; + /** Total playtime in seconds */ playtime?: number; + /** Number of times played */ playCount?: number; + /** Last activity timestamp */ lastActivity?: string; + /** Date added to library */ added?: string; + /** Last played date */ lastPlayed?: string; + /** Source platform/library */ source?: string; + /** Whether the game is currently installed */ isInstalled?: boolean; + /** Cover image as base64 data URI */ coverBase64?: string; + /** Background image as base64 data URI */ backgroundBase64?: string; + /** Icon image as base64 data URI */ iconBase64?: string; } +/** + * Response structure for the Playnite games API endpoint + */ export interface PlayniteGamesResponse { + /** Total number of games available */ total: number; + /** Offset for pagination */ offset: number; + /** Limit for pagination */ limit: number; + /** Array of game objects */ games: PlayniteGame[]; } +/** + * Callback function for logging import progress messages + * @param message - The log message to display + */ export type LogCallback = (message: string) => void; + +/** + * Callback function for updating import progress + * @param progress - Partial progress object with updated fields + */ export type ProgressCallback = (progress: Partial) => void; /* async function fetchGameCover(baseUrl: string, headers: Record, gameId: string): Promise { @@ -136,6 +215,31 @@ async function fetchGameIcon(baseUrl: string, headers: Record, g } } */ +/** + * Imports games from a Playnite library into the Kyoo media database + * + * This function performs the following steps: + * 1. Fetches existing media from Kyoo to check for duplicates + * 2. Fetches all games from the Playnite API + * 3. Fetches detailed information for each game + * 4. Converts Playnite game data to Kyoo media format + * 5. Imports or updates each game in the Kyoo database + * + * @param config - Configuration for connecting to Playnite + * @param logCallback - Callback function for logging progress messages + * @param progressCallback - Callback function for updating import progress + * @returns Promise resolving to the final import progress state + * + * @example + * ```typescript + * const progress = await importFromPlaynite( + * { ip: '192.168.1.100', apiToken: 'your-token', port: 19821 }, + * (msg) => console.log(msg), + * (prog) => updateUI(prog) + * ); + * console.log(`Imported ${progress.gamesImported} games`); + * ``` + */ export async function importFromPlaynite( config: PlayniteConfig, logCallback: LogCallback, diff --git a/src/lib/stashappImporter.ts b/src/lib/stashappImporter.ts index 04485e6..b4cf92a 100644 --- a/src/lib/stashappImporter.ts +++ b/src/lib/stashappImporter.ts @@ -1,36 +1,77 @@ +/** + * StashAPP Importer Module + * + * This module provides functionality to import adult video content and performers from a StashAPP instance + * into the Kyoo media database. It fetches scene and performer data via GraphQL, converts it to the Kyoo + * media format, and handles both new imports and updates to existing entries. + * + * @module stashappImporter + */ + const BASE_URL = import.meta.env.VITE_API_URL; // Import the source mapping and types import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types'; +/** + * Configuration for connecting to a StashAPP instance + */ export interface StashAPPConfig { + /** URL of the StashAPP server */ url: string; + /** API key for authentication (optional) */ apiKey?: string; - blacklist?: ['/AI/', 'temp', 'backup']; + /** List of path patterns to blacklist during import */ + blacklist?: string[]; + /** If true, update existing media entries; if false, only import new entries */ updateExisting?: boolean; } +/** + * Progress tracking for the import operation + */ export interface ImportProgress { + /** Current number of items processed */ current: number; + /** Total number of items to process */ total: number; + /** Current stage of the import process */ stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error'; + /** Human-readable status message */ message: string; + /** Number of videos successfully imported */ videosImported: number; + /** Number of actors successfully imported */ actorsImported: number; + /** Array of error messages encountered during import */ errors: string[]; } +/** + * Scene data structure as returned by the StashAPP GraphQL API + */ export interface StashAPPScene { + /** Unique identifier for the scene */ id: string; + /** Scene title */ title: string; + /** Scene description/details */ details: string; + /** Scene URL */ url: string; + /** Release date in ISO format */ date: string; + /** Rating on a 0-100 scale */ rating100: number; + /** Whether the scene is organized */ organized: boolean; + /** O-counter value */ o_counter: number; + /** Creation timestamp */ created_at: string; + /** Last update timestamp */ updated_at: string; + /** File paths for various media assets */ paths: { screenshot: string; preview: string; @@ -41,6 +82,7 @@ export interface StashAPPScene { funscript: string; caption: string; }; + /** Array of file information */ files: Array<{ size: number; duration: number; @@ -50,6 +92,7 @@ export interface StashAPPScene { height: number; path: string; }>; + /** Array of performers in the scene */ performers: Array<{ id: string; name: string; @@ -154,9 +197,24 @@ export interface StashAPPPerformersResponse { }; } +/** + * Callback function for logging import progress messages + * @param message - The log message to display + */ export type LogCallback = (message: string) => void; + +/** + * Callback function for updating import progress + * @param progress - Partial progress object with updated fields + */ export type ProgressCallback = (progress: Partial) => void; +/** + * Checks if a file path matches any blacklist pattern + * @param filePath - The file path to check + * @param blacklist - Array of blacklist patterns + * @returns True if the path is blacklisted, false otherwise + */ function isPathBlacklisted(filePath: string, blacklist: string[]): boolean { if (!blacklist || blacklist.length === 0) { return false; @@ -164,6 +222,17 @@ function isPathBlacklisted(filePath: string, blacklist: string[]): boolean { return blacklist.some(pattern => filePath.includes(pattern)); } +/** + * Updates or creates actor entries from StashAPP performers + * + * This function fetches all performers from StashAPP and updates or creates + * corresponding actor entries in the Kyoo database. + * + * @param config - Configuration for connecting to StashAPP + * @param logCallback - Callback function for logging progress messages + * @param progressCallback - Callback function for updating import progress + * @returns Promise resolving to the final import progress state + */ export async function updateActorsFromStashAPP( config: StashAPPConfig, logCallback: LogCallback, @@ -386,6 +455,31 @@ export async function updateActorsFromStashAPP( } } +/** + * Imports scenes and performers from a StashAPP instance into the Kyoo media database + * + * This function performs the following steps: + * 1. Fetches existing media and cast from Kyoo to check for duplicates + * 2. Fetches all scenes from StashAPP via GraphQL + * 3. Extracts unique performers from all scenes + * 4. Imports or updates performers first + * 5. Imports or updates scenes with their associated performers + * + * @param config - Configuration for connecting to StashAPP + * @param logCallback - Callback function for logging progress messages + * @param progressCallback - Callback function for updating import progress + * @returns Promise resolving to the final import progress state + * + * @example + * ```typescript + * const progress = await importFromStashAPP( + * { url: 'http://localhost:9999', apiKey: 'your-api-key' }, + * (msg) => console.log(msg), + * (prog) => updateUI(prog) + * ); + * console.log(`Imported ${progress.videosImported} videos and ${progress.actorsImported} actors`); + * ``` + */ export async function importFromStashAPP( config: StashAPPConfig, logCallback: LogCallback, diff --git a/src/lib/xbvrImporter.ts b/src/lib/xbvrImporter.ts index ab2bd4a..05c39e9 100644 --- a/src/lib/xbvrImporter.ts +++ b/src/lib/xbvrImporter.ts @@ -1,48 +1,96 @@ +/** + * XBVR Importer Module + * + * This module provides functionality to import VR adult video content from an XBVR instance into the Kyoo media database. + * It fetches scene data from the DeoVR API endpoint, extracts actors and video details, and handles both new imports + * and updates to existing entries. The module specifically filters for content in the 'Recent' scene group. + * + * @module xbvrImporter + */ + const BASE_URL = import.meta.env.VITE_API_URL; // Import the source mapping and types import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types'; +/** + * Configuration for connecting to an XBVR instance + */ export interface XBVRConfig { + /** URL of the XBVR server */ url: string; + /** API key for authentication (optional) */ apiKey?: string; + /** If true, update existing media entries; if false, only import new entries */ updateExisting?: boolean; } +/** + * Progress tracking for the import operation + */ export interface ImportProgress { + /** Current number of items processed */ current: number; + /** Total number of items to process */ total: number; + /** Current stage of the import process */ stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error'; + /** Human-readable status message */ message: string; + /** Number of videos successfully imported */ videosImported: number; + /** Number of actors successfully imported */ actorsImported: number; + /** Array of error messages encountered during import */ errors: string[]; } +/** + * Basic video information from the DeoVR scene list + */ export interface XBVRVideo { + /** Video title */ title: string; + /** Video length in seconds */ videoLength: number; + /** URL to the video thumbnail */ thumbnailUrl: string; + /** URL to fetch detailed video information */ video_url: string; } +/** + * Detailed video information as returned by the XBVR API + */ export interface XBVRVideoDetail { + /** Unique video identifier */ id: number; + /** Video title */ title: string; + /** Video description */ description: string; + /** Release date as Unix timestamp */ date: number; + /** URL to the video thumbnail */ thumbnailUrl: string; + /** Average rating */ rating_avg: number; + /** Screen type (e.g., '180', '360', 'dome') */ screenType: string; + /** Stereo mode (e.g., 'sbs', 'tb') */ stereoMode: string; + /** Video length in seconds */ videoLength: number; + /** Pay site information */ paysite?: { name: string; }; + /** Array of actors in the video */ actors: Array<{ id: number; name: string; }>; + /** Array of category tags */ categories: Array<{ tag: { name: string; @@ -50,16 +98,59 @@ export interface XBVRVideoDetail { }>; } +/** + * Scene list structure as returned by the DeoVR API + */ export interface XBVRSceneList { + /** Array of scene groups */ scenes: Array<{ + /** Name of the scene group (e.g., 'Recent', 'Favorites') */ name: string; + /** List of videos in this group */ list: XBVRVideo[]; }>; } +/** + * Callback function for logging import progress messages + * @param message - The log message to display + */ export type LogCallback = (message: string) => void; + +/** + * Callback function for updating import progress + * @param progress - Partial progress object with updated fields + */ export type ProgressCallback = (progress: Partial) => void; +/** + * Imports VR adult videos and actors from an XBVR instance into the Kyoo media database + * + * This function performs the following steps: + * 1. Fetches existing media and cast from Kyoo to check for duplicates + * 2. Fetches the scene list from the DeoVR API endpoint + * 3. Extracts videos from the 'Recent' scene group + * 4. Fetches detailed information for each video + * 5. Imports or updates actors first + * 6. Imports or updates videos with their associated actors + * + * Videos and actors containing 'aka:' in their name are automatically skipped. + * + * @param config - Configuration for connecting to XBVR + * @param logCallback - Callback function for logging progress messages + * @param progressCallback - Callback function for updating import progress + * @returns Promise resolving to the final import progress state + * + * @example + * ```typescript + * const progress = await importFromXBVR( + * { url: 'http://localhost:9999', apiKey: 'your-api-key' }, + * (msg) => console.log(msg), + * (prog) => updateUI(prog) + * ); + * console.log(`Imported ${progress.videosImported} videos and ${progress.actorsImported} actors`); + * ``` + */ export async function importFromXBVR( config: XBVRConfig, logCallback: LogCallback, diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 0000000..edfd73e --- /dev/null +++ b/typedoc.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": [ + "./src/lib/playniteImporter.ts", + "./src/lib/stashappImporter.ts", + "./src/lib/jellyfinImporter.ts", + "./src/lib/xbvrImporter.ts" + ], + "out": "docs", + "name": "Kyoo Importer Documentation", + "theme": "default", + "excludePrivate": true, + "excludeProtected": false, + "excludeInternal": true, + "hideGenerator": true, + "sort": ["source-order"], + "categorizeByGroup": true, + "defaultCategory": "Other", + "categoryOrder": [ + "Configuration", + "Types", + "Functions", + "Other" + ], + "readme": "README.md" +} diff --git a/vite.config.ts b/vite.config.ts index bfd19a3..95c99dd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,8 +17,13 @@ export default defineConfig(({mode}) => { }, server: { // HMR is disabled in AI Studio via DISABLE_HMR env var. - // Do not modify—file watching is disabled to prevent flickering during agent edits. + // Do not modify—file watching is disabled to prevent flickering during agent edits. hmr: process.env.DISABLE_HMR !== 'true', }, + test: { + globals: true, + environment: 'jsdom', + setupFiles: [], + }, }; });