From 63c5d0a7c02181de065f8a7d62d12a623c163dc8 Mon Sep 17 00:00:00 2001 From: Lars Behrends Date: Thu, 16 Apr 2026 15:09:06 +0200 Subject: [PATCH] Add Vitest, jsdom and importer tests Set up testing with Vitest and jsdom and add unit tests for importers (jellyfin, playnite, stashapp, xbvr). Add typedoc configuration and update vite.config.ts and importer source files to support the tests. Ignore generated docs by adding /docs to .gitignore and add test-related devDependencies (vitest, @vitest/ui, jsdom, typedoc) in package.json. --- .gitignore | 1 + package-lock.json | 1123 +++++++++++++++++++- package.json | 13 +- src/lib/__tests__/jellyfinImporter.test.ts | 453 ++++++++ src/lib/__tests__/playniteImporter.test.ts | 364 +++++++ src/lib/__tests__/stashappImporter.test.ts | 431 ++++++++ src/lib/__tests__/xbvrImporter.test.ts | 524 +++++++++ src/lib/jellyfinImporter.ts | 116 +- src/lib/playniteImporter.ts | 104 ++ src/lib/stashappImporter.ts | 96 +- src/lib/xbvrImporter.ts | 91 ++ typedoc.json | 26 + vite.config.ts | 7 +- 13 files changed, 3336 insertions(+), 13 deletions(-) create mode 100644 src/lib/__tests__/jellyfinImporter.test.ts create mode 100644 src/lib/__tests__/playniteImporter.test.ts create mode 100644 src/lib/__tests__/stashappImporter.test.ts create mode 100644 src/lib/__tests__/xbvrImporter.test.ts create mode 100644 typedoc.json 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: [], + }, }; });