diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index ff76e4e..9d2924a 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -21,7 +21,7 @@ jobs: - name: Ensure changelog exists run: | - CHANGELOG="changelogs/v${{ github.event.inputs.version }}.md" + CHANGELOG="app/changelogs/v${{ github.event.inputs.version }}.md" if [ ! -f "$CHANGELOG" ]; then echo "Missing changelog: $CHANGELOG" exit 1 @@ -32,12 +32,13 @@ jobs: uses: actions/setup-node@v4 with: node-version: 24 + cache-dependency-path: ./app/package-lock.json - name: Install dependencies run: npm ci - name: Run I18n generator - run: node i18n.gen.cjs + run: node app/i18n.gen.cjs - name: Signing key validation env: @@ -55,16 +56,16 @@ jobs: echo "Setting version to ${{ github.event.inputs.version }}" # package.json - jq ".version = \"${{ github.event.inputs.version }}\"" package.json > package.json.tmp - mv package.json.tmp package.json + jq ".version = \"${{ github.event.inputs.version }}\"" app/package.json > app/package.json.tmp + mv app/package.json.tmp app/package.json # tauri.conf.json (Tauri v2) - jq ".version = \"${{ github.event.inputs.version }}\"" src-tauri/tauri.conf.json > src-tauri/tauri.conf.json.tmp - mv src-tauri/tauri.conf.json.tmp src-tauri/tauri.conf.json + jq ".version = \"${{ github.event.inputs.version }}\"" app/src-tauri/tauri.conf.json > app/src-tauri/tauri.conf.json.tmp + mv app/src-tauri/tauri.conf.json.tmp app/src-tauri/tauri.conf.json # Cargo.toml (optional but strongly recommended) - sed -i.bak "s/^version = \".*\"/version = \"${{ github.event.inputs.version }}\"/" src-tauri/Cargo.toml || true - rm -f src-tauri/Cargo.toml.bak + sed -i.bak "s/^version = \".*\"/version = \"${{ github.event.inputs.version }}\"/" app/src-tauri/Cargo.toml || true + rm -f app/src-tauri/Cargo.toml.bak echo "✅ Versions updated" @@ -109,7 +110,7 @@ jobs: id: changelog shell: bash run: | - CHANGELOG=$(cat changelogs/v${{ github.event.inputs.version }}.md) + CHANGELOG=$(cat app/changelogs/v${{ github.event.inputs.version }}.md) { echo "content< + + + + + + + + + + + + + + + + + + + diff --git a/.iconify.ini b/app/.iconify.ini similarity index 100% rename from .iconify.ini rename to app/.iconify.ini diff --git a/LICENSE.txt b/app/LICENSE.txt similarity index 100% rename from LICENSE.txt rename to app/LICENSE.txt diff --git a/changelogs/v1.0.0.md b/app/changelogs/v1.0.0.md similarity index 100% rename from changelogs/v1.0.0.md rename to app/changelogs/v1.0.0.md diff --git a/changelogs/v1.0.1.md b/app/changelogs/v1.0.1.md similarity index 100% rename from changelogs/v1.0.1.md rename to app/changelogs/v1.0.1.md diff --git a/changelogs/v1.1.0.md b/app/changelogs/v1.1.0.md similarity index 100% rename from changelogs/v1.1.0.md rename to app/changelogs/v1.1.0.md diff --git a/changelogs/v1.2.0.md b/app/changelogs/v1.2.0.md similarity index 100% rename from changelogs/v1.2.0.md rename to app/changelogs/v1.2.0.md diff --git a/changelogs/v1.3.0.md b/app/changelogs/v1.3.0.md similarity index 100% rename from changelogs/v1.3.0.md rename to app/changelogs/v1.3.0.md diff --git a/i18n.gen.cjs b/app/i18n.gen.cjs similarity index 100% rename from i18n.gen.cjs rename to app/i18n.gen.cjs diff --git a/index.html b/app/index.html similarity index 100% rename from index.html rename to app/index.html diff --git a/package-lock.json b/app/package-lock.json similarity index 61% rename from package-lock.json rename to app/package-lock.json index b0e3181..e9abec7 100644 --- a/package-lock.json +++ b/app/package-lock.json @@ -1,13 +1,15 @@ { "name": "anistream", - "version": "1.1.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "anistream", - "version": "1.1.0", + "version": "1.3.0", "dependencies": { + "@babel/core": "^7.29.7", + "@babel/plugin-proposal-explicit-resource-management": "^7.27.4", "@dicebear/collection": "^9.4.2", "@dicebear/core": "^9.4.2", "@tauri-apps/api": "^2", @@ -18,17 +20,25 @@ "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-sql": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.0", + "chalk": "^5.6.2", + "commander": "^15.0.0", "hls.js": "^1.6.15", + "linkedom": "^0.18.12", + "ora": "^9.4.0", + "vitest": "^4.1.5", "vue": "^3.5.13", - "vue-mvvm": "^0.7.0", + "vue-mvvm": "^0.9.0", "vue-router": "^5.0.4" }, "devDependencies": { "@tailwindcss/vite": "^4.2.2", "@tauri-apps/cli": "^2", + "@types/babel__core": "^7.20.5", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.0.6", "@types/random-useragent": "^0.3.3", "@vitejs/plugin-vue": "^6.0.5", + "better-sqlite3": "^12.9.0", "daisyui": "^5.5.14", "tailwindcss": "^4.1.18", "typescript": "~5.6.2", @@ -36,14 +46,76 @@ "vue-tsc": "^2.1.10" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -52,31 +124,126 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -85,14 +252,79 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-proposal-explicit-resource-management": { + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-explicit-resource-management/-/plugin-proposal-explicit-resource-management-7.27.4.tgz", + "integrity": "sha512-1SwtCDdZWQvUU1i7wt/ihP7W38WjC3CSTOHAl+Xnbze8+bbMNjRvRQydnj0k9J1jPqCAZctBFp6NHJXkrVVmEA==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-explicit-resource-management instead.", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -528,10 +760,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "license": "MIT", "optional": true, "dependencies": { @@ -540,10 +771,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, "dependencies": { @@ -554,7 +784,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -607,10 +836,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", - "dev": true, + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "license": "MIT", "optional": true, "dependencies": { @@ -629,7 +857,6 @@ "version": "0.122.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -642,7 +869,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -659,7 +885,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -676,7 +901,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -693,7 +917,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -710,7 +933,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -727,7 +949,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], @@ -747,7 +968,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], @@ -767,7 +987,6 @@ "cpu": [ "ppc64" ], - "dev": true, "libc": [ "glibc" ], @@ -787,7 +1006,6 @@ "cpu": [ "s390x" ], - "dev": true, "libc": [ "glibc" ], @@ -807,7 +1025,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -827,7 +1044,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], @@ -847,7 +1063,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -864,7 +1079,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -881,7 +1095,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -898,7 +1111,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -912,7 +1124,12 @@ "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", - "dev": true, + "license": "MIT" + }, + "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==", "license": "MIT" }, "node_modules/@tailwindcss/node": { @@ -1493,13 +1710,89 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@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==", + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1510,7 +1803,7 @@ "version": "25.2.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.1.tgz", "integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -1547,6 +1840,121 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "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/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.15", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", @@ -1785,6 +2193,27 @@ "dev": true, "license": "MIT" }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-kit": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", @@ -1824,23 +2253,198 @@ "dev": true, "license": "MIT" }, - "node_modules/birpc": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", - "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", + "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/better-sqlite3": { + "version": "12.9.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "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==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/chokidar": { @@ -1858,12 +2462,95 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1887,16 +2574,141 @@ "dev": true, "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.368", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", + "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", + "license": "ISC" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -1923,12 +2735,46 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "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==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -1952,11 +2798,24 @@ } } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1967,6 +2826,34 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT" + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1996,16 +2883,106 @@ "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", "license": "MIT" }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2034,7 +3011,6 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -2067,7 +3043,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2088,7 +3063,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2109,7 +3083,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2130,7 +3103,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2151,7 +3123,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2172,7 +3143,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], @@ -2196,7 +3166,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], @@ -2220,7 +3189,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -2244,7 +3212,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], @@ -2268,7 +3235,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2289,7 +3255,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2303,6 +3268,30 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkedom": { + "version": "0.18.12", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz", + "integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==", + "license": "ISC", + "dependencies": { + "css-select": "^5.1.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.0.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/local-pkg": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", @@ -2320,6 +3309,31 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2344,6 +3358,31 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -2360,6 +3399,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -2389,6 +3445,12 @@ "pathe": "^2.0.1" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/muggle-string": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", @@ -2413,6 +3475,104 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.90.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.90.0.tgz", + "integrity": "sha512-pZNQT7UnYlMwMBy5N1lV5X/YLTbZM5ncytN3xL7CHEzhDN8uVe0u55yaPUJICIJjaCW8NrM5BFdqr7HLweStNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "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==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.0.tgz", + "integrity": "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==", + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.2", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -2486,7 +3646,46 @@ "source-map-js": "^1.2.1" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" } }, "node_modules/quansync": { @@ -2505,6 +3704,37 @@ ], "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -2518,11 +3748,26 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", - "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.122.0", @@ -2552,12 +3797,111 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/scule": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2567,6 +3911,81 @@ "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==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", @@ -2588,6 +4007,51 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -2604,14 +4068,35 @@ "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==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD", "optional": true }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -2632,11 +4117,17 @@ "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "license": "MIT" }, + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "license": "ISC" + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unplugin": { @@ -2669,11 +4160,47 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.0.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.5.tgz", "integrity": "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==", - "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", @@ -2747,6 +4274,95 @@ } } }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "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.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "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/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -2776,9 +4392,9 @@ } }, "node_modules/vue-mvvm": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/vue-mvvm/-/vue-mvvm-0.7.0.tgz", - "integrity": "sha512-BO0BeGTSJUJQA6rE2nT7etpffDLKbiPvIN7qtATJ1BsgswISBrp6CMV5KSDAoO1Yn3bgQYa4fAP4W28ko4EpEg==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/vue-mvvm/-/vue-mvvm-0.9.0.tgz", + "integrity": "sha512-4TAqV+WNWqlOXj8a16WGoEHyo+80ONmSydARhM1xenGJSKT+w+4UB5hSdXYILVvMHKu5wGYko//E7U9TQMCmtw==", "license": "MIT", "peerDependencies": { "vue": "^3.5.24", @@ -2853,6 +4469,35 @@ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, + "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==", + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, "node_modules/yaml": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", @@ -2867,6 +4512,18 @@ "funding": { "url": "https://github.com/sponsors/eemeli" } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/app/package.json similarity index 72% rename from package.json rename to app/package.json index b177b11..ba9d1e1 100644 --- a/package.json +++ b/app/package.json @@ -4,12 +4,15 @@ "version": "1.3.0", "type": "module", "scripts": { + "test": "vitest run", "dev": "vite", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview", "tauri": "tauri" }, "dependencies": { + "@babel/core": "^7.29.7", + "@babel/plugin-proposal-explicit-resource-management": "^7.27.4", "@dicebear/collection": "^9.4.2", "@dicebear/core": "^9.4.2", "@tauri-apps/api": "^2", @@ -20,17 +23,25 @@ "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-sql": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.0", + "chalk": "^5.6.2", + "commander": "^15.0.0", "hls.js": "^1.6.15", + "linkedom": "^0.18.12", + "ora": "^9.4.0", + "vitest": "^4.1.5", "vue": "^3.5.13", - "vue-mvvm": "^0.7.0", + "vue-mvvm": "^0.9.0", "vue-router": "^5.0.4" }, "devDependencies": { "@tailwindcss/vite": "^4.2.2", "@tauri-apps/cli": "^2", + "@types/babel__core": "^7.20.5", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.0.6", "@types/random-useragent": "^0.3.3", "@vitejs/plugin-vue": "^6.0.5", + "better-sqlite3": "^12.9.0", "daisyui": "^5.5.14", "tailwindcss": "^4.1.18", "typescript": "~5.6.2", diff --git a/public/128x128.png b/app/public/128x128.png similarity index 100% rename from public/128x128.png rename to app/public/128x128.png diff --git a/public/aniworld-dark.svg b/app/public/aniworld-dark.svg similarity index 100% rename from public/aniworld-dark.svg rename to app/public/aniworld-dark.svg diff --git a/public/aniworld.svg b/app/public/aniworld.svg similarity index 100% rename from public/aniworld.svg rename to app/public/aniworld.svg diff --git a/public/choose/aniworld-dark.svg b/app/public/choose/aniworld-dark.svg similarity index 100% rename from public/choose/aniworld-dark.svg rename to app/public/choose/aniworld-dark.svg diff --git a/public/choose/aniworld-light.svg b/app/public/choose/aniworld-light.svg similarity index 100% rename from public/choose/aniworld-light.svg rename to app/public/choose/aniworld-light.svg diff --git a/public/choose/sto-dark.svg b/app/public/choose/sto-dark.svg similarity index 100% rename from public/choose/sto-dark.svg rename to app/public/choose/sto-dark.svg diff --git a/public/choose/sto-light.svg b/app/public/choose/sto-light.svg similarity index 100% rename from public/choose/sto-light.svg rename to app/public/choose/sto-light.svg diff --git a/public/sto.svg b/app/public/sto.svg similarity index 100% rename from public/sto.svg rename to app/public/sto.svg diff --git a/public/sync/aniworld-dark.svg b/app/public/sync/aniworld-dark.svg similarity index 100% rename from public/sync/aniworld-dark.svg rename to app/public/sync/aniworld-dark.svg diff --git a/public/sync/aniworld-light.svg b/app/public/sync/aniworld-light.svg similarity index 100% rename from public/sync/aniworld-light.svg rename to app/public/sync/aniworld-light.svg diff --git a/public/sync/sto-dark.svg b/app/public/sync/sto-dark.svg similarity index 100% rename from public/sync/sto-dark.svg rename to app/public/sync/sto-dark.svg diff --git a/public/sync/sto-light.svg b/app/public/sync/sto-light.svg similarity index 100% rename from public/sync/sto-light.svg rename to app/public/sync/sto-light.svg diff --git a/public/tauri.svg b/app/public/tauri.svg similarity index 100% rename from public/tauri.svg rename to app/public/tauri.svg diff --git a/src-tauri/.gitignore b/app/src-tauri/.gitignore similarity index 100% rename from src-tauri/.gitignore rename to app/src-tauri/.gitignore diff --git a/src-tauri/2 b/app/src-tauri/2 similarity index 100% rename from src-tauri/2 rename to app/src-tauri/2 diff --git a/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock similarity index 99% rename from src-tauri/Cargo.lock rename to app/src-tauri/Cargo.lock index 09183fe..8f2af8f 100644 --- a/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -49,7 +49,7 @@ dependencies = [ [[package]] name = "anistream" -version = "1.2.0" +version = "1.3.0" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml similarity index 100% rename from src-tauri/Cargo.toml rename to app/src-tauri/Cargo.toml diff --git a/src-tauri/build.rs b/app/src-tauri/build.rs similarity index 100% rename from src-tauri/build.rs rename to app/src-tauri/build.rs diff --git a/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json similarity index 90% rename from src-tauri/capabilities/default.json rename to app/src-tauri/capabilities/default.json index 18c62c3..9addd09 100644 --- a/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -11,8 +11,8 @@ { "identifier": "http:default", "allow": [ - "https://*", - "http://*" + { "url": "https://*:*" }, + { "url": "http://*:*" } ] }, "sql:default", diff --git a/src-tauri/capabilities/desktop.json b/app/src-tauri/capabilities/desktop.json similarity index 100% rename from src-tauri/capabilities/desktop.json rename to app/src-tauri/capabilities/desktop.json diff --git a/src-tauri/icons/128x128.png b/app/src-tauri/icons/128x128.png similarity index 100% rename from src-tauri/icons/128x128.png rename to app/src-tauri/icons/128x128.png diff --git a/src-tauri/icons/128x128@2x.png b/app/src-tauri/icons/128x128@2x.png similarity index 100% rename from src-tauri/icons/128x128@2x.png rename to app/src-tauri/icons/128x128@2x.png diff --git a/src-tauri/icons/32x32.png b/app/src-tauri/icons/32x32.png similarity index 100% rename from src-tauri/icons/32x32.png rename to app/src-tauri/icons/32x32.png diff --git a/src-tauri/icons/64x64.png b/app/src-tauri/icons/64x64.png similarity index 100% rename from src-tauri/icons/64x64.png rename to app/src-tauri/icons/64x64.png diff --git a/src-tauri/icons/Square107x107Logo.png b/app/src-tauri/icons/Square107x107Logo.png similarity index 100% rename from src-tauri/icons/Square107x107Logo.png rename to app/src-tauri/icons/Square107x107Logo.png diff --git a/src-tauri/icons/Square142x142Logo.png b/app/src-tauri/icons/Square142x142Logo.png similarity index 100% rename from src-tauri/icons/Square142x142Logo.png rename to app/src-tauri/icons/Square142x142Logo.png diff --git a/src-tauri/icons/Square150x150Logo.png b/app/src-tauri/icons/Square150x150Logo.png similarity index 100% rename from src-tauri/icons/Square150x150Logo.png rename to app/src-tauri/icons/Square150x150Logo.png diff --git a/src-tauri/icons/Square284x284Logo.png b/app/src-tauri/icons/Square284x284Logo.png similarity index 100% rename from src-tauri/icons/Square284x284Logo.png rename to app/src-tauri/icons/Square284x284Logo.png diff --git a/src-tauri/icons/Square30x30Logo.png b/app/src-tauri/icons/Square30x30Logo.png similarity index 100% rename from src-tauri/icons/Square30x30Logo.png rename to app/src-tauri/icons/Square30x30Logo.png diff --git a/src-tauri/icons/Square310x310Logo.png b/app/src-tauri/icons/Square310x310Logo.png similarity index 100% rename from src-tauri/icons/Square310x310Logo.png rename to app/src-tauri/icons/Square310x310Logo.png diff --git a/src-tauri/icons/Square44x44Logo.png b/app/src-tauri/icons/Square44x44Logo.png similarity index 100% rename from src-tauri/icons/Square44x44Logo.png rename to app/src-tauri/icons/Square44x44Logo.png diff --git a/src-tauri/icons/Square71x71Logo.png b/app/src-tauri/icons/Square71x71Logo.png similarity index 100% rename from src-tauri/icons/Square71x71Logo.png rename to app/src-tauri/icons/Square71x71Logo.png diff --git a/src-tauri/icons/Square89x89Logo.png b/app/src-tauri/icons/Square89x89Logo.png similarity index 100% rename from src-tauri/icons/Square89x89Logo.png rename to app/src-tauri/icons/Square89x89Logo.png diff --git a/src-tauri/icons/StoreLogo.png b/app/src-tauri/icons/StoreLogo.png similarity index 100% rename from src-tauri/icons/StoreLogo.png rename to app/src-tauri/icons/StoreLogo.png diff --git a/src-tauri/icons/icon.icns b/app/src-tauri/icons/icon.icns similarity index 100% rename from src-tauri/icons/icon.icns rename to app/src-tauri/icons/icon.icns diff --git a/src-tauri/icons/icon.ico b/app/src-tauri/icons/icon.ico similarity index 100% rename from src-tauri/icons/icon.ico rename to app/src-tauri/icons/icon.ico diff --git a/src-tauri/icons/icon.png b/app/src-tauri/icons/icon.png similarity index 100% rename from src-tauri/icons/icon.png rename to app/src-tauri/icons/icon.png diff --git a/src-tauri/icons/original.svg b/app/src-tauri/icons/original.svg similarity index 100% rename from src-tauri/icons/original.svg rename to app/src-tauri/icons/original.svg diff --git a/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs similarity index 89% rename from src-tauri/src/lib.rs rename to app/src-tauri/src/lib.rs index 2f21da7..ae44890 100644 --- a/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -64,6 +64,16 @@ pub fn run() { .set_focus(); })); } + #[cfg(debug_assertions)] + { + builder = builder.setup(|app| { + let _ = app + .get_webview_window("main") + .expect("no main window") + .open_devtools(); + Ok(()) + }); + } builder.invoke_handler(tauri::generate_handler![report_issue]) .run(tauri::generate_context!()) diff --git a/src-tauri/src/main.rs b/app/src-tauri/src/main.rs similarity index 100% rename from src-tauri/src/main.rs rename to app/src-tauri/src/main.rs diff --git a/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json similarity index 100% rename from src-tauri/tauri.conf.json rename to app/src-tauri/tauri.conf.json diff --git a/app/src/AppEnv.ts b/app/src/AppEnv.ts new file mode 100644 index 0000000..7623299 --- /dev/null +++ b/app/src/AppEnv.ts @@ -0,0 +1,60 @@ +import * as os from "@tauri-apps/plugin-os"; + +type OSPlatforms = NodeJS.Platform | os.Platform; + + +export const isTesting: boolean = import.meta.env.MODE === "test"; +export const isDev: boolean = import.meta.env.MODE === "development"; +export const isProd: boolean = !isTesting && !isDev; + + +export const isClientMode: boolean = APPLICATION_TARGET == "client"; +export const isStandaloneMode: boolean = APPLICATION_TARGET == "standalone"; +export const isWorkerMode: boolean = APPLICATION_TARGET == "worker"; + +export const modeName: string = isClientMode + ? "client" + : isStandaloneMode + ? "standalone" + : isWorkerMode + ? "worker" + : "?"; + + +export const PLATFORM: OSPlatforms = isTesting || isWorkerMode + ? require("node:os").platform() + : os.platform(); + +export const isWindows: boolean = PLATFORM === "windows" || PLATFORM === "win32" || PLATFORM === "cygwin"; + +export const isLinux: boolean = PLATFORM === "linux"; + +export const isAndroid: boolean = PLATFORM === "android"; + +export const isMac: boolean = PLATFORM === "darwin" || PLATFORM === "macos"; + +export const isIOS: boolean = PLATFORM === "ios"; + +export const isApple: boolean = isMac || isIOS; + +export const isBSD: boolean = + PLATFORM === "freebsd" || + PLATFORM === "openbsd" || + PLATFORM === "netbsd" || + PLATFORM === "dragonfly"; + +export const isUnixLike: boolean = + isLinux || + isAndroid || + isMac || + isBSD || + PLATFORM === "sunos" || + PLATFORM === "solaris" || + PLATFORM === "aix" || + PLATFORM === "haiku"; + +export const isSolaris: boolean = PLATFORM === "sunos" || PLATFORM === "solaris"; + +export const isAIX: boolean = PLATFORM === "aix"; + +export const isHaiku: boolean = PLATFORM === "haiku"; \ No newline at end of file diff --git a/src/config.ts b/app/src/configs/app.ts similarity index 77% rename from src/config.ts rename to app/src/configs/app.ts index 8a28c81..b43f711 100644 --- a/src/config.ts +++ b/app/src/configs/app.ts @@ -1,12 +1,14 @@ import {App} from "vue"; import {AppShell, WritableGlobalContext} from "vue-mvvm"; +import {BaseConfig} from "@configs/base"; + import {ProviderViewModel} from "@views/ProviderView.model"; import {SettingsViewModel} from "@views/SettingsView.model"; import {StreamsViewModel} from "@views/StreamsView.model"; import {SyncViewModel} from "@views/SyncView.model"; import {WatchlistViewModel} from "@views/WatchlistView.model"; -import {SeriesSyncViewModel} from "@views/SeriesSyncView.model"; +import {SeriesSyncViewStandaloneModel, SeriesSyncViewClientModel} from "@views/SeriesSyncView.model"; import {StreamViewModel} from "@views/StreamView.model"; import {PlayerViewModel} from "@views/PlayerView.model"; import {ProfileViewModel} from "@views/ProfileView.model"; @@ -21,12 +23,16 @@ import {ReportService} from "@contracts/report.contract"; import {SettingsService} from "@contracts/settings.contract"; import {UpdateService} from "@contracts/update.contract"; -import {services} from "virtual:services"; +import * as AppEnv from "@AppEnv"; + +const SeriesSyncViewModel = AppEnv.isStandaloneMode + ? SeriesSyncViewStandaloneModel + : SeriesSyncViewClientModel; -export class AppConfig implements AppShell { +export class AppConfig extends BaseConfig { private app: App; - router: AppShell.RouterConfig = { + public router: AppShell.RouterConfig = { views: [ ProviderViewModel, SettingsViewModel, @@ -41,25 +47,23 @@ export class AppConfig implements AppShell { ] } - alert: AppShell.AlertConfig = { + public alert: AppShell.AlertConfig = { confirm: ConfirmControlModel } - toast: AppShell.ToastConfig = { + public toast: AppShell.ToastConfig = { container: ToastContainer, info: InfoToastModel, progress: ProgressToastModel } public constructor(app: App) { + super(); + this.app = app; } - configureServices(ctx: WritableGlobalContext): void { - for (const service of Object.values(services)) { - ctx.registerService(service.key, ctx => new service.ctor(ctx)); - } - + protected afterConfigureServices(ctx: WritableGlobalContext): void { // Init reporting const reportService: ReportService = ctx.getService(ReportService); this.app.config.errorHandler = async err => { diff --git a/app/src/configs/base.ts b/app/src/configs/base.ts new file mode 100644 index 0000000..5d36932 --- /dev/null +++ b/app/src/configs/base.ts @@ -0,0 +1,20 @@ +import {AppShell, WritableGlobalContext} from "vue-mvvm"; + +import {services} from "virtual:services"; + +export abstract class BaseConfig implements AppShell { + public abstract get router(): AppShell.RouterConfig; + public abstract get toast(): AppShell.ToastConfig; + public abstract get alert(): AppShell.AlertConfig; + + public configureServices(ctx: WritableGlobalContext): void { + for (const service of Object.values(services)) { + ctx.registerService(service.key, ctx => new service.ctor(ctx)); + } + + this.afterConfigureServices(ctx); + } + + protected abstract afterConfigureServices(ctx: WritableGlobalContext): void; + +} \ No newline at end of file diff --git a/app/src/configs/test.ts b/app/src/configs/test.ts new file mode 100644 index 0000000..562f2bc --- /dev/null +++ b/app/src/configs/test.ts @@ -0,0 +1,46 @@ +import {AppShell, FactoryFunction, ReadableGlobalContext, ServiceKey, WritableGlobalContext} from "vue-mvvm"; + +import {BaseConfig} from "@configs/base"; + +import ToastContainer from "@controls/ToastContainer.vue"; + +export class TestConfig extends BaseConfig { + private _ctx: WritableGlobalContext | null; + + public router: AppShell.RouterConfig = { + views: [] + } + + public alert: AppShell.AlertConfig = { + } + + public toast: AppShell.ToastConfig = { + container: ToastContainer + } + + public get ctx(): ReadableGlobalContext { + if (!this._ctx) { + throw new Error("Context not initialized"); + } + + return this._ctx.toReadableGlobalContext(); + } + + public constructor() { + super(); + + this._ctx = null; + } + + public mockService(key: ServiceKey, factory: FactoryFunction): void { + if (!this._ctx) { + throw new Error("Context not initialized"); + } + + this._ctx.mockService(key, factory); + } + + protected afterConfigureServices(ctx: WritableGlobalContext): void { + this._ctx = ctx; + } +} \ No newline at end of file diff --git a/app/src/configs/worker.ts b/app/src/configs/worker.ts new file mode 100644 index 0000000..93628d0 --- /dev/null +++ b/app/src/configs/worker.ts @@ -0,0 +1,32 @@ +import {AppShell, WritableGlobalContext} from "vue-mvvm"; + +import {ServiceDeclaration} from "@services/declaration"; + +import {UnsupportedPlatformError} from "@utils/error"; + +const services: ServiceDeclaration[] = Object.values(import.meta.glob("/src/services/worker/*.service.ts", { + eager: true, + import: "default" +})); + +export class WorkerConfig implements AppShell { + + public router: AppShell.RouterConfig = { + views: [] + } + + public alert: AppShell.AlertConfig = {} + + public get toast(): AppShell.ToastConfig { + throw new UnsupportedPlatformError("get WorkerConfig.toast"); + } + + public constructor() { + } + + public configureServices(ctx: WritableGlobalContext): void { + for (const service of services) { + ctx.registerService(service.key, ctx => new service.ctor(ctx)); + } + } +} \ No newline at end of file diff --git a/src/contracts/changelog.contract.ts b/app/src/contracts/changelog.contract.ts similarity index 100% rename from src/contracts/changelog.contract.ts rename to app/src/contracts/changelog.contract.ts diff --git a/app/src/contracts/client/api.contract.ts b/app/src/contracts/client/api.contract.ts new file mode 100644 index 0000000..c58d36e --- /dev/null +++ b/app/src/contracts/client/api.contract.ts @@ -0,0 +1,23 @@ +import {ServiceKey} from "vue-mvvm"; + +export type PathParts = Array; +export type QueryTypes = string | number | boolean; +export type PathParameter = PathParts | RouteBuilder; + +export interface RouteBuilder { + path: PathParts; + query: Record; +} + + +export interface ApiService { + get(def: PathParameter): Promise; + + post(def: PathParameter, body: Body): Promise; + + put(def: PathParameter, body: Body): Promise; + + delete(def: PathParameter): Promise; +} + +export const ApiService: ServiceKey = new ServiceKey("api.service"); \ No newline at end of file diff --git a/src/contracts/episode.contract.ts b/app/src/contracts/episode.contract.ts similarity index 100% rename from src/contracts/episode.contract.ts rename to app/src/contracts/episode.contract.ts diff --git a/src/contracts/fetch.contract.ts b/app/src/contracts/fetch.contract.ts similarity index 57% rename from src/contracts/fetch.contract.ts rename to app/src/contracts/fetch.contract.ts index 3838652..9c57c3d 100644 --- a/src/contracts/fetch.contract.ts +++ b/app/src/contracts/fetch.contract.ts @@ -2,10 +2,11 @@ import {ServiceKey} from "vue-mvvm"; import type {SeriesFetchModel, SeriesModel} from "@models/series.model"; import type {GenreFetchModel} from "@models/genre.model"; -import type {SeasonFetchModel} from "@models/season.model"; -import type {EpisodeFetchModel} from "@models/episode.model"; +import type {SeasonFetchModel, SeasonModel} from "@models/season.model"; +import type {EpisodeFetchModel, EpisodeModel} from "@models/episode.model"; import {DefaultProvider, EpisodeLanguage} from "@providers/default"; +import {SyncStatus} from "@contracts/season.contract"; export interface Provider { name: string; @@ -13,16 +14,25 @@ export interface Provider { embeddedURL: string; } +/** + * Only used in the API + */ +export interface ProviderSync { + +} + export interface FetchService { getCatalog(provider?: DefaultProvider | null): Promise; - getSeries(guid: string, provider?: DefaultProvider | null): Promise<[model: SeriesFetchModel, genres: GenreFetchModel[]]>; + getSeries(guid: string, provider?: DefaultProvider | null): Promise<[model: SeriesFetchModel, genres: GenreFetchModel[], previewImage: Uint8Array | null]>; getSeasons(series: SeriesModel, provider?: DefaultProvider | null): Promise; getEpisodes(guid: string, seasonNumber: number, provider?: DefaultProvider | null): Promise; - getProviders(guid: string, seasonNumber: number, episodeNumber: number, provider?: DefaultProvider | null): Promise; + getProviders(series: SeriesModel, season: SeasonModel, episode: EpisodeModel, provider?: DefaultProvider | null): Promise<[SyncStatus, Provider[]]>; + + startRemoteSyncing(seriesId: number, provider?: DefaultProvider | null): Promise; } export const FetchService: ServiceKey = new ServiceKey("fetch.service"); \ No newline at end of file diff --git a/src/contracts/genre.contract.ts b/app/src/contracts/genre.contract.ts similarity index 94% rename from src/contracts/genre.contract.ts rename to app/src/contracts/genre.contract.ts index b59ac32..f9d6c3a 100644 --- a/src/contracts/genre.contract.ts +++ b/app/src/contracts/genre.contract.ts @@ -8,7 +8,7 @@ export interface GenreService { insertGenre(key: string): Promise; - insertGenreToSeries(genre: GenreModel, series: SeriesModel, main_genre: boolean): Promise; + insertGenreToSeries(genre: GenreModel, series: SeriesModel, mainGenre: boolean): Promise; getGenres(): Promise; diff --git a/src/contracts/i18n.contract.ts b/app/src/contracts/i18n.contract.ts similarity index 100% rename from src/contracts/i18n.contract.ts rename to app/src/contracts/i18n.contract.ts diff --git a/src/contracts/list.contract.ts b/app/src/contracts/list.contract.ts similarity index 100% rename from src/contracts/list.contract.ts rename to app/src/contracts/list.contract.ts diff --git a/src/contracts/provider.contract.ts b/app/src/contracts/provider.contract.ts similarity index 88% rename from src/contracts/provider.contract.ts rename to app/src/contracts/provider.contract.ts index 5ed5c2b..b3c7c8f 100644 --- a/src/contracts/provider.contract.ts +++ b/app/src/contracts/provider.contract.ts @@ -4,20 +4,17 @@ import type {AniWorldProvider} from "@providers/aniworld"; import type {StoProvider} from "@providers/sto"; import type {DefaultProvider} from "@providers/default"; -import type {DbSession} from "@services/utils/db"; - import {ProfileModel} from "@models/profile.model"; export interface ProviderService { get ANIWORLD(): AniWorldProvider; + get STO(): StoProvider; get ALL_PROVIDERS(): DefaultProvider[]; getProvider(): Promise; - getDatabase(): Promise; - setProvider(provider: DefaultProvider): Promise; deleteProfile(profile: ProfileModel): Promise; diff --git a/src/contracts/report.contract.ts b/app/src/contracts/report.contract.ts similarity index 100% rename from src/contracts/report.contract.ts rename to app/src/contracts/report.contract.ts diff --git a/app/src/contracts/resource.contract.ts b/app/src/contracts/resource.contract.ts new file mode 100644 index 0000000..d2baea0 --- /dev/null +++ b/app/src/contracts/resource.contract.ts @@ -0,0 +1,9 @@ +import {ServiceKey} from "vue-mvvm"; + +export interface ResourceService { + getResourceLocation(): Promise; + + saveResource(name: string, data: Uint8Array): Promise; +} + +export const ResourceService: ServiceKey = new ServiceKey("resource.service"); \ No newline at end of file diff --git a/src/contracts/season.contract.ts b/app/src/contracts/season.contract.ts similarity index 64% rename from src/contracts/season.contract.ts rename to app/src/contracts/season.contract.ts index 6015654..215a24e 100644 --- a/src/contracts/season.contract.ts +++ b/app/src/contracts/season.contract.ts @@ -2,8 +2,20 @@ import {ServiceKey} from "vue-mvvm"; import type {SeasonModel} from "@models/season.model"; +export type SyncInformation = { + requiresSync: boolean; + status: SyncStatus | null; +} + +export const enum SyncStatus { + Queued, + Processing, + Completed, + Failed +} + export interface SeasonService { - requiresSync(seriesId: number): Promise; + getSyncStatus(seriesId: number): Promise; getSeason(seasonId: number): Promise; diff --git a/src/contracts/series.contract.ts b/app/src/contracts/series.contract.ts similarity index 100% rename from src/contracts/series.contract.ts rename to app/src/contracts/series.contract.ts diff --git a/src/contracts/settings.contract.ts b/app/src/contracts/settings.contract.ts similarity index 100% rename from src/contracts/settings.contract.ts rename to app/src/contracts/settings.contract.ts diff --git a/app/src/contracts/standalone/db.contract.ts b/app/src/contracts/standalone/db.contract.ts new file mode 100644 index 0000000..b1a8812 --- /dev/null +++ b/app/src/contracts/standalone/db.contract.ts @@ -0,0 +1,13 @@ +import {ServiceKey} from "vue-mvvm"; + +import {DbSession} from "@services/utils/db"; + +import {DefaultProvider} from "@providers/default"; + +export interface DbService { + getDatabase(provider: DefaultProvider): Promise; + + closeDatabase(provider: DefaultProvider): Promise; +} + +export const DbService: ServiceKey = new ServiceKey("db.service"); diff --git a/src/contracts/standalone/metadata.contract.ts b/app/src/contracts/standalone/metadata.contract.ts similarity index 100% rename from src/contracts/standalone/metadata.contract.ts rename to app/src/contracts/standalone/metadata.contract.ts diff --git a/src/contracts/standalone/user.contract.ts b/app/src/contracts/standalone/user.contract.ts similarity index 100% rename from src/contracts/standalone/user.contract.ts rename to app/src/contracts/standalone/user.contract.ts diff --git a/src/contracts/update.contract.ts b/app/src/contracts/update.contract.ts similarity index 100% rename from src/contracts/update.contract.ts rename to app/src/contracts/update.contract.ts diff --git a/src/contracts/user.contract.ts b/app/src/contracts/user.contract.ts similarity index 92% rename from src/contracts/user.contract.ts rename to app/src/contracts/user.contract.ts index b2d0b3c..ff8a924 100644 --- a/src/contracts/user.contract.ts +++ b/app/src/contracts/user.contract.ts @@ -5,6 +5,10 @@ import type {ProfileEye, ProfileModel, ProfileMouth} from "@models/profile.model import type {SupportedLocals} from "@contracts/i18n.contract"; export interface UserService { + authenticate(profile: ProfileModel, password: string): Promise; + + logout(): Promise; + getActiveProfile(): Promise; setActiveProfile(profile: ProfileModel): Promise; diff --git a/src/contracts/watchlist.contract.ts b/app/src/contracts/watchlist.contract.ts similarity index 100% rename from src/contracts/watchlist.contract.ts rename to app/src/contracts/watchlist.contract.ts diff --git a/src/contracts/watchtime.contract.ts b/app/src/contracts/watchtime.contract.ts similarity index 100% rename from src/contracts/watchtime.contract.ts rename to app/src/contracts/watchtime.contract.ts diff --git a/src/controls/ChangelogControl.model.ts b/app/src/controls/ChangelogControl.model.ts similarity index 100% rename from src/controls/ChangelogControl.model.ts rename to app/src/controls/ChangelogControl.model.ts diff --git a/src/controls/ChangelogControl.vue b/app/src/controls/ChangelogControl.vue similarity index 100% rename from src/controls/ChangelogControl.vue rename to app/src/controls/ChangelogControl.vue diff --git a/src/controls/ColorPicker.vue b/app/src/controls/ColorPicker.vue similarity index 100% rename from src/controls/ColorPicker.vue rename to app/src/controls/ColorPicker.vue diff --git a/src/controls/ConfirmControl.model.ts b/app/src/controls/ConfirmControl.model.ts similarity index 100% rename from src/controls/ConfirmControl.model.ts rename to app/src/controls/ConfirmControl.model.ts diff --git a/src/controls/ConfirmControl.vue b/app/src/controls/ConfirmControl.vue similarity index 100% rename from src/controls/ConfirmControl.vue rename to app/src/controls/ConfirmControl.vue diff --git a/src/controls/DetailControl.model.ts b/app/src/controls/DetailControl.model.ts similarity index 96% rename from src/controls/DetailControl.model.ts rename to app/src/controls/DetailControl.model.ts index 2807d29..bb9ad5b 100644 --- a/src/controls/DetailControl.model.ts +++ b/app/src/controls/DetailControl.model.ts @@ -13,7 +13,7 @@ import {GenreModel} from "@models/genre.model"; import {GenreService} from "@contracts/genre.contract"; import {I18nService} from "@contracts/i18n.contract"; -import {SeasonService} from "@contracts/season.contract"; +import {SeasonService, SyncInformation} from "@contracts/season.contract"; import {WatchlistService} from "@contracts/watchlist.contract"; import {WatchtimeService} from "@contracts/watchtime.contract"; import {ListService} from "@contracts/list.contract"; @@ -103,7 +103,8 @@ export class DetailControlModel extends DialogControl { public async onWatchBtn(): Promise { await this.closeDialog(); - if (await this.seasonService.requiresSync(this.seriesId)) { + const status: SyncInformation = await this.seasonService.getSyncStatus(this.seriesId); + if (status.requiresSync) { await this.routerService.navigateTo(SeriesSyncViewModel, { series_id: this.seriesId }); diff --git a/src/controls/DetailControl.vue b/app/src/controls/DetailControl.vue similarity index 99% rename from src/controls/DetailControl.vue rename to app/src/controls/DetailControl.vue index 4169aa9..bd713d3 100644 --- a/src/controls/DetailControl.vue +++ b/app/src/controls/DetailControl.vue @@ -61,7 +61,7 @@ const vm: DetailControlModel = useDialogControl(DetailControlModel);
+ max="100"/>
-
-
-

- -

-
-
-
-
-
-
-
-
-

-
- -

-
+

diff --git a/src/controls/InfoToast.model.ts b/app/src/controls/InfoToast.model.ts similarity index 100% rename from src/controls/InfoToast.model.ts rename to app/src/controls/InfoToast.model.ts diff --git a/src/controls/InfoToast.vue b/app/src/controls/InfoToast.vue similarity index 100% rename from src/controls/InfoToast.vue rename to app/src/controls/InfoToast.vue diff --git a/src/controls/ListHash.vue b/app/src/controls/ListHash.vue similarity index 77% rename from src/controls/ListHash.vue rename to app/src/controls/ListHash.vue index 5da150c..8f69052 100644 --- a/src/controls/ListHash.vue +++ b/app/src/controls/ListHash.vue @@ -1,9 +1,15 @@ + + + + diff --git a/src/controls/PrefControl.model.ts b/app/src/controls/PrefControl.model.ts similarity index 100% rename from src/controls/PrefControl.model.ts rename to app/src/controls/PrefControl.model.ts diff --git a/src/controls/PrefControl.vue b/app/src/controls/PrefControl.vue similarity index 89% rename from src/controls/PrefControl.vue rename to app/src/controls/PrefControl.vue index 22f09b6..0cf5e15 100644 --- a/src/controls/PrefControl.vue +++ b/app/src/controls/PrefControl.vue @@ -9,6 +9,8 @@ import Text from "@controls/Text.vue"; import LucideEdit from "@icons/LucideEdit.vue"; import LucideTrash from "@icons/LucideTrash.vue"; +import * as AppEnv from "@AppEnv"; + const vm: PrefControlModel = useUserControl(PrefControlModel); @@ -21,8 +23,10 @@ const vm: PrefControlModel = useUserControl(PrefControlModel); class="w-24 rounded-2xl ring ring-primary ring-offset-base-100 ring-offset-4 overflow-hidden bg-base-200"> Avatar Preview

-
@@ -186,27 +190,29 @@ const vm: PrefControlModel = useUserControl(PrefControlModel);
-
- -
-

- -

- -
+
diff --git a/src/controls/ProfileDialog.model.ts b/app/src/controls/ProfileDialog.model.ts similarity index 100% rename from src/controls/ProfileDialog.model.ts rename to app/src/controls/ProfileDialog.model.ts diff --git a/src/controls/ProfileDialog.vue b/app/src/controls/ProfileDialog.vue similarity index 100% rename from src/controls/ProfileDialog.vue rename to app/src/controls/ProfileDialog.vue diff --git a/src/controls/ProfileSetupControl.model.ts b/app/src/controls/ProfileSetupControl.model.ts similarity index 100% rename from src/controls/ProfileSetupControl.model.ts rename to app/src/controls/ProfileSetupControl.model.ts diff --git a/src/controls/ProfileSetupControl.vue b/app/src/controls/ProfileSetupControl.vue similarity index 100% rename from src/controls/ProfileSetupControl.vue rename to app/src/controls/ProfileSetupControl.vue diff --git a/src/controls/ProgressToast.model.ts b/app/src/controls/ProgressToast.model.ts similarity index 100% rename from src/controls/ProgressToast.model.ts rename to app/src/controls/ProgressToast.model.ts diff --git a/src/controls/ProgressToast.vue b/app/src/controls/ProgressToast.vue similarity index 100% rename from src/controls/ProgressToast.vue rename to app/src/controls/ProgressToast.vue diff --git a/src/controls/ReportControl.model.ts b/app/src/controls/ReportControl.model.ts similarity index 90% rename from src/controls/ReportControl.model.ts rename to app/src/controls/ReportControl.model.ts index 1432b36..529c73e 100644 --- a/src/controls/ReportControl.model.ts +++ b/app/src/controls/ReportControl.model.ts @@ -27,8 +27,8 @@ export class ReportControlModel extends DialogControl implements Action packageJSON.version); public readonly platform: string = this.computed(() => `${getPlatform()} ${getArch()}`); @@ -49,8 +49,13 @@ export class ReportControlModel extends DialogControl implements Action { - this.lang = await this.settingsService.getLocal(); - this.theme = await this.settingsService.getTheme(); + try { + this.lang = await this.settingsService.getLocal(); + } catch {} + + try { + this.theme = await this.settingsService.getTheme(); + } catch {} } public onOpen(): void { diff --git a/src/controls/ReportControl.vue b/app/src/controls/ReportControl.vue similarity index 100% rename from src/controls/ReportControl.vue rename to app/src/controls/ReportControl.vue diff --git a/src/controls/Text.model.ts b/app/src/controls/Text.model.ts similarity index 100% rename from src/controls/Text.model.ts rename to app/src/controls/Text.model.ts diff --git a/src/controls/Text.vue b/app/src/controls/Text.vue similarity index 100% rename from src/controls/Text.vue rename to app/src/controls/Text.vue diff --git a/src/controls/ToSControl.model.ts b/app/src/controls/ToSControl.model.ts similarity index 100% rename from src/controls/ToSControl.model.ts rename to app/src/controls/ToSControl.model.ts diff --git a/src/controls/ToSControl.vue b/app/src/controls/ToSControl.vue similarity index 95% rename from src/controls/ToSControl.vue rename to app/src/controls/ToSControl.vue index d00d3ac..8d2a54b 100644 --- a/src/controls/ToSControl.vue +++ b/app/src/controls/ToSControl.vue @@ -3,8 +3,8 @@ import {useDialogControl} from "vue-mvvm/dialog"; import {ToSControlModel} from "@controls/ToSControl.model"; import LucideInfo from "@icons/LucideInfo.vue"; -import tos from "@/../ToS.txt?raw"; -import disclaimer from "@/../LegalDisclaimer.txt?raw"; +import tos from "@/../../ToS.txt?raw"; +import disclaimer from "@/../../LegalDisclaimer.txt?raw"; const vm: ToSControlModel = useDialogControl(ToSControlModel); diff --git a/src/controls/ToastContainer.vue b/app/src/controls/ToastContainer.vue similarity index 100% rename from src/controls/ToastContainer.vue rename to app/src/controls/ToastContainer.vue diff --git a/src/controls/UpdateControl.model.ts b/app/src/controls/UpdateControl.model.ts similarity index 100% rename from src/controls/UpdateControl.model.ts rename to app/src/controls/UpdateControl.model.ts diff --git a/src/controls/UpdateControl.vue b/app/src/controls/UpdateControl.vue similarity index 100% rename from src/controls/UpdateControl.vue rename to app/src/controls/UpdateControl.vue diff --git a/src/icons/FlowbiteGithubSolid.vue b/app/src/icons/FlowbiteGithubSolid.vue similarity index 100% rename from src/icons/FlowbiteGithubSolid.vue rename to app/src/icons/FlowbiteGithubSolid.vue diff --git a/src/icons/LucideArrowLeft.vue b/app/src/icons/LucideArrowLeft.vue similarity index 100% rename from src/icons/LucideArrowLeft.vue rename to app/src/icons/LucideArrowLeft.vue diff --git a/src/icons/LucideArrowRight.vue b/app/src/icons/LucideArrowRight.vue similarity index 100% rename from src/icons/LucideArrowRight.vue rename to app/src/icons/LucideArrowRight.vue diff --git a/src/icons/LucideCheck.vue b/app/src/icons/LucideCheck.vue similarity index 100% rename from src/icons/LucideCheck.vue rename to app/src/icons/LucideCheck.vue diff --git a/src/icons/LucideCloudSync.vue b/app/src/icons/LucideCloudSync.vue similarity index 100% rename from src/icons/LucideCloudSync.vue rename to app/src/icons/LucideCloudSync.vue diff --git a/src/icons/LucideEdit.vue b/app/src/icons/LucideEdit.vue similarity index 100% rename from src/icons/LucideEdit.vue rename to app/src/icons/LucideEdit.vue diff --git a/src/icons/LucideEllipsisVertical.vue b/app/src/icons/LucideEllipsisVertical.vue similarity index 100% rename from src/icons/LucideEllipsisVertical.vue rename to app/src/icons/LucideEllipsisVertical.vue diff --git a/src/icons/LucideEye.vue b/app/src/icons/LucideEye.vue similarity index 100% rename from src/icons/LucideEye.vue rename to app/src/icons/LucideEye.vue diff --git a/src/icons/LucideEyeOff.vue b/app/src/icons/LucideEyeOff.vue similarity index 100% rename from src/icons/LucideEyeOff.vue rename to app/src/icons/LucideEyeOff.vue diff --git a/src/icons/LucideFolder.vue b/app/src/icons/LucideFolder.vue similarity index 100% rename from src/icons/LucideFolder.vue rename to app/src/icons/LucideFolder.vue diff --git a/src/icons/LucideHeart.vue b/app/src/icons/LucideHeart.vue similarity index 100% rename from src/icons/LucideHeart.vue rename to app/src/icons/LucideHeart.vue diff --git a/src/icons/LucideHistory.vue b/app/src/icons/LucideHistory.vue similarity index 100% rename from src/icons/LucideHistory.vue rename to app/src/icons/LucideHistory.vue diff --git a/src/icons/LucideInfo.vue b/app/src/icons/LucideInfo.vue similarity index 100% rename from src/icons/LucideInfo.vue rename to app/src/icons/LucideInfo.vue diff --git a/src/icons/LucideListVideo.vue b/app/src/icons/LucideListVideo.vue similarity index 100% rename from src/icons/LucideListVideo.vue rename to app/src/icons/LucideListVideo.vue diff --git a/src/icons/LucideMaximize.vue b/app/src/icons/LucideMaximize.vue similarity index 100% rename from src/icons/LucideMaximize.vue rename to app/src/icons/LucideMaximize.vue diff --git a/src/icons/LucideMinimize.vue b/app/src/icons/LucideMinimize.vue similarity index 100% rename from src/icons/LucideMinimize.vue rename to app/src/icons/LucideMinimize.vue diff --git a/src/icons/LucideNewspaper.vue b/app/src/icons/LucideNewspaper.vue similarity index 100% rename from src/icons/LucideNewspaper.vue rename to app/src/icons/LucideNewspaper.vue diff --git a/src/icons/LucidePalette.vue b/app/src/icons/LucidePalette.vue similarity index 100% rename from src/icons/LucidePalette.vue rename to app/src/icons/LucidePalette.vue diff --git a/src/icons/LucidePause.vue b/app/src/icons/LucidePause.vue similarity index 100% rename from src/icons/LucidePause.vue rename to app/src/icons/LucidePause.vue diff --git a/src/icons/LucidePlay.vue b/app/src/icons/LucidePlay.vue similarity index 100% rename from src/icons/LucidePlay.vue rename to app/src/icons/LucidePlay.vue diff --git a/src/icons/LucidePlus.vue b/app/src/icons/LucidePlus.vue similarity index 100% rename from src/icons/LucidePlus.vue rename to app/src/icons/LucidePlus.vue diff --git a/src/icons/LucidePopcorn.vue b/app/src/icons/LucidePopcorn.vue similarity index 100% rename from src/icons/LucidePopcorn.vue rename to app/src/icons/LucidePopcorn.vue diff --git a/src/icons/LucideSearch.vue b/app/src/icons/LucideSearch.vue similarity index 100% rename from src/icons/LucideSearch.vue rename to app/src/icons/LucideSearch.vue diff --git a/src/icons/LucideSettings.vue b/app/src/icons/LucideSettings.vue similarity index 100% rename from src/icons/LucideSettings.vue rename to app/src/icons/LucideSettings.vue diff --git a/src/icons/LucideTimerReset.vue b/app/src/icons/LucideTimerReset.vue similarity index 100% rename from src/icons/LucideTimerReset.vue rename to app/src/icons/LucideTimerReset.vue diff --git a/src/icons/LucideTrash.vue b/app/src/icons/LucideTrash.vue similarity index 100% rename from src/icons/LucideTrash.vue rename to app/src/icons/LucideTrash.vue diff --git a/src/icons/LucideX.vue b/app/src/icons/LucideX.vue similarity index 100% rename from src/icons/LucideX.vue rename to app/src/icons/LucideX.vue diff --git a/src/icons/SvgSpinnersBlocksWave.vue b/app/src/icons/SvgSpinnersBlocksWave.vue similarity index 100% rename from src/icons/SvgSpinnersBlocksWave.vue rename to app/src/icons/SvgSpinnersBlocksWave.vue diff --git a/src/langs/control/DetailControl.de.json b/app/src/langs/control/DetailControl.de.json similarity index 100% rename from src/langs/control/DetailControl.de.json rename to app/src/langs/control/DetailControl.de.json diff --git a/src/langs/control/DetailControl.en.json b/app/src/langs/control/DetailControl.en.json similarity index 100% rename from src/langs/control/DetailControl.en.json rename to app/src/langs/control/DetailControl.en.json diff --git a/src/langs/control/HLSPlayer.de.json b/app/src/langs/control/HLSPlayer.de.json similarity index 100% rename from src/langs/control/HLSPlayer.de.json rename to app/src/langs/control/HLSPlayer.de.json diff --git a/src/langs/control/HLSPlayer.en.json b/app/src/langs/control/HLSPlayer.en.json similarity index 100% rename from src/langs/control/HLSPlayer.en.json rename to app/src/langs/control/HLSPlayer.en.json diff --git a/src/langs/control/InfoControl.de.json b/app/src/langs/control/InfoControl.de.json similarity index 96% rename from src/langs/control/InfoControl.de.json rename to app/src/langs/control/InfoControl.de.json index 01e5b10..0bcf9df 100644 --- a/src/langs/control/InfoControl.de.json +++ b/app/src/langs/control/InfoControl.de.json @@ -5,6 +5,7 @@ "application": { "title": "Anwendung", "version": "Version", + "config": "Anwendungsconfig", "platform": "Plattform" }, "storage": { diff --git a/src/langs/control/InfoControl.en.json b/app/src/langs/control/InfoControl.en.json similarity index 96% rename from src/langs/control/InfoControl.en.json rename to app/src/langs/control/InfoControl.en.json index aeef4ee..11c0ecf 100644 --- a/src/langs/control/InfoControl.en.json +++ b/app/src/langs/control/InfoControl.en.json @@ -5,6 +5,7 @@ "application": { "title": "Application", "version": "Version", + "config": "Application Config", "platform": "Platform" }, "storage": { diff --git a/app/src/langs/control/PinDialog.de.json b/app/src/langs/control/PinDialog.de.json new file mode 100644 index 0000000..6986b25 --- /dev/null +++ b/app/src/langs/control/PinDialog.de.json @@ -0,0 +1,6 @@ +{ + "$lang": "en", + "$group": "PinDialog", + "title": "PIN eingeben", + "wrong": "Die eingegebene PIN ist falsch" +} \ No newline at end of file diff --git a/app/src/langs/control/PinDialog.en.json b/app/src/langs/control/PinDialog.en.json new file mode 100644 index 0000000..fa01fcb --- /dev/null +++ b/app/src/langs/control/PinDialog.en.json @@ -0,0 +1,6 @@ +{ + "$lang": "en", + "$group": "PinDialog", + "title": "Enter PIN", + "wrong": "The entered pin was wrong" +} \ No newline at end of file diff --git a/src/langs/control/PrefControl.de.json b/app/src/langs/control/PrefControl.de.json similarity index 100% rename from src/langs/control/PrefControl.de.json rename to app/src/langs/control/PrefControl.de.json diff --git a/src/langs/control/PrefControl.en.json b/app/src/langs/control/PrefControl.en.json similarity index 100% rename from src/langs/control/PrefControl.en.json rename to app/src/langs/control/PrefControl.en.json diff --git a/src/langs/control/ProfileSetupControl.de.json b/app/src/langs/control/ProfileSetupControl.de.json similarity index 100% rename from src/langs/control/ProfileSetupControl.de.json rename to app/src/langs/control/ProfileSetupControl.de.json diff --git a/src/langs/control/ProfileSetupControl.en.json b/app/src/langs/control/ProfileSetupControl.en.json similarity index 100% rename from src/langs/control/ProfileSetupControl.en.json rename to app/src/langs/control/ProfileSetupControl.en.json diff --git a/src/langs/control/ReportControl.de.json b/app/src/langs/control/ReportControl.de.json similarity index 100% rename from src/langs/control/ReportControl.de.json rename to app/src/langs/control/ReportControl.de.json diff --git a/src/langs/control/ReportControl.en.json b/app/src/langs/control/ReportControl.en.json similarity index 100% rename from src/langs/control/ReportControl.en.json rename to app/src/langs/control/ReportControl.en.json diff --git a/src/langs/control/UpdateControl.de.json b/app/src/langs/control/UpdateControl.de.json similarity index 100% rename from src/langs/control/UpdateControl.de.json rename to app/src/langs/control/UpdateControl.de.json diff --git a/src/langs/control/UpdateControl.en.json b/app/src/langs/control/UpdateControl.en.json similarity index 100% rename from src/langs/control/UpdateControl.en.json rename to app/src/langs/control/UpdateControl.en.json diff --git a/src/langs/other/EpisodeLanguage.en.json b/app/src/langs/other/EpisodeLanguage.en.json similarity index 100% rename from src/langs/other/EpisodeLanguage.en.json rename to app/src/langs/other/EpisodeLanguage.en.json diff --git a/src/langs/other/Genres.de.json b/app/src/langs/other/Genres.de.json similarity index 100% rename from src/langs/other/Genres.de.json rename to app/src/langs/other/Genres.de.json diff --git a/src/langs/other/Genres.en.json b/app/src/langs/other/Genres.en.json similarity index 100% rename from src/langs/other/Genres.en.json rename to app/src/langs/other/Genres.en.json diff --git a/src/langs/views/ListView.de.json b/app/src/langs/views/ListView.de.json similarity index 100% rename from src/langs/views/ListView.de.json rename to app/src/langs/views/ListView.de.json diff --git a/src/langs/views/ListView.en.json b/app/src/langs/views/ListView.en.json similarity index 100% rename from src/langs/views/ListView.en.json rename to app/src/langs/views/ListView.en.json diff --git a/src/langs/views/PlayerView.de.json b/app/src/langs/views/PlayerView.de.json similarity index 100% rename from src/langs/views/PlayerView.de.json rename to app/src/langs/views/PlayerView.de.json diff --git a/src/langs/views/PlayerView.en.json b/app/src/langs/views/PlayerView.en.json similarity index 100% rename from src/langs/views/PlayerView.en.json rename to app/src/langs/views/PlayerView.en.json diff --git a/src/langs/views/ProviderView.de.json b/app/src/langs/views/ProviderView.de.json similarity index 100% rename from src/langs/views/ProviderView.de.json rename to app/src/langs/views/ProviderView.de.json diff --git a/src/langs/views/ProviderView.en.json b/app/src/langs/views/ProviderView.en.json similarity index 100% rename from src/langs/views/ProviderView.en.json rename to app/src/langs/views/ProviderView.en.json diff --git a/src/langs/views/SeriesSyncView.de.json b/app/src/langs/views/SeriesSyncView.de.json similarity index 58% rename from src/langs/views/SeriesSyncView.de.json rename to app/src/langs/views/SeriesSyncView.de.json index 4796498..2bf46c2 100644 --- a/src/langs/views/SeriesSyncView.de.json +++ b/app/src/langs/views/SeriesSyncView.de.json @@ -7,5 +7,9 @@ "refreshSeasons": "Bestehende Staffeln aktualisieren", "movies": "Filme", "season": "Staffel", - "startSync": "Sync starten" + "startSync": "Sync starten", + "resync": "Erneut synchronisieren", + "upToDate": "Alles ist auf dem neuesten Stand. Du kannst trotzdem eine erneute Synchronisierung starten, um die neuesten Inhalte zu laden.", + "queued": "Eine Synchronisierung ist in der Warteschlange und startet in Kürze.", + "processing": "Eine Synchronisierung läuft derzeit." } \ No newline at end of file diff --git a/src/langs/views/SeriesSyncView.en.json b/app/src/langs/views/SeriesSyncView.en.json similarity index 60% rename from src/langs/views/SeriesSyncView.en.json rename to app/src/langs/views/SeriesSyncView.en.json index 0cc3efa..af41cea 100644 --- a/src/langs/views/SeriesSyncView.en.json +++ b/app/src/langs/views/SeriesSyncView.en.json @@ -7,5 +7,9 @@ "refreshSeasons": "Refresh existing seasons", "movies": "Movies", "season": "Season", - "startSync": "Start sync" + "startSync": "Start sync", + "resync": "Resync", + "upToDate": "Everything is up to date. You can still trigger a resync to fetch the latest content.", + "queued": "A sync is queued and will start shortly.", + "processing": "A sync is currently in progress." } \ No newline at end of file diff --git a/src/langs/views/SettingsView.de.json b/app/src/langs/views/SettingsView.de.json similarity index 100% rename from src/langs/views/SettingsView.de.json rename to app/src/langs/views/SettingsView.de.json diff --git a/src/langs/views/SettingsView.en.json b/app/src/langs/views/SettingsView.en.json similarity index 100% rename from src/langs/views/SettingsView.en.json rename to app/src/langs/views/SettingsView.en.json diff --git a/src/langs/views/StreamView.de.json b/app/src/langs/views/StreamView.de.json similarity index 100% rename from src/langs/views/StreamView.de.json rename to app/src/langs/views/StreamView.de.json diff --git a/src/langs/views/StreamView.en.json b/app/src/langs/views/StreamView.en.json similarity index 100% rename from src/langs/views/StreamView.en.json rename to app/src/langs/views/StreamView.en.json diff --git a/src/langs/views/StreamsView.de.json b/app/src/langs/views/StreamsView.de.json similarity index 100% rename from src/langs/views/StreamsView.de.json rename to app/src/langs/views/StreamsView.de.json diff --git a/src/langs/views/StreamsView.en.json b/app/src/langs/views/StreamsView.en.json similarity index 100% rename from src/langs/views/StreamsView.en.json rename to app/src/langs/views/StreamsView.en.json diff --git a/src/langs/views/SyncView.de.json b/app/src/langs/views/SyncView.de.json similarity index 100% rename from src/langs/views/SyncView.de.json rename to app/src/langs/views/SyncView.de.json diff --git a/src/langs/views/SyncView.en.json b/app/src/langs/views/SyncView.en.json similarity index 100% rename from src/langs/views/SyncView.en.json rename to app/src/langs/views/SyncView.en.json diff --git a/src/langs/views/WatchlistView.de.json b/app/src/langs/views/WatchlistView.de.json similarity index 100% rename from src/langs/views/WatchlistView.de.json rename to app/src/langs/views/WatchlistView.de.json diff --git a/src/langs/views/WatchlistView.en.json b/app/src/langs/views/WatchlistView.en.json similarity index 100% rename from src/langs/views/WatchlistView.en.json rename to app/src/langs/views/WatchlistView.en.json diff --git a/src/main.css b/app/src/main.css similarity index 100% rename from src/main.css rename to app/src/main.css diff --git a/src/main.ts b/app/src/main.ts similarity index 65% rename from src/main.ts rename to app/src/main.ts index 21a90ef..c7ee2d4 100644 --- a/src/main.ts +++ b/app/src/main.ts @@ -1,6 +1,6 @@ -import { App, createApp } from "vue"; -import { createMVVM, MVVMApp } from "vue-mvvm"; -import { AppConfig } from "@/config"; +import {App, createApp} from "vue"; +import {createMVVM, MVVMApp} from "vue-mvvm"; +import {AppConfig} from "@configs/app"; import "@utils/array"; import "@utils/string"; diff --git a/src/models/episode.model.ts b/app/src/models/episode.model.ts similarity index 74% rename from src/models/episode.model.ts rename to app/src/models/episode.model.ts index a71bc87..e38fb7a 100644 --- a/src/models/episode.model.ts +++ b/app/src/models/episode.model.ts @@ -1,3 +1,6 @@ +import {SyncStatus} from "@contracts/season.contract"; +import {EpisodeLanguage} from "@providers/default"; + export interface EpisodeFetchModel { episode_number: number; german_title: string; @@ -18,6 +21,38 @@ export interface EpisodeModel extends EpisodeDbModel { clone(): EpisodeModel; } +/** + * Used for API only + */ +export interface EpisodeCreateModel { + season_id: number; + episode_number: number; + german_title: string; + english_title: string; + description: string; +} + +/** + * Used for API only + */ +export interface EpisodeUpdateModel { + german_title: string; + english_title: string; + description: string; +} + +/** + * Used for API only + */ +export interface EpisodeProviderModel { + status: SyncStatus; + providers: Array<{ + name: string; + url: string; + language_code: EpisodeLanguage; + }>; +} + export function EpisodeModel( episode_id: number, season_id: number, diff --git a/src/models/genre.model.ts b/app/src/models/genre.model.ts similarity index 81% rename from src/models/genre.model.ts rename to app/src/models/genre.model.ts index 61536fb..0901547 100644 --- a/src/models/genre.model.ts +++ b/app/src/models/genre.model.ts @@ -12,6 +12,22 @@ export interface GenreModel extends GenreDbModel { clone(): GenreModel; } +/** + * Used for API only + */ +export interface GenreCreateModel { + key: string; +} + +/** + * Used for API only + */ +export interface GenreToSeriesModel { + genre_id: number; + series_id: number; + main_genre: boolean; +} + export function GenreModel(key: string): GenreModel; export function GenreModel(genre_id: number, key: string): GenreModel; diff --git a/src/models/list.model.ts b/app/src/models/list.model.ts similarity index 94% rename from src/models/list.model.ts rename to app/src/models/list.model.ts index 1b151a0..f0834f1 100644 --- a/src/models/list.model.ts +++ b/app/src/models/list.model.ts @@ -8,6 +8,10 @@ export interface ListModel extends ListDbModel { clone(): ListModel; } +export interface ListCreateModel { + name: string; +} + export function ListModel( name: string, tenant_id: string diff --git a/src/models/profile.model.ts b/app/src/models/profile.model.ts similarity index 69% rename from src/models/profile.model.ts rename to app/src/models/profile.model.ts index c21cfe3..4f5c79a 100644 --- a/src/models/profile.model.ts +++ b/app/src/models/profile.model.ts @@ -14,8 +14,8 @@ export interface ProfileDbModel { mouth: ProfileMouth; theme: string; lang: SupportedLocals; - tos_accepted: string; - sync_catalog: string; + tos_accepted: number; + sync_catalog: number; } export interface ProfileModel extends Omit { @@ -24,6 +24,41 @@ export interface ProfileModel extends Omit { + tos_accepted: boolean; + sync_catalog: boolean; +} + +/** + * Used for API only + */ +export interface ProfileCreateModel { + name: string; + password: string; + password_salt: string; + background_color: string; + eye: ProfileEye; + mouth: ProfileMouth; + theme: string; + lang: SupportedLocals; +} + +/** + * Used for API only + */ +export interface ProfileUpdateModel { + name: string; + background_color: string; + eye: ProfileEye; + mouth: ProfileMouth; + theme: string; + lang: SupportedLocals; + tos_accepted: boolean; +} + export function ProfileModel( uuid: string, name: string, @@ -32,8 +67,8 @@ export function ProfileModel( mouth: ProfileMouth, theme: string, lang: SupportedLocals, - tosAccepted: string, - syncCatalog: string + tosAccepted: boolean, + syncCatalog: boolean ): ProfileModel; export function ProfileModel( profile_id: number, @@ -44,8 +79,8 @@ export function ProfileModel( mouth: ProfileMouth, theme: string, lang: SupportedLocals, - tosAccepted: string, - syncCatalog: string + tosAccepted: boolean, + syncCatalog: boolean ): ProfileModel; export function ProfileModel(...args: unknown[]): ProfileModel { @@ -53,7 +88,7 @@ export function ProfileModel(...args: unknown[]): ProfileModel { args.unshift(0); } - return _ProfileModel(...args as [number, string, string, string, ProfileEye, ProfileMouth, string, SupportedLocals, string, string]); + return _ProfileModel(...args as [number, string, string, string, ProfileEye, ProfileMouth, string, SupportedLocals, boolean, boolean]); } function _ProfileModel( @@ -65,8 +100,8 @@ function _ProfileModel( mouth: ProfileMouth, theme: string, lang: SupportedLocals, - tosAccepted: string, - syncCatalog: string, + tosAccepted: boolean, + syncCatalog: boolean, ): ProfileModel { const obj: ProfileModel = { profile_id: profile_id, @@ -77,8 +112,8 @@ function _ProfileModel( mouth: mouth, theme: theme, lang: lang, - tos_accepted: tosAccepted == "true", - sync_catalog: syncCatalog == "true", + tos_accepted: tosAccepted, + sync_catalog: syncCatalog, clone: clone, }; diff --git a/src/models/season.model.ts b/app/src/models/season.model.ts similarity index 91% rename from src/models/season.model.ts rename to app/src/models/season.model.ts index 9e4ae2d..14e58f2 100644 --- a/src/models/season.model.ts +++ b/app/src/models/season.model.ts @@ -13,6 +13,14 @@ export interface SeasonModel extends SeasonDbModel { clone(): SeasonModel; } +/** + * Used for API only + */ +export interface SeasonCreateModel { + series_id: number; + season_number: number; +} + export function SeasonModel(series_id: number, season_number: number): SeasonModel; export function SeasonModel(season_id: number, series_id: number, season_number: number): SeasonModel; diff --git a/src/models/series.model.ts b/app/src/models/series.model.ts similarity index 82% rename from src/models/series.model.ts rename to app/src/models/series.model.ts index 1e702ee..8e075b2 100644 --- a/src/models/series.model.ts +++ b/app/src/models/series.model.ts @@ -1,3 +1,5 @@ +import {SyncStatus} from "@contracts/season.contract"; + export interface SeriesFetchModel { series_id: number; guid: string; @@ -18,6 +20,24 @@ export interface SeriesModel extends SeriesDbModel { clone(): SeriesModel; } +/** + * Used for API only + */ +export interface SeriesCreateModel { + guid: string; + title: string; + description: string; + preview_image: string | null; +} + +/** + * Used for API only + */ +export interface SeriesSyncModel { + requires_sync: boolean; + status: SyncStatus | null; +} + export function SeriesModel(guid: string, title: string, description: string, preview_image: string | null): SeriesModel; export function SeriesModel(series_id: number, guid: string, title: string, description: string, preview_image: string | null): SeriesModel; export function SeriesModel(...args: unknown[]): SeriesModel { diff --git a/src/models/watchtime.model.ts b/app/src/models/watchtime.model.ts similarity index 79% rename from src/models/watchtime.model.ts rename to app/src/models/watchtime.model.ts index d149116..1986b7e 100644 --- a/src/models/watchtime.model.ts +++ b/app/src/models/watchtime.model.ts @@ -10,6 +10,31 @@ export interface WatchtimeModel extends WatchtimeDbModel { clone(): WatchtimeModel; } + +/** + * Used for API only + */ +export interface TotalProgressionModel { + total_progression: number; +} + +/** + * Used for API only + */ +export interface WatchtimeCreateModel { + episode_id: number; + percentage_watched: number; + stopped_time: number; +} + +/** + * Used for API only + */ +export interface WatchtimeUpdateModel { + percentage_watched: number | null; + stopped_time: number | null; +} + export function WatchtimeModel(episode_id: number, percentage_watched: number, stopped_time: number, tenant_id: string): WatchtimeModel; export function WatchtimeModel(watchtime_id: number, episode_id: number, percentage_watched: number, stopped_time: number, tenant_id: string): WatchtimeModel; diff --git a/src/providers/aniworld/fetcher.ts b/app/src/providers/aniworld/fetcher.ts similarity index 91% rename from src/providers/aniworld/fetcher.ts rename to app/src/providers/aniworld/fetcher.ts index 5258f2c..75be6e6 100644 --- a/src/providers/aniworld/fetcher.ts +++ b/app/src/providers/aniworld/fetcher.ts @@ -1,6 +1,3 @@ -import {path} from "@tauri-apps/api"; -import * as fs from "@tauri-apps/plugin-fs"; - import {Provider} from "@contracts/fetch.contract"; import {DefaultProvider, EpisodeLanguage, IInformationFetcher} from "@providers/default"; @@ -13,7 +10,6 @@ import {EpisodeFetchModel} from "@models/episode.model"; import * as http from "@utils/http"; import * as hash from "@utils/hash"; - export class AniWorldFetcher implements IInformationFetcher { private readonly provider: DefaultProvider; private readonly parser: DOMParser; @@ -24,7 +20,7 @@ export class AniWorldFetcher implements IInformationFetcher { } public async getCatalog(): Promise { - const html: string = await http.get(this.provider.catalogURL); + const html: string = await http.get(this.provider.catalogURL).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const seriesList: HTMLUListElement | null = document.querySelector("#seriesContainer ul"); if (!seriesList) { @@ -46,8 +42,8 @@ export class AniWorldFetcher implements IInformationFetcher { return guids; } - public async getSeries(guid: string): Promise<[model: SeriesFetchModel, genres: GenreFetchModel[]]> { - const html: string = await http.get(this.provider.streamURL(guid)); + public async getSeries(guid: string): Promise<[model: SeriesFetchModel, genres: GenreFetchModel[], previewImage: Uint8Array | null]> { + const html: string = await http.get(this.provider.streamURL(guid)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const informationPanel: HTMLElement | null = document.querySelector("#series"); @@ -65,15 +61,12 @@ export class AniWorldFetcher implements IInformationFetcher { const previewImageElement: HTMLImageElement | null = informationPanel.querySelector(".seriesCoverBox img"); - let previewImage: string | null = null; + let previewImageHash: string | null = null; + let previewImage: Uint8Array | null = null; if (previewImageElement && previewImageElement.hasAttribute("data-src")) { const url: string = previewImageElement.getAttribute("data-src")!.substring(1); - const binary: Uint8Array = await http.getBinary(`${this.provider.baseURL}/${url}`); - previewImage = hash.fnv1a(guid); - - const storageLocation: string = await this.provider.getStorageLocation(); - const filePath: string = await path.join(storageLocation, previewImage); - await fs.writeFile(filePath, binary); + previewImage = await http.get(`${this.provider.baseURL}/${url}`).uint8Array(); + previewImageHash = hash.fnv1a(guid); } const genres: GenreFetchModel[] = []; @@ -94,12 +87,12 @@ export class AniWorldFetcher implements IInformationFetcher { genres.push({key: genre, main: genre == mainGenreKey}); } - const model: SeriesModel = SeriesModel(guid, title, description, previewImage); - return [model, genres] + const model: SeriesModel = SeriesModel(guid, title, description, previewImageHash); + return [model, genres, previewImage] } public async getSeasons(series: SeriesModel): Promise { - const html: string = await http.get(this.provider.streamURL(series.guid)); + const html: string = await http.get(this.provider.streamURL(series.guid)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const streamPanel: HTMLElement | null = document.querySelector("#stream"); if (!streamPanel) { @@ -134,7 +127,7 @@ export class AniWorldFetcher implements IInformationFetcher { } public async getEpisodes(guid: string, seasonNumber: number): Promise { - const html: string = await http.get(this.provider.seasonURL(guid, seasonNumber)); + const html: string = await http.get(this.provider.seasonURL(guid, seasonNumber)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const tableBody: HTMLTableSectionElement | null = document.querySelector(`#season${seasonNumber}`); @@ -143,7 +136,7 @@ export class AniWorldFetcher implements IInformationFetcher { } const episodes: EpisodeFetchModel[] = []; - for (const row of tableBody.rows) { + for (const row of tableBody.children) { if (!row.hasAttribute("data-episode-season-id")) { continue; } @@ -170,7 +163,7 @@ export class AniWorldFetcher implements IInformationFetcher { } private async fetchDescription(guid: string, seasonNumber: number, episodeNumber: number): Promise { - const html: string = await http.get(this.provider.episodeURL(guid, seasonNumber, episodeNumber)); + const html: string = await http.get(this.provider.episodeURL(guid, seasonNumber, episodeNumber)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const descriptionPanel: HTMLElement | null = document.querySelector("#wrapper div.hosterSiteTitle p.descriptionSpoiler"); if (!descriptionPanel) { @@ -181,7 +174,7 @@ export class AniWorldFetcher implements IInformationFetcher { } public async fetchProviders(guid: string, seasonNumber: number, episodeNumber: number): Promise { - const html: string = await http.get(this.provider.episodeURL(guid, seasonNumber, episodeNumber)); + const html: string = await http.get(this.provider.episodeURL(guid, seasonNumber, episodeNumber)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const row: HTMLUListElement | null = document.querySelector(".hosterSiteVideo ul.row"); diff --git a/src/providers/aniworld/index.ts b/app/src/providers/aniworld/index.ts similarity index 100% rename from src/providers/aniworld/index.ts rename to app/src/providers/aniworld/index.ts diff --git a/src/providers/aniworld/provider.ts b/app/src/providers/aniworld/provider.ts similarity index 81% rename from src/providers/aniworld/provider.ts rename to app/src/providers/aniworld/provider.ts index ab26e02..fa9efa7 100644 --- a/src/providers/aniworld/provider.ts +++ b/app/src/providers/aniworld/provider.ts @@ -1,11 +1,12 @@ -import {path} from "@tauri-apps/api" import * as fs from "@tauri-apps/plugin-fs"; -import {MetadataDbService} from "@contracts/standalone/metadata.contract"; - import {AniWorldFetcher} from "@providers/aniworld/fetcher"; import {DefaultProvider, EpisodeLanguage, IInformationFetcher} from "@providers/default"; +import * as path from "@utils/path"; + +import * as AppEnv from "@AppEnv"; + export class AniWorldProvider extends DefaultProvider { public static readonly UNIQUE_KEY: string = "aniworld"; private fetcher: IInformationFetcher | null; @@ -20,8 +21,8 @@ export class AniWorldProvider extends DefaultProvider { return `${this.baseURL}/animes-alphabet`; } - public constructor(service: MetadataDbService) { - super(service); + public constructor() { + super(); this.fetcher = null; } @@ -47,13 +48,15 @@ export class AniWorldProvider extends DefaultProvider { } public async getStorageLocation(): Promise { - const appDir: string = await path.appDataDir(); - const dataDir: string = await path.join(appDir, "aniworld"); - - if (!await fs.exists(dataDir)) { - await fs.mkdir(dataDir, { - recursive: true - }); + const appDir: string = await path.appDataDir() + const dataDir: string = path.join(appDir, "aniworld"); + + if (!AppEnv.isTesting) { + if (!await fs.exists(dataDir)) { + await fs.mkdir(dataDir, { + recursive: true + }); + } } diff --git a/src/providers/default.ts b/app/src/providers/default.ts similarity index 62% rename from src/providers/default.ts rename to app/src/providers/default.ts index 342d3db..5d44f44 100644 --- a/src/providers/default.ts +++ b/app/src/providers/default.ts @@ -1,8 +1,3 @@ -import {path} from "@tauri-apps/api"; - -import {DbSession} from "@services/utils/db"; - -import {MetadataDbService} from "@contracts/standalone/metadata.contract"; import {Provider} from "@contracts/fetch.contract"; import {SeriesFetchModel, SeriesModel} from "@models/series.model"; @@ -10,6 +5,8 @@ import {SeasonFetchModel} from "@models/season.model"; import {EpisodeFetchModel} from "@models/episode.model"; import {GenreFetchModel} from "@models/genre.model"; +import * as path from "@utils/path"; + export enum EpisodeLanguage { DE_DUB, DE_SUP, @@ -21,7 +18,7 @@ export enum EpisodeLanguage { export interface IInformationFetcher { getCatalog(): Promise; - getSeries(guid: string): Promise<[model: SeriesFetchModel, genres: GenreFetchModel[]]>; + getSeries(guid: string): Promise<[model: SeriesFetchModel, genres: GenreFetchModel[], previewImage: Uint8Array | null]>; getSeasons(series: SeriesModel): Promise; @@ -38,33 +35,13 @@ export abstract class DefaultProvider { public abstract get catalogURL(): string; - private readonly service: MetadataDbService; - private session: DbSession | null; - - protected constructor(service: MetadataDbService) { - this.service = service; - this.session = null; + protected constructor() { } - public async getDatabase(): Promise { - if (this.session) { - return this.session; - } - + public async getDatabaseFile(): Promise { const dataDir: string = await this.getStorageLocation(); - const dbFile: string = await path.join(dataDir, "metadata.db"); - - this.session = await this.service.openDB(dbFile, this.uniqueKey); - return this.session!; - } - - public async closeDatabase(): Promise { - if (!this.session) { - return; - } - await this.session.close(); - this.session = null; + return path.join(dataDir, "metadata.db"); } public abstract streamURL(guid: string): string; diff --git a/src/providers/sto/fetcher.ts b/app/src/providers/sto/fetcher.ts similarity index 89% rename from src/providers/sto/fetcher.ts rename to app/src/providers/sto/fetcher.ts index fb58bc6..e33505f 100644 --- a/src/providers/sto/fetcher.ts +++ b/app/src/providers/sto/fetcher.ts @@ -1,6 +1,3 @@ -import {path} from "@tauri-apps/api"; -import * as fs from "@tauri-apps/plugin-fs"; - import {Provider} from "@contracts/fetch.contract"; import {DefaultProvider, EpisodeLanguage, IInformationFetcher} from "@providers/default"; @@ -13,7 +10,6 @@ import {EpisodeFetchModel} from "@models/episode.model"; import * as http from "@utils/http"; import * as hash from "@utils/hash"; - export class StoFetcher implements IInformationFetcher { private readonly provider: DefaultProvider; private readonly parser: DOMParser; @@ -24,7 +20,7 @@ export class StoFetcher implements IInformationFetcher { } public async getCatalog(): Promise { - const html: string = await http.get(this.provider.catalogURL); + const html: string = await http.get(this.provider.catalogURL).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const seriesLists: NodeListOf = document.querySelectorAll("ul.series-list"); if (seriesLists.length == 0) { @@ -48,8 +44,8 @@ export class StoFetcher implements IInformationFetcher { return guids; } - public async getSeries(guid: string): Promise<[model: SeriesFetchModel, genres: GenreFetchModel[]]> { - const html: string = await http.get(this.provider.streamURL(guid)); + public async getSeries(guid: string): Promise<[model: SeriesFetchModel, genres: GenreFetchModel[], previewImage: Uint8Array | null]> { + const html: string = await http.get(this.provider.streamURL(guid)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const titleElement: HTMLElement | null = document.querySelector("div.show-header-wrapper > div.container-fluid.px-2.px-md-3.px-lg-3.px-xl-4.position-relative > div.row.g-4.mb-2 > div.col-12.col-md-9.col-lg-10.text-light > h1") @@ -62,7 +58,8 @@ export class StoFetcher implements IInformationFetcher { const description: string = descriptionElement?.innerText.trim() ?? "N/A"; const previewImageElement: HTMLImageElement | null = document.querySelector("body > div.show-header-wrapper > div.container-fluid.px-2.px-md-3.px-lg-3.px-xl-4.position-relative > div.row.g-4.mb-2 > div.col-3.col-md-3.col-lg-2.d-none.d-md-block > picture > img") - let previewImage: string | null = null; + let previewImageHash: string | null = null; + let previewImage: Uint8Array | null = null; if (previewImageElement && previewImageElement.hasAttribute("data-src")) { let url: string = previewImageElement.getAttribute("data-src")!; if (!url.startsWith("http")) { @@ -71,12 +68,8 @@ export class StoFetcher implements IInformationFetcher { } url = `${this.provider.baseURL}${url}`; } - const binary: Uint8Array = await http.getBinary(url); - previewImage = hash.fnv1a(guid); - - const storageLocation: string = await this.provider.getStorageLocation(); - const filePath: string = await path.join(storageLocation, previewImage); - await fs.writeFile(filePath, binary); + previewImage = await http.get(url).uint8Array(); + previewImageHash = hash.fnv1a(guid); } const genres: GenreFetchModel[] = []; @@ -90,12 +83,12 @@ export class StoFetcher implements IInformationFetcher { genres.push({key: genre, main: false}); } - const model: SeriesModel = SeriesModel(guid, title, description, previewImage); - return [model, genres] + const model: SeriesModel = SeriesModel(guid, title, description, previewImageHash); + return [model, genres, previewImage] } public async getSeasons(series: SeriesModel): Promise { - const html: string = await http.get(this.provider.streamURL(series.guid)); + const html: string = await http.get(this.provider.streamURL(series.guid)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const seasonElements: NodeListOf = document.querySelectorAll("#season-nav > ul a") @@ -121,16 +114,20 @@ export class StoFetcher implements IInformationFetcher { } public async getEpisodes(guid: string, seasonNumber: number): Promise { - const html: string = await http.get(this.provider.seasonURL(guid, seasonNumber)); + const html: string = await http.get(this.provider.seasonURL(guid, seasonNumber)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); - const tableBody: HTMLTableSectionElement | null = document.querySelector(`table.episode-table`); - - if (!tableBody) { + const table: HTMLTableElement | null = document.querySelector(`table.episode-table`); + if (!table) { throw "Failed to extract meta information: Failed to find episode table"; } - + + const tableBody: HTMLTableSectionElement | null = table.querySelector("tbody"); + if (!tableBody) { + throw "Failed to extract meta information: Failed to find episode table body"; + } + const episodes: EpisodeFetchModel[] = []; - for (const row of tableBody.rows) { + for (const row of tableBody.children) { if (!row.classList.contains("episode-row")) { continue; } @@ -161,7 +158,7 @@ export class StoFetcher implements IInformationFetcher { } private async fetchDescription(guid: string, seasonNumber: number, episodeNumber: number): Promise { - const html: string = await http.get(this.provider.episodeURL(guid, seasonNumber, episodeNumber)); + const html: string = await http.get(this.provider.episodeURL(guid, seasonNumber, episodeNumber)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const descriptionPanel: HTMLElement | null = document.querySelector("[id^='desc-'] > div"); if (!descriptionPanel) { @@ -172,7 +169,7 @@ export class StoFetcher implements IInformationFetcher { } public async fetchProviders(guid: string, seasonNumber: number, episodeNumber: number): Promise { - const html: string = await http.get(this.provider.episodeURL(guid, seasonNumber, episodeNumber)); + const html: string = await http.get(this.provider.episodeURL(guid, seasonNumber, episodeNumber)).text(); const document: Document = this.parser.parseFromString(html, "text/html"); const providerElements: NodeListOf = document.querySelectorAll("#episode-links button"); diff --git a/src/providers/sto/index.ts b/app/src/providers/sto/index.ts similarity index 100% rename from src/providers/sto/index.ts rename to app/src/providers/sto/index.ts diff --git a/src/providers/sto/provider.ts b/app/src/providers/sto/provider.ts similarity index 78% rename from src/providers/sto/provider.ts rename to app/src/providers/sto/provider.ts index d7a6e86..c91b236 100644 --- a/src/providers/sto/provider.ts +++ b/app/src/providers/sto/provider.ts @@ -1,11 +1,12 @@ -import {path} from "@tauri-apps/api" import * as fs from "@tauri-apps/plugin-fs"; -import {MetadataDbService} from "@contracts/standalone/metadata.contract"; - import {StoFetcher} from "@providers/sto/fetcher"; import {DefaultProvider, EpisodeLanguage, IInformationFetcher} from "@providers/default"; +import * as path from "@utils/path"; + +import * as AppEnv from "@AppEnv"; + export class StoProvider extends DefaultProvider { public static readonly UNIQUE_KEY: string = "sto"; @@ -21,8 +22,8 @@ export class StoProvider extends DefaultProvider { return `${this.baseURL}/serien-alphabet`; } - public constructor(service: MetadataDbService) { - super(service); + public constructor() { + super(); this.fetcher = null; } @@ -40,13 +41,15 @@ export class StoProvider extends DefaultProvider { } public async getStorageLocation(): Promise { - const appDir: string = await path.appDataDir(); - const dataDir: string = await path.join(appDir, "sto"); - - if (!await fs.exists(dataDir)) { - await fs.mkdir(dataDir, { - recursive: true - }); + const appDir: string = await path.appDataDir() + const dataDir: string = path.join(appDir, "sto"); + + if (!AppEnv.isTesting) { + if (!await fs.exists(dataDir)) { + await fs.mkdir(dataDir, { + recursive: true + }); + } } return dataDir; diff --git a/app/src/services/client/api/api.service.ts b/app/src/services/client/api/api.service.ts new file mode 100644 index 0000000..a93d5a5 --- /dev/null +++ b/app/src/services/client/api/api.service.ts @@ -0,0 +1,74 @@ +import {ReadableGlobalContext} from "vue-mvvm"; + +import {ApiService, PathParameter} from "@contracts/client/api.contract"; + +import {ServiceDeclaration} from "@services/declaration"; + +import * as http from "@utils/http"; + +export class ApiServiceImpl implements ApiService { + public static HEADERS: [string, string][] = [["Content-Type", "application/json"]]; + + public constructor(_ctx: ReadableGlobalContext) { + } + + public async get(def: PathParameter): Promise { + const url: string = this.buildURL(def); + return await http.get(url, ApiServiceImpl.HEADERS).json(); + } + + public async post(def: PathParameter, body: Body): Promise { + let data: string | undefined; + + if (body == null) { + data = undefined; + } else if (typeof body == "object") { + data = JSON.stringify(body); + } else { + data = body; + } + + const url: string = this.buildURL(def); + return await http.post(url, data, ApiServiceImpl.HEADERS).json(); + } + + public async put(def: PathParameter, body: Body): Promise { + let data: string; + + if (typeof body == "object") { + data = JSON.stringify(body); + } else { + data = body; + } + + const url: string = this.buildURL(def); + return await http.put(url, data, ApiServiceImpl.HEADERS).json(); + } + + public async delete(def: PathParameter): Promise { + const url: string = this.buildURL(def); + return await http.delete$(url, ApiServiceImpl.HEADERS).json(); + } + + protected buildURL(def: PathParameter): string { + if (Array.isArray(def)) { + // TODO replace with dynamic domain config + const base: string = "http://localhost:5000"; + const path: string = def.join("/"); + return `${base}/${path}`; + } + + const base: string = this.buildURL(def.path); + const query: string = Object.entries(def.query).map(([key, value]) => Array.isArray(value) + ? value.map(value => `${key}=${value}`).join("&") + : `${key}=${value}` + ).join("&"); + + return `${base}?${query}`; + } +} + +export default { + key: ApiService, + ctor: ApiServiceImpl +} satisfies ServiceDeclaration; \ No newline at end of file diff --git a/app/src/services/client/episode.service.ts b/app/src/services/client/episode.service.ts new file mode 100644 index 0000000..5acb3d3 --- /dev/null +++ b/app/src/services/client/episode.service.ts @@ -0,0 +1,114 @@ +import {ReadableGlobalContext} from "vue-mvvm"; + +import {ApiServiceBase} from "@services/utils/api"; +import {ServiceDeclaration} from "@services/declaration"; + +import {EpisodeService} from "@contracts/episode.contract"; +import {ProviderService} from "@contracts/provider.contract"; + +import {EpisodeCreateModel, EpisodeDbModel, EpisodeModel, EpisodeUpdateModel} from "@models/episode.model"; + +import {DefaultProvider} from "@providers/default"; + +import {HTTPError} from "@utils/http"; +import {UnsupportedPlatformError} from "@utils/error"; + +import * as AppEnv from "@AppEnv"; + +class EpisodeServiceImpl extends ApiServiceBase implements EpisodeService { + private readonly providerService: ProviderService; + + public constructor(ctx: ReadableGlobalContext) { + super(ctx); + + this.providerService = ctx.getService(ProviderService); + } + + public async getEpisode(episodeId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + try { + const episode: EpisodeDbModel = await this.get(["api", provider.uniqueKey, "episodes", episodeId]); + + return EpisodeModel( + episode.episode_id, + episode.season_id, + episode.episode_number, + episode.german_title, + episode.english_title, + episode.description + ); + } catch (e) { + if (e instanceof HTTPError && e.status == 404) { + return null; + } + + throw e; + } + } + + public async getEpisodes(seasonId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const episodes: EpisodeDbModel[] = await this.get(["api", provider.uniqueKey, "episodes", "season", seasonId]); + + return episodes.map(episode => EpisodeModel( + episode.episode_id, + episode.season_id, + episode.episode_number, + episode.german_title, + episode.english_title, + episode.description + )); + } + + public async insertEpisode( + seasonId: number, + episodeNumber: number, + germanTitle: string, + englishTitle: string, + description: string + ): Promise { + if (!AppEnv.isTesting) { + throw new UnsupportedPlatformError("EpisodeServiceImpl.insertEpisode"); + } + + const provider: DefaultProvider = await this.providerService.getProvider(); + + const episode: EpisodeDbModel = await this.post(["api", provider.uniqueKey, "episodes"], { + season_id: seasonId, + episode_number: episodeNumber, + german_title: germanTitle, + english_title: englishTitle, + description + }); + + return EpisodeModel( + episode.episode_id, + episode.season_id, + episode.episode_number, + episode.german_title, + episode.english_title, + episode.description + ); + } + + public async updateEpisodeMetadata(episodeId: number, germanTitle: string, englishTitle: string, description: string): Promise { + if (!AppEnv.isTesting) { + throw new UnsupportedPlatformError("EpisodeServiceImpl.updateEpisodeMetadata"); + } + + const provider: DefaultProvider = await this.providerService.getProvider(); + + await this.put(["api", provider.uniqueKey, "episodes", episodeId], { + german_title: germanTitle, + english_title: englishTitle, + description + }); + } +} + +export default { + key: EpisodeService, + ctor: EpisodeServiceImpl +} satisfies ServiceDeclaration; \ No newline at end of file diff --git a/app/src/services/client/fetch.service.ts b/app/src/services/client/fetch.service.ts new file mode 100644 index 0000000..2ffee0c --- /dev/null +++ b/app/src/services/client/fetch.service.ts @@ -0,0 +1,66 @@ +import {ReadableGlobalContext} from "vue-mvvm"; + +import {ApiServiceBase} from "@services/utils/api"; +import {ServiceDeclaration} from "@services/declaration"; + +import {FetchService, Provider} from "@contracts/fetch.contract"; +import {ProviderService} from "@contracts/provider.contract"; + +import {EpisodeFetchModel, EpisodeProviderModel, type EpisodeModel} from "@models/episode.model"; +import {GenreFetchModel} from "@models/genre.model"; +import {SeasonFetchModel, type SeasonModel} from "@models/season.model"; +import {SeriesFetchModel, SeriesModel} from "@models/series.model"; + +import {DefaultProvider} from "@providers/default"; + +import {UnsupportedPlatformError} from "@utils/error"; +import {SyncStatus} from "@contracts/season.contract"; + +class FetchServiceImpl extends ApiServiceBase implements FetchService { + private readonly providerService: ProviderService; + + public constructor(ctx: ReadableGlobalContext) { + super(ctx); + + this.providerService = ctx.getService(ProviderService); + } + + getCatalog(_provider?: DefaultProvider | null): Promise { + throw new UnsupportedPlatformError("FetchServiceImpl.getCatalog"); + } + + getSeries(_guid: string, _provider?: DefaultProvider | null): Promise<[model: SeriesFetchModel, genres: GenreFetchModel[], previewImage: Uint8Array | null]> { + throw new UnsupportedPlatformError("FetchServiceImpl.getSeries"); + } + + getSeasons(_series: SeriesModel, _provider?: DefaultProvider | null): Promise { + throw new UnsupportedPlatformError("FetchServiceImpl.getSeasons"); + } + + getEpisodes(_guid: string, _seasonNumber: number, _provider?: DefaultProvider | null): Promise { + throw new UnsupportedPlatformError("FetchServiceImpl.getEpisodes"); + } + + async getProviders(_series: SeriesModel, _season: SeasonModel, episode: EpisodeModel, provider?: DefaultProvider | null): Promise<[SyncStatus, Provider[]]> { + provider ??= await this.providerService.getProvider(); + + const res: EpisodeProviderModel = await this.get(["api", provider.uniqueKey, "episodes", episode.episode_id, "providers"]); + + return [res.status, res.providers.map(provider => ({ + name: provider.name, + language: provider.language_code, + embeddedURL: provider.url + }))]; + } + + async startRemoteSyncing(seriesId: number, provider: DefaultProvider | null): Promise { + provider ??= await this.providerService.getProvider(); + + await this.post(["api", provider.uniqueKey, "series", seriesId, "sync"], null); + } +} + +export default { + key: FetchService, + ctor: FetchServiceImpl +} satisfies ServiceDeclaration; \ No newline at end of file diff --git a/app/src/services/client/genre.service.ts b/app/src/services/client/genre.service.ts new file mode 100644 index 0000000..95c4ea7 --- /dev/null +++ b/app/src/services/client/genre.service.ts @@ -0,0 +1,118 @@ +import {ReadableGlobalContext} from "vue-mvvm"; + +import {ServiceDeclaration} from "@services/declaration"; +import {ApiServiceBase} from "@services/utils/api"; + +import {GenreService} from "@contracts/genre.contract"; +import {ProviderService} from "@contracts/provider.contract"; + +import {GenreCreateModel, GenreDbModel, GenreModel, GenreToSeriesModel} from "@models/genre.model"; +import {SeriesModel} from "@models/series.model"; + +import {DefaultProvider} from "@providers/default"; + +import {HTTPError} from "@utils/http"; +import {UnsupportedPlatformError} from "@utils/error"; + +import * as AppEnv from "@AppEnv"; + +class GenreServiceImpl extends ApiServiceBase implements GenreService { + private readonly providerService: ProviderService; + + public constructor(ctx: ReadableGlobalContext) { + super(ctx); + + this.providerService = ctx.getService(ProviderService); + } + + public async getGenreByKey(key: string): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + try { + const genre: GenreDbModel = await this.get(["api", provider.uniqueKey, "genres", key, "key"]); + return GenreModel( + genre.genre_id, + genre.key + ); + } catch (e) { + if (e instanceof HTTPError && e.status == 404) { + return null; + } + + throw e; + } + } + + public async insertGenre(key: string): Promise { + if (!AppEnv.isTesting) { + throw new UnsupportedPlatformError("GenreServiceImpl.insertGenre"); + } + + const provider: DefaultProvider = await this.providerService.getProvider(); + + const genre: GenreDbModel = await this.post(["api", provider.uniqueKey, "genres"], { + key + }); + + return GenreModel(genre.genre_id, genre.key); + } + + public async insertGenreToSeries(genre: GenreModel, series: SeriesModel, mainGenre: boolean): Promise { + if (!AppEnv.isTesting) { + throw new UnsupportedPlatformError("GenreServiceImpl.insertGenreToSeries"); + } + + const provider: DefaultProvider = await this.providerService.getProvider(); + + await this.post<{}, GenreToSeriesModel>(["api", provider.uniqueKey, "genres", "series"], { + genre_id: genre.genre_id, + series_id: series.series_id, + main_genre: mainGenre + }); + } + + public async getGenres(): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const genres: GenreDbModel[] = await this.get(["api", provider.uniqueKey, "genres"]); + + return genres.map(genre => GenreModel( + genre.genre_id, + genre.key + )); + } + + public async getMainGenreOfSeries(seriesId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + try { + const genre: GenreDbModel = await this.get(["api", provider.uniqueKey, "genres", "series", seriesId, "main"]); + + return GenreModel( + genre.genre_id, + genre.key + ); + } catch (e) { + if (e instanceof HTTPError && e.status == 404) { + return null; + } + + throw e; + } + } + + public async getNonMainGenresOfSeries(seriesId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const genres: GenreDbModel[] = await this.get(["api", provider.uniqueKey, "genres", "series", seriesId]); + + return genres.map(genre => GenreModel( + genre.genre_id, + genre.key + )); + } +} + +export default { + key: GenreService, + ctor: GenreServiceImpl +} satisfies ServiceDeclaration; \ No newline at end of file diff --git a/app/src/services/client/list.service.ts b/app/src/services/client/list.service.ts new file mode 100644 index 0000000..df16fe0 --- /dev/null +++ b/app/src/services/client/list.service.ts @@ -0,0 +1,133 @@ +import {ReadableGlobalContext} from "vue-mvvm"; + +import {ServiceDeclaration} from "@services/declaration"; +import {ApiServiceBase} from "@services/utils/api"; + +import {ListService} from "@contracts/list.contract"; +import {ProviderService} from "@contracts/provider.contract"; + +import {SeriesDbModel, SeriesModel} from "@models/series.model"; +import {ListCreateModel, ListDbModel, ListModel} from "@models/list.model"; + +import {DefaultProvider} from "@providers/default"; + +import {HTTPError} from "@utils/http"; + +class ListServiceImpl extends ApiServiceBase implements ListService { + private readonly providerService: ProviderService; + + public constructor(ctx: ReadableGlobalContext) { + super(ctx); + + this.providerService = ctx.getService(ProviderService); + } + + public async getLists(): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const lists: ListDbModel[] = await this.get(["api", provider.uniqueKey, "lists"]); + + return lists.map(list => ListModel( + list.list_id, + list.name, + list.tenant_id + )); + } + + public async getList(listId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + try { + const list: ListDbModel = await this.get(["api", provider.uniqueKey, "lists", listId]); + + return ListModel( + list.list_id, + list.name, + list.tenant_id + ); + } catch (e) { + if (e instanceof HTTPError && e.status == 404) { + return null; + } + + throw e; + } + } + + public async createList(name: string): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const list: ListDbModel = await this.post(["api", provider.uniqueKey, "lists"], { + name + }); + + return ListModel( + list.list_id, + list.name, + list.tenant_id + ); + } + + public async updateList(listId: number, name: string): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + await this.put(["api", provider.uniqueKey, "lists", listId], { + name + }); + } + + public async deleteList(listId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + await this.delete<{}>(["api", provider.uniqueKey, "lists", listId]); + } + + public async getListsOfSeries(seriesId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const lists: ListDbModel[] = await this.get(["api", provider.uniqueKey, "lists", "series", seriesId]); + + return lists.map(list => ListModel( + list.list_id, + list.name, + list.tenant_id + )); + } + + public async getSeries(listId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const series: SeriesDbModel[] = await this.get(["api", provider.uniqueKey, "lists", listId, "series"]); + + return series.map(series => SeriesModel( + series.series_id, + series.guid, + series.title, + series.description, + series.preview_image + )); + } + + public async addSeriesToList(seriesId: number, listId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + await this.post<{}, null>(["api", provider.uniqueKey, "lists", listId, "series", seriesId], null); + } + + public async removeSeriesFromList(seriesId: number, listId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + await this.delete<{}>(["api", provider.uniqueKey, "lists", listId, "series", seriesId]); + } + + public async getPreviewHashes(listId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + return await this.get(["api", provider.uniqueKey, "lists", listId, "preview"]); + } +} + +export default { + key: ListService, + ctor: ListServiceImpl +} satisfies ServiceDeclaration \ No newline at end of file diff --git a/app/src/services/client/provider.service.ts b/app/src/services/client/provider.service.ts new file mode 100644 index 0000000..0f69fd7 --- /dev/null +++ b/app/src/services/client/provider.service.ts @@ -0,0 +1,88 @@ +import {ProfileModel} from "@models/profile.model"; + +import {DefaultProvider} from "@providers/default"; +import {AniWorldProvider} from "@providers/aniworld"; +import {StoProvider} from "@providers/sto"; + +import {ServiceDeclaration} from "@services/declaration"; + +import {ProviderService} from "@contracts/provider.contract"; + +import {UnsupportedPlatformError} from "@utils/error"; + +import * as AppEnv from "@AppEnv"; + +export class ProviderServiceImpl implements ProviderService { + private static readonly SESSION_KEY: string = "active-provider"; + + private provider: DefaultProvider | null = null; + + public readonly ANIWORLD: AniWorldProvider; + public readonly STO: StoProvider; + + public get ALL_PROVIDERS(): DefaultProvider[] { + return [this.ANIWORLD, this.STO]; + } + + public constructor() { + this.ANIWORLD = new AniWorldProvider(); + this.STO = new StoProvider(); + + this.provider = null; + } + + public async getProvider(): Promise { + if (this.provider) { + return this.provider; + } + + if (await this.loadCache()) { + return this.provider!; + } + + throw "No provider set and no provider was registered in the cache"; + } + + public async setProvider(provider: DefaultProvider): Promise { + this.provider = provider; + + if (!AppEnv.isTesting) { + sessionStorage.setItem(ProviderServiceImpl.SESSION_KEY, provider.uniqueKey); + } + } + + public async deleteProfile(_profile: ProfileModel): Promise { + throw new UnsupportedPlatformError("ProviderServiceImpl.deleteProfile"); + } + + private getProviderFromUniqueKey(key: string): DefaultProvider | null { + switch (key) { + case AniWorldProvider.UNIQUE_KEY: + return this.ANIWORLD; + case StoProvider.UNIQUE_KEY: + return this.STO; + } + + return null; + } + + private async loadCache(): Promise { + if (AppEnv.isTesting) { + return false; + } + + let value: string | null = sessionStorage.getItem(ProviderServiceImpl.SESSION_KEY); + if (!value) { + return false; + } + + this.provider = this.getProviderFromUniqueKey(value); + + return !!this.provider; + } +} + +export default { + key: ProviderService, + ctor: ProviderServiceImpl +} satisfies ServiceDeclaration; \ No newline at end of file diff --git a/app/src/services/client/resource.service.ts b/app/src/services/client/resource.service.ts new file mode 100644 index 0000000..e31a42e --- /dev/null +++ b/app/src/services/client/resource.service.ts @@ -0,0 +1,33 @@ +import {ReadableGlobalContext} from "vue-mvvm"; + +import {ServiceDeclaration} from "@services/declaration"; + +import {ResourceService} from "@contracts/resource.contract"; +import {ProviderService} from "@contracts/provider.contract"; + +import {DefaultProvider} from "@providers/default"; + +import {UnsupportedPlatformError} from "@utils/error"; + +class ResourceServiceImpl implements ResourceService { + private readonly providerService: ProviderService; + + public constructor(ctx: ReadableGlobalContext) { + this.providerService = ctx.getService(ProviderService); + } + + public async getResourceLocation(): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + // TODO replace with settings url + return `http://localhost:5000/api/${provider.uniqueKey}/resources/`; + } + + public async saveResource(_name: string, _data: Uint8Array): Promise { + throw new UnsupportedPlatformError("ResourceServiceImpl.saveResource"); + } +} + +export default { + key: ResourceService, + ctor: ResourceServiceImpl +} satisfies ServiceDeclaration; \ No newline at end of file diff --git a/app/src/services/client/season.service.ts b/app/src/services/client/season.service.ts new file mode 100644 index 0000000..8aa7c1d --- /dev/null +++ b/app/src/services/client/season.service.ts @@ -0,0 +1,93 @@ +import {ReadableGlobalContext} from "vue-mvvm"; + +import {ApiServiceBase} from "@services/utils/api"; +import {ServiceDeclaration} from "@services/declaration"; + +import {SeasonService, SyncInformation} from "@contracts/season.contract"; +import {ProviderService} from "@contracts/provider.contract"; + +import {SeasonCreateModel, SeasonDbModel, SeasonModel} from "@models/season.model"; +import {SeriesSyncModel} from "@models/series.model"; + +import {DefaultProvider} from "@providers/default"; + +import {HTTPError} from "@utils/http"; +import {UnsupportedPlatformError} from "@utils/error"; + +import * as AppEnv from "@AppEnv"; + +class SeasonServiceImpl extends ApiServiceBase implements SeasonService { + private readonly providerService: ProviderService; + + public constructor(ctx: ReadableGlobalContext) { + super(ctx); + + this.providerService = ctx.getService(ProviderService); + } + + public async getSyncStatus(seriesId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const model: SeriesSyncModel = await this.get(["api", provider.uniqueKey, "series", seriesId, "sync"]); + return { + requiresSync: model.requires_sync, + status: model.status + }; + } + + public async getSeason(seasonId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + try { + const season: SeasonDbModel = await this.get(["api", provider.uniqueKey, "seasons", seasonId]); + + return SeasonModel( + season.season_id, + season.series_id, + season.season_number + ); + } catch (e) { + if (e instanceof HTTPError && e.status == 404) { + return null; + } + + throw e; + } + } + + public async getSeasons(seriesId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const seasons: SeasonDbModel[] = await this.get(["api", provider.uniqueKey, "seasons", "series", seriesId]); + + return seasons.map(season => SeasonModel( + season.season_id, + season.series_id, + season.season_number + )); + } + + public async insertSeason(seriesId: number, seasonNumber: number): Promise { + if (!AppEnv.isTesting) { + throw new UnsupportedPlatformError("SeasonServiceImpl.insertSeason"); + } + + const provider: DefaultProvider = await this.providerService.getProvider(); + + const season: SeasonDbModel = await this.post(["api", provider.uniqueKey, "seasons"], { + series_id: seriesId, + season_number: seasonNumber + }); + + return SeasonModel( + season.season_id, + season.series_id, + season.season_number + ); + } +} + +export default { + key: SeasonService, + ctor: SeasonServiceImpl +} satisfies ServiceDeclaration; \ No newline at end of file diff --git a/app/src/services/client/series.service.ts b/app/src/services/client/series.service.ts new file mode 100644 index 0000000..4c6c533 --- /dev/null +++ b/app/src/services/client/series.service.ts @@ -0,0 +1,173 @@ +import {ReadableGlobalContext} from "vue-mvvm"; + +import {ApiServiceBase} from "@services/utils/api"; +import {ServiceDeclaration} from "@services/declaration"; + +import {SeriesService} from "@contracts/series.contract"; +import {ProviderService} from "@contracts/provider.contract"; + +import {SeriesCreateModel, SeriesDbModel, SeriesModel} from "@models/series.model"; + +import {DefaultProvider} from "@providers/default"; + +import {HTTPError} from "@utils/http"; +import {UnsupportedPlatformError} from "@utils/error"; + +import * as AppEnv from "@AppEnv"; + +interface SeriesFilterModel { + limit: number; + offset: number; + genre_ids: number[] | null; + search_text: string | null; +} + +class SeriesServiceImpl extends ApiServiceBase implements SeriesService { + private readonly providerService: ProviderService; + + public constructor(ctx: ReadableGlobalContext) { + super(ctx); + + this.providerService = ctx.getService(ProviderService); + } + + public async requiresSync(): Promise { + return false; + } + + public async existByGUID(guid: string): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + try { + await this.get(["api", provider.uniqueKey, "series", "guid", guid]); + return true; + } catch (e) { + if (e instanceof HTTPError && e.status == 404) { + return false; + } + + throw e; + } + } + + public async insertSeries(guid: string, title: string, description: string, previewImage: string | null): Promise { + if (!AppEnv.isTesting) { + throw new UnsupportedPlatformError("SeriesServiceImpl.insertSeries"); + } + + const provider: DefaultProvider = await this.providerService.getProvider(); + + const series: SeriesDbModel = await this.post(["api", provider.uniqueKey, "series"], { + guid, + title, + description, + preview_image: previewImage + }); + + return SeriesModel( + series.series_id, + series.guid, + series.title, + series.description, + series.preview_image + ); + } + + public async getSeriesChunk(offset: number, limit: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const series: SeriesDbModel[] = await this.post(["api", provider.uniqueKey, "series", "chunk"], { + offset: offset, + limit: limit, + genre_ids: null, + search_text: null + }); + + return series.map(series => SeriesModel( + series.series_id, + series.guid, + series.title, + series.description, + series.preview_image + )); + } + + public async getFilteredSeriesChunk(offset: number, limit: number, searchText: string, genresIds: number[]): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const series: SeriesDbModel[] = await this.post(["api", provider.uniqueKey, "series", "chunk"], { + offset: offset, + limit: limit, + genre_ids: genresIds.length > 0 ? genresIds : null, + search_text: searchText + }); + + return series.map(series => SeriesModel( + series.series_id, + series.guid, + series.title, + series.description, + series.preview_image + )); + } + + public async getSeries(seriesId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + try { + const series: SeriesDbModel = await this.get(["api", provider.uniqueKey, "series", seriesId]); + + return SeriesModel( + series.series_id, + series.guid, + series.title, + series.description, + series.preview_image + ); + } catch (e) { + if (e instanceof HTTPError && e.status == 404) { + return null; + } + + throw e; + } + } + + public async getStartedSeries(): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const series: SeriesDbModel[] = await this.get(["api", provider.uniqueKey, "series", "started"]); + + return series.map(series => SeriesModel( + series.series_id, + series.guid, + series.title, + series.description, + series.preview_image + )); + } + + public async getSeriesByIds(seriesIds: number[]): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const series: SeriesDbModel[] = await this.get({ + path: ["api", provider.uniqueKey, "series"], + query: { + seriesIds: seriesIds + } + }); + + return series.map(series => SeriesModel( + series.series_id, + series.guid, + series.title, + series.description, + series.preview_image + )); + } +} + +export default { + key: SeriesService, + ctor: SeriesServiceImpl +} satisfies ServiceDeclaration; \ No newline at end of file diff --git a/app/src/services/client/settings.service.ts b/app/src/services/client/settings.service.ts new file mode 100644 index 0000000..a29a534 --- /dev/null +++ b/app/src/services/client/settings.service.ts @@ -0,0 +1,175 @@ +import {Ref, readonly, ref} from "vue"; +import {ReadableGlobalContext} from "vue-mvvm"; + +import {ServiceDeclaration} from "@services/declaration"; + +import {I18nService, SupportedLocals} from "@contracts/i18n.contract"; +import {SettingsService} from "@contracts/settings.contract"; +import {UserService} from "@contracts/user.contract"; + +import {ProfileModel} from "@models/profile.model"; + +import * as AppEnv from "@AppEnv"; + +export class SettingsServiceImpl implements SettingsService { + private static readonly IGNORE_VERSION_KEY: string = "ignore-version"; + private static readonly UPDATES_ACTIVE_KEY: string = "updates-active"; + private static readonly HEALTHZ_KEY: string = "healthz"; + + private _ignoreVersion: Ref = ref(""); + private _updatesActive: Ref = ref(false); + private _healthz: Ref = ref(""); + + private readonly i18nService: I18nService; + private readonly userService: UserService; + + public get ignoreVersion(): Readonly> { + return readonly(this._ignoreVersion); + } + + public set ignoreVersion(v: string) { + this._ignoreVersion.value = v; + localStorage.setItem(SettingsServiceImpl.IGNORE_VERSION_KEY, v); + } + + public get updatesActive(): Readonly> { + return readonly(this._updatesActive); + } + + public set updatesActive(v: boolean) { + this._updatesActive.value = v; + localStorage.setItem(SettingsServiceImpl.UPDATES_ACTIVE_KEY, v ? "true" : "false"); + } + + public get healthz(): Readonly> { + return readonly(this._healthz); + } + + public set healthz(v: string) { + this._healthz.value = v; + localStorage.setItem(SettingsServiceImpl.HEALTHZ_KEY, v); + } + + public constructor(ctx: ReadableGlobalContext) { + this.i18nService = ctx.getService(I18nService); + this.userService = ctx.getService(UserService); + + if (AppEnv.isTesting) { + return; + } + + this.ignoreVersion = this.loadFromStorage(SettingsServiceImpl.IGNORE_VERSION_KEY, "0.0.0"); + this.updatesActive = this.loadFromStorage(SettingsServiceImpl.UPDATES_ACTIVE_KEY, "true") == "true"; + this.healthz = this.loadFromStorage(SettingsServiceImpl.HEALTHZ_KEY, "https://www.google.com/generate_204"); + + this.setThemeSession(this.getDefaultTheme()); + this.setLocalSession(this.getDefaultLocal()); + } + + public async setTheme(theme: string): Promise { + const profile: ProfileModel = await this.userService.getActiveProfile(); + profile.theme = theme; + this.setThemeSession(theme); + await this.updateProfile(profile); + } + + public setThemeSession(theme: string): void { + document.documentElement.setAttribute("data-theme", theme); + } + + public getDefaultTheme(): string { + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "aniworld-dark" + : "aniworld-light"; + } + + public async getTheme(): Promise { + const profile: ProfileModel | null = await this.userService.getActiveProfileOrDefault(); + if (!profile) { + return this.getDefaultTheme(); + } + + return profile.theme; + } + + public async setLocal(local: SupportedLocals): Promise { + const profile: ProfileModel = await this.userService.getActiveProfile(); + profile.lang = local; + this.setLocalSession(local); + await this.updateProfile(profile); + } + + public setLocalSession(local: SupportedLocals): void { + this.i18nService.setLocal(local); + } + + public getDefaultLocal(): SupportedLocals { + const lang: string = window.navigator.language; + if (lang != "en" && lang != "de") { + return "en"; + } + + return lang; + } + + public async getLocal(): Promise { + const profile: ProfileModel = await this.userService.getActiveProfile(); + if (!profile) { + return this.getDefaultLocal(); + } + + return profile.lang; + } + + public async setTosAccepted(value: boolean): Promise { + const profile: ProfileModel = await this.userService.getActiveProfile(); + profile.tos_accepted = value; + await this.updateProfile(profile); + } + + public async isTosAccepted(): Promise { + const profile: ProfileModel = await this.userService.getActiveProfile(); + return profile.tos_accepted; + } + + public async getAutoSyncCatalog(): Promise { + return false; + } + + public setAutoSyncCatalog(_sync: boolean): Promise { + throw new Error("Option is not allowed in client applications"); + } + + public async getImageVariant(name: string, extension: string): Promise { + return `/${name}/${await this.getTheme()}.${extension}`; + } + + public async loadProfileSettings(): Promise { + const profile: ProfileModel = await this.userService.getActiveProfile(); + this.setLocalSession(profile.lang); + this.setThemeSession(profile.theme); + } + + private async updateProfile(profile: ProfileModel): Promise { + await this.userService.updateProfile( + profile.profile_id, + profile.name, + profile.background_color, + profile.eye, + profile.mouth, + profile.theme, + profile.lang, + profile.tos_accepted, + profile.sync_catalog + ); + } + + private loadFromStorage(key: string, defaultValue: string): string { + return localStorage.getItem(key) ?? defaultValue; + } +} + +export default { + key: SettingsService, + ctor: SettingsServiceImpl +} satisfies ServiceDeclaration; \ No newline at end of file diff --git a/app/src/services/client/user.service.ts b/app/src/services/client/user.service.ts new file mode 100644 index 0000000..fcfdfbf --- /dev/null +++ b/app/src/services/client/user.service.ts @@ -0,0 +1,245 @@ +import {ReadableGlobalContext} from "vue-mvvm"; + +import * as dicebear from "@dicebear/core"; +import {botttsNeutral} from "@dicebear/collection"; + +import {ApiServiceBase} from "@services/utils/api"; +import {ServiceDeclaration} from "@services/declaration"; + +import {UserService} from "@contracts/user.contract"; +import {SupportedLocals} from "@contracts/i18n.contract"; +import {SettingsService} from "@contracts/settings.contract"; + +import { + ProfileApiModel, + ProfileCreateModel, + ProfileEye, + ProfileModel, + ProfileMouth, + ProfileUpdateModel +} from "@models/profile.model"; + +import * as http from "@utils/http"; +import {UnsupportedPlatformError} from "@utils/error"; + +import * as AppEnv from "@AppEnv"; + +interface LoginModel { + uuid: string; + password: string; +} + +export class UserServiceImpl extends ApiServiceBase implements UserService { + private static readonly SESSION_KEY: string = "active-profile"; + + private readonly ctx: ReadableGlobalContext; + + private activeProfile: ProfileModel | null = null; + + public constructor(ctx: ReadableGlobalContext) { + super(ctx); + + this.ctx = ctx; + } + + public async authenticate(profile: ProfileModel, password: string): Promise { + try { + await this.post(["api", "credentials", "login"], { + uuid: profile.uuid, + password + }); + + return true; + } catch (e) { + if (e instanceof http.HTTPError && e.status == 401) { + return false; + } + + throw e; + } + } + + public async logout(): Promise { + await this.get(["api", "credentials", "logout"]); + } + + public async getActiveProfile(): Promise { + const profile: ProfileModel | null = await this.getActiveProfileOrDefault(); + if (profile) { + return profile; + } + + throw "No active profile set and no profile was registered in the cache"; + } + + public async setActiveProfile(profile: ProfileModel): Promise { + this.activeProfile = profile; + sessionStorage.setItem(UserServiceImpl.SESSION_KEY, profile.uuid); + + // Lazy load to prevent circular dependency + const settingsService: SettingsService = this.ctx.getService(SettingsService); + await settingsService.loadProfileSettings(); + } + + public async getActiveProfileOrDefault(): Promise { + if (this.activeProfile) { + return this.activeProfile; + } + + if (await this.loadCache()) { + return this.activeProfile!; + } + + return null; + } + + public getMigrationProfile(): Promise { + throw new UnsupportedPlatformError("UserServiceImpl.getMigrationProfile"); + } + + public async getProfiles(): Promise { + const rows: ProfileApiModel[] = await this.get(["api", "profiles"]); + + return rows.map(row => ProfileModel( + row.profile_id, + row.uuid, + row.name, + row.background_color, + row.eye, + row.mouth, + row.theme, + row.lang, + row.tos_accepted, + row.sync_catalog + )); + } + + public async getProfileByUUID(uuid: string): Promise { + try { + const row: ProfileApiModel = await this.get(["api", "profiles", uuid, "uuid"]); + + return ProfileModel( + row.profile_id, + row.uuid, + row.name, + row.background_color, + row.eye, + row.mouth, + row.theme, + row.lang, + row.tos_accepted, + row.sync_catalog + ); + } catch (e) { + console.log(e); + if (e instanceof http.HTTPError && e.status == 404) { + return null; + } + throw e; + } + } + + public async requiresProfileSetup(): Promise { + return false; + } + + public async createProfile( + name: string, + backgroundColor: string, + eye: ProfileEye, + mouth: ProfileMouth, + theme: string, + local: SupportedLocals + ): Promise { + if (!AppEnv.isTesting) { + throw new UnsupportedPlatformError("UserServiceImpl.createProfile"); + } + + const row: ProfileApiModel = await this.post(["api", "profiles"], { + name, + password: "", + password_salt: "", + background_color: backgroundColor, + eye, + mouth, + theme, + lang: local + }); + + return ProfileModel( + row.profile_id, + row.uuid, + row.name, + row.background_color, + row.eye, + row.mouth, + row.theme, + row.lang, + row.tos_accepted, + row.sync_catalog + ); + } + + public async updateProfile( + profileId: number, + name: string, + backgroundColor: string, + eye: ProfileEye, + mouth: ProfileMouth, + theme: string, + local: SupportedLocals, + tosAccepted: boolean, + _syncCatalog: boolean + ): Promise { + await this.put(["api", "profiles", profileId], { + name, + background_color: backgroundColor, + eye, + mouth, + theme, + lang: local, + tos_accepted: tosAccepted + }); + } + + public deleteProfile(_profile: ProfileModel): Promise { + throw new UnsupportedPlatformError("UserServiceImpl.deleteProfile"); + } + + public getAvatarSvg(backgroundColor: string, eye: ProfileEye, mouth: ProfileMouth): string { + const result: dicebear.Result = dicebear.createAvatar(botttsNeutral, { + backgroundColor: [backgroundColor], + eyes: [eye], + mouth: [mouth] + }); + + return result.toDataUri(); + } + + public getAvatarSvgOfProfile(profile: ProfileModel): string { + return this.getAvatarSvg(profile.background_color, profile.eye, profile.mouth); + } + + private async loadCache(): Promise { + let value: string | null = sessionStorage.getItem(UserServiceImpl.SESSION_KEY); + if (!value) { + return false; + } + + this.activeProfile = await this.getProfileByUUID(value); + if (this.activeProfile) { + // Lazy load to prevent circilar dependency + const settingsService: SettingsService = this.ctx.getService(SettingsService); + await settingsService.loadProfileSettings(); + + return true; + } + + return false; + } +} + +export default { + key: UserService, + ctor: UserServiceImpl +} satisfies ServiceDeclaration; \ No newline at end of file diff --git a/app/src/services/client/utils/api.ts b/app/src/services/client/utils/api.ts new file mode 100644 index 0000000..f9e35b1 --- /dev/null +++ b/app/src/services/client/utils/api.ts @@ -0,0 +1,29 @@ +import {ReadableGlobalContext} from "vue-mvvm"; + +import {ApiService, PathParameter} from "@contracts/client/api.contract"; + +export class ApiServiceBase { + private readonly apiService: ApiService; + // protected readonly settingsService: SettingsService; + + protected constructor(ctx: ReadableGlobalContext) { + this.apiService = ctx.getService(ApiService); + // this.settingsService = ctx.getService(SettingsService); + } + + protected async get(def: PathParameter): Promise { + return this.apiService.get(def); + } + + protected async post(def: PathParameter, body: Body): Promise { + return this.apiService.post(def, body); + } + + protected async put(def: PathParameter, body: Body): Promise { + return this.apiService.put(def, body); + } + + protected async delete(def: PathParameter): Promise { + return this.apiService.delete(def); + } +} \ No newline at end of file diff --git a/app/src/services/client/watchlist.service.ts b/app/src/services/client/watchlist.service.ts new file mode 100644 index 0000000..bbb3ebe --- /dev/null +++ b/app/src/services/client/watchlist.service.ts @@ -0,0 +1,59 @@ +import {ReadableGlobalContext} from "vue-mvvm"; + +import {ServiceDeclaration} from "@services/declaration"; +import {ApiServiceBase} from "@services/utils/api"; + +import {WatchlistService} from "@contracts/watchlist.contract"; +import {ProviderService} from "@contracts/provider.contract"; + +import {DefaultProvider} from "@providers/default"; + +import * as http from "@utils/http"; + +class WatchlistServiceImpl extends ApiServiceBase implements WatchlistService { + private readonly providerService: ProviderService; + + public constructor(ctx: ReadableGlobalContext) { + super(ctx); + + this.providerService = ctx.getService(ProviderService); + } + + public async getSeriesIds(): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + return this.get(["api", provider.uniqueKey, "watchlist", "series"]); + } + + public async isSeriesOnWatchlist(seriesIds: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + try { + await this.get(["api", provider.uniqueKey, "watchlist", "series", seriesIds]); + return true; + } catch (e) { + if (e instanceof http.HTTPError && e.status == 404) { + return false; + } + + throw e; + } + } + + public async addToWatchlist(seriesId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + await this.post(["api", provider.uniqueKey, "watchlist", "series", seriesId], null); + } + + public async removeFromWatchlist(seriesId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + await this.delete(["api", provider.uniqueKey, "watchlist", "series", seriesId]); + } +} + +export default { + key: WatchlistService, + ctor: WatchlistServiceImpl +} satisfies ServiceDeclaration; \ No newline at end of file diff --git a/app/src/services/client/watchtime.service.ts b/app/src/services/client/watchtime.service.ts new file mode 100644 index 0000000..fe51c37 --- /dev/null +++ b/app/src/services/client/watchtime.service.ts @@ -0,0 +1,132 @@ +import {ReadableGlobalContext} from "vue-mvvm"; + +import {ServiceDeclaration} from "@services/declaration"; +import {ApiServiceBase} from "@services/utils/api"; + +import {WatchtimeService} from "@contracts/watchtime.contract"; +import {ProviderService} from "@contracts/provider.contract"; + +import { + TotalProgressionModel, + WatchtimeCreateModel, + WatchtimeDbModel, + WatchtimeModel, + WatchtimeUpdateModel +} from "@models/watchtime.model"; + +import {DefaultProvider} from "@providers/default"; + +import * as http from "@utils/http"; + +class WatchtimeServiceImpl extends ApiServiceBase implements WatchtimeService { + private readonly providerService: ProviderService + + public constructor(ctx: ReadableGlobalContext) { + super(ctx); + + this.providerService = ctx.getService(ProviderService); + } + + public async getWatchtimeOfEpisode(episodeId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + try { + const watchtime: WatchtimeDbModel = await this.get(["api", provider.uniqueKey, "watchtime", episodeId, "episode"]) + + return WatchtimeModel( + watchtime.watchtime_id, + watchtime.episode_id, + watchtime.percentage_watched, + watchtime.stopped_time, + watchtime.tenant_id + ); + } catch (e) { + if (e instanceof http.HTTPError && e.status == 404) { + return null; + } + + throw e; + } + } + + public async getWatchtimesOfSeries(seriesId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const watchTimes: WatchtimeDbModel[] = await this.get(["api", provider.uniqueKey, "watchtime", seriesId, "series"]); + + return watchTimes.map(watchtime => WatchtimeModel( + watchtime.watchtime_id, + watchtime.episode_id, + watchtime.percentage_watched, + watchtime.stopped_time, + watchtime.tenant_id + )); + } + + public async getTotalWatchProgression(seriesId: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const total: TotalProgressionModel = await this.get(["api", provider.uniqueKey, "watchtime", seriesId, "total"]); + + return total.total_progression; + } + + public async createWatchtimeOfEpisode(episodeId: number, percentageWatched: number, stoppedTime: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + const watchtime: WatchtimeDbModel = await this.post(["api", provider.uniqueKey, "watchtime"], { + episode_id: episodeId, + percentage_watched: percentageWatched, + stopped_time: stoppedTime + }); + + return WatchtimeModel( + watchtime.watchtime_id, + watchtime.episode_id, + watchtime.percentage_watched, + watchtime.stopped_time, + watchtime.tenant_id + ) + } + + public async updateWatchtime(watchtimeId: number, percentageWatched: number, stoppedTime: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + await this.put(["api", provider.uniqueKey, "watchtime", watchtimeId], { + percentage_watched: percentageWatched, + stopped_time: stoppedTime + }); + } + + public async updateWatchtimeWithEpisode(episodeId: number, percentageWatched: number, stoppedTime: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + await this.put(["api", provider.uniqueKey, "watchtime", episodeId, "episode"], { + percentage_watched: percentageWatched, + stopped_time: stoppedTime + }); + } + + public async updateWatchtimesOfSeason(seasonId: number, percentageWatched: number, stoppedTime: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + await this.put(["api", provider.uniqueKey, "watchtime", seasonId, "season"], { + percentage_watched: percentageWatched, + stopped_time: stoppedTime + }); + } + + public async updateWatchtimesOfSeries(seriesId: number, percentageWatched: number, stoppedTime: number): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + await this.put(["api", provider.uniqueKey, "watchtime", seriesId, "series"], { + percentage_watched: percentageWatched, + stopped_time: stoppedTime + }); + } +} + +export default { + key: WatchtimeService, + ctor: WatchtimeServiceImpl +} satisfies ServiceDeclaration; \ No newline at end of file diff --git a/src/services/shared/changelog.service.ts b/app/src/services/shared/changelog.service.ts similarity index 100% rename from src/services/shared/changelog.service.ts rename to app/src/services/shared/changelog.service.ts diff --git a/src/services/shared/declaration.ts b/app/src/services/shared/declaration.ts similarity index 100% rename from src/services/shared/declaration.ts rename to app/src/services/shared/declaration.ts diff --git a/src/services/shared/i18n.service.ts b/app/src/services/shared/i18n.service.ts similarity index 100% rename from src/services/shared/i18n.service.ts rename to app/src/services/shared/i18n.service.ts diff --git a/src/services/shared/report.service.ts b/app/src/services/shared/report.service.ts similarity index 90% rename from src/services/shared/report.service.ts rename to app/src/services/shared/report.service.ts index 528510a..f5e6ed1 100644 --- a/src/services/shared/report.service.ts +++ b/app/src/services/shared/report.service.ts @@ -11,6 +11,8 @@ import {ServiceDeclaration} from "@services/declaration"; import {ReportControlModel, ReportResult} from "@controls/ReportControl.model"; +import * as AppEnv from "@AppEnv"; + import * as packageJSON from "@/../package.json"; class ReportServiceImpl implements ReportService { @@ -19,6 +21,10 @@ class ReportServiceImpl implements ReportService { public constructor(ctx: ReadableGlobalContext) { this.ctx = ctx; + if (AppEnv.isTesting) { + return; + } + window.addEventListener("unhandledrejection", async ev => { await this.handleFatalError(ev.reason || "Unhandled Rejection"); }); @@ -70,8 +76,21 @@ class ReportServiceImpl implements ReportService { private async generateMarkdown(title: string, stack: string, userMessage: string): Promise { const settings: SettingsService = this.ctx.getService(SettingsService); - const theme: string = await settings.getTheme(); - const local: string = await settings.getLocal(); + + let theme: string; + let local: string; + + try { + theme = await settings.getTheme(); + } catch { + theme = "Unknown"; + } + + try { + local = await settings.getLocal(); + } catch { + local = "Unknown"; + } return `## Fatal Error: ${this.sanitizeForMarkdown(title)} diff --git a/src/services/standalone/update.service.ts b/app/src/services/shared/update.service.ts similarity index 100% rename from src/services/standalone/update.service.ts rename to app/src/services/shared/update.service.ts diff --git a/app/src/services/standalone/db/db.service.ts b/app/src/services/standalone/db/db.service.ts new file mode 100644 index 0000000..00b75da --- /dev/null +++ b/app/src/services/standalone/db/db.service.ts @@ -0,0 +1,47 @@ +import {ReadableGlobalContext} from "vue-mvvm"; + +import {DbService} from "@contracts/standalone/db.contract"; +import {MetadataDbService} from "@contracts/standalone/metadata.contract"; + +import {DbSession} from "@services/utils/db"; +import {ServiceDeclaration} from "@services/declaration"; + +import {DefaultProvider} from "@providers/default"; + +class DbServiceImpl implements DbService { + private readonly metadataDbService: MetadataDbService; + + private readonly sessions: Map; + + + public constructor(ctx: ReadableGlobalContext) { + this.metadataDbService = ctx.getService(MetadataDbService); + + this.sessions = new Map(); + } + + public async getDatabase(provider: DefaultProvider): Promise { + let session: DbSession | undefined = this.sessions.get(provider.uniqueKey); + if (!session || session.closed) { + const dbFile: string = await provider.getDatabaseFile(); + + session = await this.metadataDbService.openDB(dbFile, provider.uniqueKey); + + this.sessions.set(provider.uniqueKey, session); + } + + return session; + } + + public async closeDatabase(provider: DefaultProvider): Promise { + const session: DbSession | undefined = this.sessions.get(provider.uniqueKey); + if (session) { + await session.close(); + } + } +} + +export default { + key: DbService, + ctor: DbServiceImpl +} satisfies ServiceDeclaration; \ No newline at end of file diff --git a/src/services/standalone/db/metadata.service.ts b/app/src/services/standalone/db/metadata.service.ts similarity index 52% rename from src/services/standalone/db/metadata.service.ts rename to app/src/services/standalone/db/metadata.service.ts index adc02ee..92a688e 100644 --- a/src/services/standalone/db/metadata.service.ts +++ b/app/src/services/standalone/db/metadata.service.ts @@ -11,7 +11,13 @@ import {UserService} from "@contracts/user.contract"; import {ProfileModel} from "@models/profile.model"; -class MetadataDbServiceImpl implements MetadataDbService { +import sql1 from "../../../../../migration/standalone/metadata/1.sql?raw"; +import sql2 from "../../../../../migration/standalone/metadata/2.sql?raw"; +import sql3 from "../../../../../migration/standalone/metadata/3.sql?raw"; +import sql4 from "../../../../../migration/standalone/metadata/4.sql?raw"; +import sql5 from "../../../../../migration/standalone/metadata/5.sql?raw"; + +export class MetadataDbServiceImpl implements MetadataDbService { private readonly userService: UserService; public constructor(ctx: ReadableGlobalContext) { @@ -27,7 +33,7 @@ class MetadataDbServiceImpl implements MetadataDbService { return session; } - private async beginMigration(session: DbSession, provider: string): Promise { + protected async beginMigration(session: DbSession, provider: string): Promise { // language=SQLite const [{user_version: currentVersion}] = await session.query { - // language=SQLite - await session.execute(` -PRAGMA user_version = 1; - -CREATE TABLE series -( - series_id INTEGER PRIMARY KEY AUTOINCREMENT, - guid TEXT UNIQUE NOT NULL, - title TEXT NOT NULL, - description TEXT NOT NULL, - preview_image TEXT UNIQUE -); - -CREATE TABLE season -( - season_id INTEGER PRIMARY KEY AUTOINCREMENT, - series_id INTEGER NOT NULL, - season_number INTEGER NOT NULL, - FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT -); - -CREATE TABLE episode -( - episode_id INTEGER PRIMARY KEY AUTOINCREMENT, - season_id INTEGER NOT NULL, - episode_number INTEGER NOT NULL, - german_title TEXT NOT NULL, - english_title TEXT NOT NULL, - description TEXT NOT NULL, - percentage_watched INTEGER NOT NULL, - stopped_time INTEGER NOT NULL, - FOREIGN KEY (season_id) REFERENCES season (season_id) ON DELETE RESTRICT -); - -CREATE TABLE genre -( - genre_id INTEGER PRIMARY KEY AUTOINCREMENT, - key TEXT UNIQUE NOT NULL -); - -CREATE TABLE genre_to_series -( - genre_to_series_id INTEGER PRIMARY KEY AUTOINCREMENT, - genre_id INTEGER NOT NULL, - series_id INTEGER NOT NULL, - main_genre BOOLEAN NOT NULL, - FOREIGN KEY (genre_id) REFERENCES genre (genre_id) ON DELETE RESTRICT, - FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT -); - `); + await session.execute(sql1); } } @@ -138,15 +95,7 @@ class DbVersion2 implements MetadataDbVersion { public version: number = 2; public async migrate(session: DbSession, _userService: UserService, _provider: string): Promise { - // language=SQLite - await session.execute(` -CREATE TABLE watchlist -( - watchlist_id INTEGER PRIMARY KEY AUTOINCREMENT, - series_id INTEGER UNIQUE NOT NULL, - FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT -); - `); + await session.execute(sql2); } } @@ -158,55 +107,7 @@ class DbVersion3 implements MetadataDbVersion { public async migrate(session: DbSession, userService: UserService, _provider: string): Promise { const profile: ProfileModel = await userService.getMigrationProfile(); - // language=SQLite - await session.execute(` -CREATE TABLE watchlist_new -( - watchlist_id INTEGER PRIMARY KEY AUTOINCREMENT, - series_id INTEGER UNIQUE NOT NULL, - tenant_id TEXT NOT NULL, - FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT -); - -INSERT INTO watchlist_new (watchlist_id, series_id, tenant_id) -SELECT watchlist_id, series_id, ? FROM watchlist; - -DROP TABLE watchlist; - -ALTER TABLE watchlist_new RENAME TO watchlist; - -CREATE TABLE episode_new -( - episode_id INTEGER PRIMARY KEY AUTOINCREMENT, - season_id INTEGER NOT NULL, - episode_number INTEGER NOT NULL, - german_title TEXT NOT NULL, - english_title TEXT NOT NULL, - description TEXT NOT NULL, - FOREIGN KEY (season_id) REFERENCES season (season_id) ON DELETE RESTRICT -); - -INSERT INTO episode_new (episode_id, season_id, episode_number, german_title, english_title, description) -SELECT episode_id, season_id, episode_number, german_title, english_title, description FROM episode; - -ALTER TABLE episode RENAME TO episode_old; -ALTER TABLE episode_new RENAME TO episode; - -CREATE TABLE watchtime -( - watchtime_id INTEGER PRIMARY KEY AUTOINCREMENT, - episode_id INTEGER NOT NULL, - percentage_watched INTEGER NOT NULL, - stopped_time INTEGER NOT NULL, - tenant_id TEXT NOT NULL, - FOREIGN KEY (episode_id) REFERENCES episode (episode_id) ON DELETE CASCADE -); - -INSERT INTO watchtime (episode_id, percentage_watched, stopped_time, tenant_id) -SELECT episode_id, percentage_watched, stopped_time, ? FROM episode_old; - -DROP TABLE episode_old; - `, profile.uuid, profile.uuid); + await session.execute(sql3, profile.uuid, profile.uuid); } } @@ -215,24 +116,16 @@ class DbVersion4 implements MetadataDbVersion { public version: number = 4; public async migrate(session: DbSession, _userService: UserService, _provider: string): Promise { - // language=SQLite - await session.execute(` -CREATE TABLE list -( - list_id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - tenant_id TEXT NOT NULL -); - -CREATE TABLE list_to_series -( - list_id INTEGER NOT NULL, - series_id INTEGER NOT NULL, - PRIMARY KEY (list_id, series_id), - FOREIGN KEY (list_id) REFERENCES list (list_id) ON DELETE CASCADE, - FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT -); - `); + await session.execute(sql4); + } +} + +class DbVersion5 implements MetadataDbVersion { + public previousVersion: MetadataDbVersionConstructor = DbVersion4; + public version: number = 4; + + public async migrate(session: DbSession, _userService: UserService, _provider: string): Promise { + await session.execute(sql5); } } @@ -240,7 +133,7 @@ CREATE TABLE list_to_series // END MIGRATION // // ================================================================================================================== // -const LATEST_VERSION: Exclude = DbVersion4; +const LATEST_VERSION: Exclude = DbVersion5; export default { key: MetadataDbService, diff --git a/src/services/standalone/db/user.service.ts b/app/src/services/standalone/db/user.service.ts similarity index 69% rename from src/services/standalone/db/user.service.ts rename to app/src/services/standalone/db/user.service.ts index b7b4765..875f21c 100644 --- a/src/services/standalone/db/user.service.ts +++ b/app/src/services/standalone/db/user.service.ts @@ -5,6 +5,10 @@ import {UserDbService} from "@contracts/standalone/user.contract"; import {ServiceDeclaration} from "@services/declaration"; import {DbSession, DbVersion, DbVersionConstructor} from "@services/utils/db"; +import sql1 from "../../../../../migration/standalone/profile/1.sql?raw"; +import sql2 from "../../../../../migration/standalone/profile/2.sql?raw"; +import sql3 from "../../../../../migration/standalone/profile/3.sql?raw"; + export class UserDbServiceImpl implements UserDbService { public constructor() { } @@ -18,7 +22,7 @@ export class UserDbServiceImpl implements UserDbService { return session; } - private async beginMigration(session: DbSession): Promise { + protected async beginMigration(session: DbSession): Promise { const [{user_version: currentVersion}] = await session.query>("PRAGMA user_version"); @@ -66,23 +70,7 @@ class DbVersion1 implements UserDbVersion { } public async migrate(session: DbSession): Promise { - // language=SQLite - await session.execute(` -PRAGMA user_version = 1; - -CREATE TABLE profile -( - profile_id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT UNIQUE NOT NULL, - name TEXT NOT NULL, - background_color TEXT NOT NULL, - eye TEXT NOT NULL, - mouth TEXT NOT NULL, - theme TEXT NOT NULL, - lang TEXT NOT NULL, - tos_accepted BOOLEAN NOT NULL -); - `); + await session.execute(sql1); } } @@ -94,29 +82,19 @@ class DbVersion2 implements UserDbVersion { } public async migrate(session: DbSession): Promise { - // language=SQLite - await session.execute(` -CREATE TABLE profile_new -( - profile_id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT UNIQUE NOT NULL, - name TEXT NOT NULL, - background_color TEXT NOT NULL, - eye TEXT NOT NULL, - mouth TEXT NOT NULL, - theme TEXT NOT NULL, - lang TEXT NOT NULL, - tos_accepted BOOLEAN NOT NULL, - sync_catalog BOOLEAN NOT NULL -); - -INSERT INTO profile_new (profile_id, uuid, name, background_color, eye, mouth, theme, lang, tos_accepted, sync_catalog) -SELECT profile_id, uuid, name, background_color, eye, mouth, theme, lang, tos_accepted, false FROM profile; - -DROP TABLE profile; - -ALTER TABLE profile_new RENAME TO profile; - `); + await session.execute(sql2); + } +} + +class DbVersion3 implements UserDbVersion { + public previousVersion: UserDbVersionConstructor = DbVersion2; + public version: number = 3; + + public constructor() { + } + + public async migrate(session: DbSession): Promise { + await session.execute(sql3); } } @@ -124,7 +102,7 @@ ALTER TABLE profile_new RENAME TO profile; // END MIGRATION // // ================================================================================================================== // -const LATEST_VERSION: Exclude = DbVersion2; +const LATEST_VERSION: Exclude = DbVersion3; export default { key: UserDbService, diff --git a/src/services/standalone/episode.service.ts b/app/src/services/standalone/episode.service.ts similarity index 90% rename from src/services/standalone/episode.service.ts rename to app/src/services/standalone/episode.service.ts index 5618707..81b8cee 100644 --- a/src/services/standalone/episode.service.ts +++ b/app/src/services/standalone/episode.service.ts @@ -15,7 +15,7 @@ class EpisodeServiceImpl extends DbServiceBase implements EpisodeService { } public async getEpisode(episodeId: number): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase(); const rows: EpisodeDbModel[] = await session.query("SELECT * FROM episode WHERE episode_id = ?;", episodeId); @@ -34,7 +34,7 @@ class EpisodeServiceImpl extends DbServiceBase implements EpisodeService { } public async getEpisodes(seasonId: number): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() const rows: EpisodeDbModel[] = await session.query("SELECT * FROM episode WHERE season_id = ?;", seasonId); @@ -54,7 +54,7 @@ class EpisodeServiceImpl extends DbServiceBase implements EpisodeService { english_title: string, description: string ): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() const result: QueryResult = await session.execute( "INSERT INTO episode (season_id, episode_number, german_title, english_title, description) VALUES (?, ?, ?, ?, ?);", @@ -74,7 +74,7 @@ class EpisodeServiceImpl extends DbServiceBase implements EpisodeService { english_title: string, description: string ): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() await session.execute( "UPDATE episode SET german_title = ?, english_title = ?, description = ? WHERE episode_id = ?;", diff --git a/src/services/standalone/fetch.service.ts b/app/src/services/standalone/fetch.service.ts similarity index 72% rename from src/services/standalone/fetch.service.ts rename to app/src/services/standalone/fetch.service.ts index 2fc441b..fc68592 100644 --- a/src/services/standalone/fetch.service.ts +++ b/app/src/services/standalone/fetch.service.ts @@ -8,10 +8,12 @@ import {ServiceDeclaration} from "@services/declaration"; import {SeriesFetchModel, SeriesModel} from "@models/series.model"; import {GenreFetchModel} from "@models/genre.model"; -import {SeasonFetchModel} from "@models/season.model"; -import {EpisodeFetchModel} from "@models/episode.model"; +import {SeasonFetchModel, type SeasonModel} from "@models/season.model"; +import {EpisodeFetchModel, type EpisodeModel} from "@models/episode.model"; import {DefaultProvider, IInformationFetcher} from "@providers/default"; +import {UnsupportedPlatformError} from "@utils/error"; +import {SyncStatus} from "@contracts/season.contract"; class FetchServiceImpl implements FetchService { private readonly providerService: ProviderService; @@ -27,7 +29,7 @@ class FetchServiceImpl implements FetchService { return await fetcher.getCatalog(); } - public async getSeries(guid: string, provider: DefaultProvider | null = null): Promise<[model: SeriesFetchModel, genres: GenreFetchModel[]]> { + public async getSeries(guid: string, provider: DefaultProvider | null = null): Promise<[model: SeriesFetchModel, genres: GenreFetchModel[], previewImage: Uint8Array | null]> { provider ??= await this.providerService.getProvider(); const fetcher: IInformationFetcher = provider.getFetcher(); @@ -48,11 +50,15 @@ class FetchServiceImpl implements FetchService { return await fetcher.getEpisodes(guid, seasonNumber); } - public async getProviders(guid: string, seasonNumber: number, episodeNumber: number, provider: DefaultProvider | null = null): Promise { + public async getProviders(series: SeriesModel, season: SeasonModel, episode: EpisodeModel, provider: DefaultProvider | null = null): Promise<[SyncStatus, Provider[]]> { provider ??= await this.providerService.getProvider(); const fetcher: IInformationFetcher = provider.getFetcher(); - return await fetcher.fetchProviders(guid, seasonNumber, episodeNumber); + return [SyncStatus.Completed, await fetcher.fetchProviders(series.guid, season.season_number, episode.episode_number)]; + } + + public async startRemoteSyncing(_seriesId: number, _provider?: DefaultProvider | null): Promise { + throw new UnsupportedPlatformError("FetchServiceImpl.startRemoteSyncing"); } } diff --git a/src/services/standalone/genre.service.ts b/app/src/services/standalone/genre.service.ts similarity index 87% rename from src/services/standalone/genre.service.ts rename to app/src/services/standalone/genre.service.ts index 9327eb6..9f7007b 100644 --- a/src/services/standalone/genre.service.ts +++ b/app/src/services/standalone/genre.service.ts @@ -16,7 +16,7 @@ class GenreServiceImpl extends DbServiceBase implements GenreService { } public async getGenreByKey(key: string): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() const rows: GenreDbModel[] = await session.query("SELECT * FROM genre WHERE key = ? LIMIT 1;", key); if (rows.length == 0) { @@ -27,7 +27,7 @@ class GenreServiceImpl extends DbServiceBase implements GenreService { } public async insertGenre(key: string): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() const result: QueryResult = await session.execute("INSERT INTO genre (key) VALUES (?)", key); @@ -43,20 +43,20 @@ class GenreServiceImpl extends DbServiceBase implements GenreService { throw "Genre is not tracked, you have to insert it first"; } - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() await session.execute("INSERT INTO genre_to_series (genre_id, series_id, main_genre) VALUES (?, ?, ?)", genre.genre_id, series.series_id, main_genre); } public async getGenres(): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() const rows: GenreDbModel[] = await session.query("SELECT * FROM genre ORDER BY key"); return rows.map(row => GenreModel(row.genre_id, row.key)); } public async getMainGenreOfSeries(seriesId: number): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() const rows: GenreDbModel[] = await session.query("SELECT g.* FROM genre_to_series AS gs LEFT JOIN genre AS g ON gs.genre_id = g.genre_id WHERE gs.series_id = ? AND gs.main_genre = 'true' LIMIT 1;", seriesId); if (rows.length == 0) { @@ -67,7 +67,7 @@ class GenreServiceImpl extends DbServiceBase implements GenreService { } public async getNonMainGenresOfSeries(seriesId: number): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() const rows: GenreDbModel[] = await session.query("SELECT g.* FROM genre_to_series AS gs LEFT JOIN genre AS g ON gs.genre_id = g.genre_id WHERE gs.series_id = ? AND gs.main_genre = 'false';", seriesId); return rows.map(row => GenreModel(row.genre_id, row.key)); diff --git a/src/services/standalone/list.service.ts b/app/src/services/standalone/list.service.ts similarity index 87% rename from src/services/standalone/list.service.ts rename to app/src/services/standalone/list.service.ts index 8b8103f..19b8a0d 100644 --- a/src/services/standalone/list.service.ts +++ b/app/src/services/standalone/list.service.ts @@ -23,7 +23,7 @@ class ListServiceImpl extends DbServiceBase implements ListService { public async getLists(): Promise { const profile: ProfileModel = await this.userService.getActiveProfile(); - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() // language=SQLite const rows: ListDbModel[] = await session.query( @@ -40,7 +40,7 @@ class ListServiceImpl extends DbServiceBase implements ListService { public async getListsOfSeries(seriesId: number): Promise { const profile: ProfileModel = await this.userService.getActiveProfile(); - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() // language=SQLite const rows: ListDbModel[] = await session.query(` @@ -58,7 +58,7 @@ class ListServiceImpl extends DbServiceBase implements ListService { } public async getList(listId: number): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() // language=SQLite const rows: ListDbModel[] = await session.query( @@ -79,7 +79,7 @@ class ListServiceImpl extends DbServiceBase implements ListService { public async createList(name: string): Promise { const profile: ProfileModel = await this.userService.getActiveProfile(); - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() // language=SQLite const result: QueryResult = await session.execute( @@ -96,7 +96,7 @@ class ListServiceImpl extends DbServiceBase implements ListService { } public async updateList(listId: number, name: string): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() // language=SQLite await session.execute( @@ -107,7 +107,7 @@ class ListServiceImpl extends DbServiceBase implements ListService { } public async deleteList(listId: number): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() // language=SQLite await session.execute( @@ -117,7 +117,7 @@ class ListServiceImpl extends DbServiceBase implements ListService { } public async getSeries(listId: number): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() // language=SQLite const rows: SeriesDbModel[] = await session.query(` @@ -137,21 +137,21 @@ class ListServiceImpl extends DbServiceBase implements ListService { } public async addSeriesToList(seriesId: number, listId: number): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() // language=SQLite await session.execute("INSERT INTO list_to_series (list_id, series_id) VALUES (?, ?)", listId, seriesId); } public async removeSeriesFromList(seriesId:number, listId:number): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() // language=SQLite await session.execute("DELETE FROM list_to_series WHERE list_id = ? AND series_id = ?", listId, seriesId); } public async getPreviewHashes(listId: number): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() // language=SQLite const rows: Array<{ preview_image: string }> = await session.query(` diff --git a/src/services/standalone/provider.service.ts b/app/src/services/standalone/provider.service.ts similarity index 75% rename from src/services/standalone/provider.service.ts rename to app/src/services/standalone/provider.service.ts index 46eb429..c8e4e28 100644 --- a/src/services/standalone/provider.service.ts +++ b/app/src/services/standalone/provider.service.ts @@ -1,7 +1,6 @@ import {ReadableGlobalContext} from "vue-mvvm"; import {ProviderService} from "@contracts/provider.contract"; -import {MetadataDbService} from "@contracts/standalone/metadata.contract"; import {ServiceDeclaration} from "@services/declaration"; import {DbSession} from "@services/utils/db"; @@ -12,10 +11,15 @@ import {StoProvider} from "@providers/sto"; import {ProfileModel} from "@models/profile.model"; +import * as AppEnv from "@AppEnv"; +import {DbService} from "@contracts/standalone/db.contract"; + class ProviderServiceImpl implements ProviderService { private static readonly SESSION_KEY: string = "active-provider"; - private provider: DefaultProvider | null = null; + private readonly dbService: DbService; + + private provider: DefaultProvider | null; public readonly ANIWORLD: AniWorldProvider; public readonly STO: StoProvider; @@ -25,10 +29,12 @@ class ProviderServiceImpl implements ProviderService { } public constructor(ctx: ReadableGlobalContext) { - let dbService: MetadataDbService = ctx.getService(MetadataDbService); + this.dbService = ctx.getService(DbService); - this.ANIWORLD = new AniWorldProvider(dbService); - this.STO = new StoProvider(dbService); + this.provider = null; + + this.ANIWORLD = new AniWorldProvider(); + this.STO = new StoProvider(); this.provider = null; } @@ -45,29 +51,26 @@ class ProviderServiceImpl implements ProviderService { throw "No provider set and no provider was registered in the cache"; } - public async getDatabase(): Promise { - const provider: DefaultProvider = await this.getProvider(); - return await provider.getDatabase(); - } - public async setProvider(provider: DefaultProvider): Promise { if (this.provider) { - await this.provider.closeDatabase(); + await this.dbService.closeDatabase(this.provider); } this.provider = provider; - sessionStorage.setItem(ProviderServiceImpl.SESSION_KEY, provider.uniqueKey); + if (!AppEnv.isTesting) { + sessionStorage.setItem(ProviderServiceImpl.SESSION_KEY, provider.uniqueKey); + } } public async deleteProfile(profile: ProfileModel): Promise { for (const provider of this.ALL_PROVIDERS) { - const db: DbSession = await provider.getDatabase(); + const db: DbSession = await this.dbService.getDatabase(provider); // language=SQLite await db.execute("DELETE FROM watchlist WHERE tenant_id = ?", profile.uuid); // language=SQLite await db.execute("DELETE FROM watchtime WHERE tenant_id = ?", profile.uuid); - await provider.closeDatabase(); + await this.dbService.closeDatabase(provider); } } @@ -83,6 +86,10 @@ class ProviderServiceImpl implements ProviderService { } private async loadCache(): Promise { + if (AppEnv.isTesting) { + return false; + } + let value: string | null = sessionStorage.getItem(ProviderServiceImpl.SESSION_KEY); if (!value) { return false; diff --git a/app/src/services/standalone/resource.service.ts b/app/src/services/standalone/resource.service.ts new file mode 100644 index 0000000..a277f47 --- /dev/null +++ b/app/src/services/standalone/resource.service.ts @@ -0,0 +1,39 @@ +import * as fs from "@tauri-apps/plugin-fs"; + +import {ReadableGlobalContext} from "vue-mvvm"; + +import {ServiceDeclaration} from "@services/declaration"; + +import {ResourceService} from "@contracts/resource.contract"; +import {ProviderService} from "@contracts/provider.contract"; + +import {DefaultProvider} from "@providers/default"; + +import * as path from "@utils/path"; + +class ResourceServiceImpl implements ResourceService { + private readonly providerService: ProviderService; + + public constructor(ctx: ReadableGlobalContext) { + this.providerService = ctx.getService(ProviderService); + } + + public async getResourceLocation(): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + return await provider.getStorageLocation(); + } + + public async saveResource(name: string, data: Uint8Array): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + const basePath: string = await provider.getStorageLocation(); + const fullPath: string = path.join(basePath, name); + + await fs.writeFile(fullPath, data); + } +} + +export default { + key: ResourceService, + ctor: ResourceServiceImpl +} satisfies ServiceDeclaration; \ No newline at end of file diff --git a/src/services/standalone/season.service.ts b/app/src/services/standalone/season.service.ts similarity index 78% rename from src/services/standalone/season.service.ts rename to app/src/services/standalone/season.service.ts index 56f2892..baa84d9 100644 --- a/src/services/standalone/season.service.ts +++ b/app/src/services/standalone/season.service.ts @@ -2,7 +2,7 @@ import {QueryResult} from "@tauri-apps/plugin-sql"; import {ReadableGlobalContext} from "vue-mvvm"; -import {SeasonService} from "@contracts/season.contract"; +import {SeasonService, SyncInformation} from "@contracts/season.contract"; import {ServiceDeclaration} from "@services/declaration"; import {DbServiceBase, DbSession} from "@services/utils/db"; @@ -15,18 +15,21 @@ class SeasonServiceImpl extends DbServiceBase implements SeasonService { super(ctx); } - public async requiresSync(seriesId: number): Promise { - const session: DbSession = await this.provider.getDatabase(); + public async getSyncStatus(seriesId: number): Promise { + const session: DbSession = await this.getDatabase() const [{count}] = await session.query<[{ count: number }]>("SELECT count(season_id) AS count FROM season WHERE series_id = ?;", seriesId); - return count == 0; + return { + requiresSync: count == 0, + status: null + } } public async getSeason(seasonId: number): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() const rows: SeasonDbModel[] = await session.query("SELECT * FROM season WHERE season_id = ?;", seasonId); if (rows.length == 0) { @@ -37,7 +40,7 @@ class SeasonServiceImpl extends DbServiceBase implements SeasonService { } public async getSeasons(seriesId: number): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() const seasons: SeasonDbModel[] = await session.query("SELECT * FROM season WHERE series_id = ? ORDER BY season_number;", seriesId); @@ -45,7 +48,7 @@ class SeasonServiceImpl extends DbServiceBase implements SeasonService { } public async insertSeason(seriesId: number, seasonNumber: number): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() const result: QueryResult = await session.execute("INSERT INTO season (series_id, season_number) VALUES (?, ?);", seriesId, seasonNumber); diff --git a/src/services/standalone/series.service.ts b/app/src/services/standalone/series.service.ts similarity index 88% rename from src/services/standalone/series.service.ts rename to app/src/services/standalone/series.service.ts index 831ef5a..8d882b1 100644 --- a/src/services/standalone/series.service.ts +++ b/app/src/services/standalone/series.service.ts @@ -21,7 +21,7 @@ class SeriesServiceImpl extends DbServiceBase implements SeriesService { } public async requiresSync(): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() const [{count}] = await session.query<[{ count: number @@ -30,13 +30,13 @@ class SeriesServiceImpl extends DbServiceBase implements SeriesService { } public async getStreams(): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() return session.query(""); } public async existByGUID(guid: string): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() const [{count}] = await session.query<[{ count: number @@ -46,7 +46,7 @@ class SeriesServiceImpl extends DbServiceBase implements SeriesService { } public async insertSeries(guid: string, title: string, description: string, preview_image: string | null): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() let result: QueryResult = await session.execute("INSERT INTO series (guid, title, description, preview_image) VALUES (?, ?, ?, ?)", guid, title, description, preview_image); @@ -54,7 +54,7 @@ class SeriesServiceImpl extends DbServiceBase implements SeriesService { } public async getSeriesChunk(offset: number, limit: number): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() let rows: SeriesDbModel[] = await session.query("SELECT * FROM series ORDER BY title LIMIT ? OFFSET ? ;", limit, offset); @@ -82,14 +82,14 @@ class SeriesServiceImpl extends DbServiceBase implements SeriesService { filters.push(`(${genreFilter.join(" OR ")})`); } - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() const rows: SeriesDbModel[] = await session.query(`SELECT DISTINCT s.* FROM series AS s INNER JOIN genre_to_series AS gs ON s.series_id = gs.series_id WHERE true AND ${filters.join(" AND ")} ORDER BY s.title LIMIT ? OFFSET ?`, ...params, limit, offset); return rows.map(row => SeriesModel(row.series_id, row.guid, row.title, row.description, row.preview_image)); } public async getSeries(seriesId: number): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() const rows: SeriesDbModel[] = await session.query(`SELECT * FROM series WHERE series_id = ? LIMIT 1`, seriesId); @@ -102,7 +102,7 @@ class SeriesServiceImpl extends DbServiceBase implements SeriesService { public async getStartedSeries(): Promise { const profile: ProfileModel = await this.userService.getActiveProfile(); - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() // language=SQLite const rows: SeriesDbModel[] = await session.query(` @@ -111,7 +111,7 @@ class SeriesServiceImpl extends DbServiceBase implements SeriesService { JOIN season AS se ON se.series_id = s.series_id JOIN episode AS e ON e.season_id = se.season_id LEFT JOIN watchtime AS wt ON wt.episode_id = e.episode_id - AND wt.tenant_id = 'c5e8c854-bdbf-42d3-930c-8385aa6bf308' + AND wt.tenant_id = ? WHERE se.season_number > 0 GROUP BY s.series_id HAVING @@ -129,7 +129,7 @@ class SeriesServiceImpl extends DbServiceBase implements SeriesService { return []; } - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() const rows: SeriesDbModel[] = await session.query("SELECT * FROM series WHERE series_id = ?" + (" OR series_id = ?".repeat(seriesIds.length - 1)), ...seriesIds); return rows.map(row => SeriesModel(row.series_id, row.guid, row.title, row.description, row.preview_image)); diff --git a/src/services/standalone/settings.service.ts b/app/src/services/standalone/settings.service.ts similarity index 98% rename from src/services/standalone/settings.service.ts rename to app/src/services/standalone/settings.service.ts index b0f8fa8..635047a 100644 --- a/src/services/standalone/settings.service.ts +++ b/app/src/services/standalone/settings.service.ts @@ -9,6 +9,8 @@ import {ServiceDeclaration} from "@services/declaration"; import {ProfileModel} from "@models/profile.model"; +import * as AppEnv from "@AppEnv"; + class SettingsServiceImpl implements SettingsService { private static readonly IGNORE_VERSION_KEY: string = "ignore-version"; private static readonly UPDATES_ACTIVE_KEY: string = "updates-active"; @@ -52,6 +54,10 @@ class SettingsServiceImpl implements SettingsService { this.i18nService = ctx.getService(I18nService); this.userService = ctx.getService(UserService); + if (AppEnv.isTesting) { + return; + } + this.ignoreVersion = this.loadFromStorage(SettingsServiceImpl.IGNORE_VERSION_KEY, "0.0.0"); this.updatesActive = this.loadFromStorage(SettingsServiceImpl.UPDATES_ACTIVE_KEY, "true") == "true"; this.healthz = this.loadFromStorage(SettingsServiceImpl.HEALTHZ_KEY, "https://www.google.com/generate_204"); diff --git a/src/services/standalone/user.service.ts b/app/src/services/standalone/user.service.ts similarity index 86% rename from src/services/standalone/user.service.ts rename to app/src/services/standalone/user.service.ts index 6a71006..d6f6f80 100644 --- a/src/services/standalone/user.service.ts +++ b/app/src/services/standalone/user.service.ts @@ -1,4 +1,3 @@ -import {path} from "@tauri-apps/api"; import {QueryResult} from "@tauri-apps/plugin-sql"; import {ReadableGlobalContext} from "vue-mvvm"; @@ -15,7 +14,10 @@ import {DbSession} from "@services/utils/db"; import {ProfileDbModel, ProfileEye, ProfileModel, ProfileMouth} from "@models/profile.model"; -class UserServiceImpl implements UserService { +import * as path from "@utils/path"; +import {UnsupportedPlatformError} from "@utils/error"; + +export class UserServiceImpl implements UserService { private static readonly SESSION_KEY: string = "active-profile"; private readonly ctx: ReadableGlobalContext; @@ -32,13 +34,21 @@ class UserServiceImpl implements UserService { this.dbService = ctx.getService(UserDbService); } + public async authenticate(_profile: ProfileModel, _password: string): Promise { + throw new UnsupportedPlatformError("UserServiceImpl.authenticate"); + } + + public async logout(): Promise { + throw new UnsupportedPlatformError("UserServiceImpl.logout"); + } + public async getActiveProfile(): Promise { const profile: ProfileModel | null = await this.getActiveProfileOrDefault(); if (profile) { return profile; } - throw "No active profile set and no profile wa registered in the cache"; + throw "No active profile set and no profile was registered in the cache"; } public async setActiveProfile(profile: ProfileModel): Promise { @@ -80,8 +90,8 @@ class UserServiceImpl implements UserService { rows[0].mouth, rows[0].theme, rows[0].lang, - rows[0].tos_accepted, - rows[0].sync_catalog + rows[0].tos_accepted == 1, + rows[0].sync_catalog == 1 ); } @@ -98,8 +108,8 @@ class UserServiceImpl implements UserService { row.mouth, row.theme, row.lang, - row.tos_accepted, - row.sync_catalog + row.tos_accepted == 1, + row.sync_catalog == 1 )); } @@ -121,8 +131,8 @@ class UserServiceImpl implements UserService { rows[0].mouth, rows[0].theme, rows[0].lang, - rows[0].tos_accepted, - rows[0].sync_catalog + rows[0].tos_accepted == 1, + rows[0].sync_catalog == 1 ); } @@ -142,7 +152,7 @@ class UserServiceImpl implements UserService { // language=SQLite const result: QueryResult = await session.execute( - "INSERT INTO profile (uuid, name, background_color, eye, mouth, theme, lang, tos_accepted) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO profile (uuid, name, background_color, eye, mouth, theme, lang, tos_accepted, sync_catalog) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", uuid, name, backgroundColor, @@ -150,8 +160,8 @@ class UserServiceImpl implements UserService { mouth, theme, local, - "false", - "false" + 0, + 0 ); return ProfileModel( @@ -163,8 +173,8 @@ class UserServiceImpl implements UserService { mouth, "aniworld-light", "en", - "false", - "false" + false, + false ); } @@ -180,22 +190,12 @@ class UserServiceImpl implements UserService { mouth, theme, local, - tosAccepted ? "true" : "false", - syncCatalog ? "true" : "false", + tosAccepted ? 1 : 0, + syncCatalog ? 1 : 0, profileId ); } - public getAvatarSvg(backgroundColor: string, eye: ProfileEye, mouth: ProfileMouth): string { - const result: dicebear.Result = dicebear.createAvatar(botttsNeutral, { - backgroundColor: [backgroundColor], - eyes: [eye], - mouth: [mouth] - }); - - return result.toDataUri() - } - public async deleteProfile(profile: ProfileModel): Promise { const session: DbSession = await this.getDatabase(); @@ -206,6 +206,16 @@ class UserServiceImpl implements UserService { ); } + public getAvatarSvg(backgroundColor: string, eye: ProfileEye, mouth: ProfileMouth): string { + const result: dicebear.Result = dicebear.createAvatar(botttsNeutral, { + backgroundColor: [backgroundColor], + eyes: [eye], + mouth: [mouth] + }); + + return result.toDataUri(); + } + public getAvatarSvgOfProfile(profile: ProfileModel): string { return this.getAvatarSvg(profile.background_color, profile.eye, profile.mouth); } @@ -234,7 +244,7 @@ class UserServiceImpl implements UserService { } const appDir: string = await path.appDataDir(); - const dbFile: string = await path.join(appDir, "profiles.db"); + const dbFile: string = path.join(appDir, "profiles.db"); this._session = await this.dbService.openDB(dbFile); return this._session!; diff --git a/src/services/standalone/utils/db.ts b/app/src/services/standalone/utils/db.ts similarity index 67% rename from src/services/standalone/utils/db.ts rename to app/src/services/standalone/utils/db.ts index 04d8264..cb8c346 100644 --- a/src/services/standalone/utils/db.ts +++ b/app/src/services/standalone/utils/db.ts @@ -2,28 +2,43 @@ import Database, {QueryResult} from "@tauri-apps/plugin-sql"; import {ReadableGlobalContext} from "vue-mvvm"; +import {DbService} from "@contracts/standalone/db.contract"; import {ProviderService} from "@contracts/provider.contract"; +import {DefaultProvider} from "@providers/default"; + export class DbServiceBase { - protected readonly provider: ProviderService; + private readonly dbService: DbService; + private readonly providerService: ProviderService; protected constructor(ctx: ReadableGlobalContext) { - this.provider = ctx.getService(ProviderService); + this.dbService = ctx.getService(DbService); + this.providerService = ctx.getService(ProviderService); + } + + protected async getDatabase(): Promise { + const provider: DefaultProvider = await this.providerService.getProvider(); + + return await this.dbService.getDatabase(provider); } } export class DbSession { private readonly handler: Database; - private closed: boolean; + private _closed: boolean; + + public get closed(): boolean { + return this._closed; + } public constructor(handler: Database) { this.handler = handler; - this.closed = false; + this._closed = false; } public async query(query: string, ...params: any[]): Promise { - if (this.closed) { + if (this._closed) { throw "No DB Handler was initialized"; } @@ -31,7 +46,7 @@ export class DbSession { } public async execute(query: string, ...params: any[]): Promise { - if (this.closed) { + if (this._closed) { throw "No DB Handler was initialized"; } @@ -51,7 +66,7 @@ export class DbSession { } public async close(): Promise { - this.closed = true; + this._closed = true; await this.handler.close(this.handler.path); } } diff --git a/src/services/standalone/watchlist.service.ts b/app/src/services/standalone/watchlist.service.ts similarity index 87% rename from src/services/standalone/watchlist.service.ts rename to app/src/services/standalone/watchlist.service.ts index e36a731..469bad8 100644 --- a/src/services/standalone/watchlist.service.ts +++ b/app/src/services/standalone/watchlist.service.ts @@ -19,7 +19,7 @@ class WatchlistServiceImpl extends DbServiceBase implements WatchlistService { public async getSeriesIds(): Promise { let profile: ProfileModel = await this.userService.getActiveProfile(); - let session: DbSession = await this.provider.getDatabase(); + let session: DbSession = await this.getDatabase() const rows: Array<{series_id: number}> = await session.query>("SELECT series_id FROM watchlist WHERE tenant_id = ?", profile.uuid); @@ -28,7 +28,7 @@ class WatchlistServiceImpl extends DbServiceBase implements WatchlistService { public async isSeriesOnWatchlist(seriesId: number): Promise { let profile: ProfileModel = await this.userService.getActiveProfile(); - let session: DbSession = await this.provider.getDatabase(); + let session: DbSession = await this.getDatabase() const rows: unknown[] = await session.query("SELECT series_id FROM watchlist WHERE tenant_id = ? AND series_id = ? LIMIT 1", profile.uuid, seriesId); @@ -37,13 +37,13 @@ class WatchlistServiceImpl extends DbServiceBase implements WatchlistService { public async addToWatchlist(seriesId: number): Promise { let profile: ProfileModel = await this.userService.getActiveProfile(); - let session: DbSession = await this.provider.getDatabase(); + let session: DbSession = await this.getDatabase() await session.execute("INSERT INTO watchlist (series_id, tenant_id) VALUES (?, ?)", seriesId, profile.uuid); } public async removeFromWatchlist(seriesId: number): Promise { - let session: DbSession = await this.provider.getDatabase(); + let session: DbSession = await this.getDatabase() await session.execute("DELETE FROM watchlist WHERE series_id = ?", seriesId); } diff --git a/src/services/standalone/watchtime.service.ts b/app/src/services/standalone/watchtime.service.ts similarity index 91% rename from src/services/standalone/watchtime.service.ts rename to app/src/services/standalone/watchtime.service.ts index c817390..878468e 100644 --- a/src/services/standalone/watchtime.service.ts +++ b/app/src/services/standalone/watchtime.service.ts @@ -22,7 +22,7 @@ class WatchtimeServiceImpl extends DbServiceBase implements WatchtimeService { public async getWatchtimeOfEpisode(episodeId: number): Promise { const profile: ProfileModel = await this.userService.getActiveProfile(); - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() // language=SQLite const rows: WatchtimeDbModel[] = await session.query("SELECT * FROM watchtime WHERE episode_id = ? AND tenant_id = ?", episodeId, profile.uuid); @@ -41,7 +41,7 @@ class WatchtimeServiceImpl extends DbServiceBase implements WatchtimeService { public async getWatchtimesOfSeries(seriesId: number): Promise { const profile: ProfileModel = await this.userService.getActiveProfile(); - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() // language=SQLite const rows: WatchtimeDbModel[] = await session.query( @@ -61,7 +61,7 @@ class WatchtimeServiceImpl extends DbServiceBase implements WatchtimeService { public async getTotalWatchProgression(seriesId: number): Promise { const profile: ProfileModel = await this.userService.getActiveProfile(); - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() // language=SQLite const [{total_episodes, finished_episodes}] = await session.query<[{ @@ -84,12 +84,12 @@ class WatchtimeServiceImpl extends DbServiceBase implements WatchtimeService { return 0; } - return finished_episodes / total_episodes; + return Math.round(100.0 * finished_episodes / total_episodes); } public async createWatchtimeOfEpisode(episodeId: number, percentageWatched: number, stoppedTime: number): Promise { const profile: ProfileModel = await this.userService.getActiveProfile(); - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() // language=SQLite const result: QueryResult = await session.execute( @@ -110,7 +110,7 @@ class WatchtimeServiceImpl extends DbServiceBase implements WatchtimeService { } public async updateWatchtime(watchtimeId: number, percentageWatched: number, stoppedTime: number): Promise { - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() // language=SQLite await session.execute( @@ -123,7 +123,7 @@ class WatchtimeServiceImpl extends DbServiceBase implements WatchtimeService { public async updateWatchtimeWithEpisode(episodeId: number, percentageWatched: number, stoppedTime: number): Promise { const profile: ProfileModel = await this.userService.getActiveProfile(); - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() // language=SQLite await session.execute( @@ -137,7 +137,7 @@ class WatchtimeServiceImpl extends DbServiceBase implements WatchtimeService { public async updateWatchtimesOfSeason(seasonId: number, percentageWatched: number, stoppedTime: number): Promise { const profile: ProfileModel = await this.userService.getActiveProfile(); - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() // language=SQLite await session.execute(` @@ -151,7 +151,7 @@ class WatchtimeServiceImpl extends DbServiceBase implements WatchtimeService { public async updateWatchtimesOfSeries(seriesId: number, percentageWatched: number, stoppedTime: number): Promise { const profile: ProfileModel = await this.userService.getActiveProfile(); - const session: DbSession = await this.provider.getDatabase(); + const session: DbSession = await this.getDatabase() // language=SQLite await session.execute(` diff --git a/app/src/services/worker/provider.service.ts b/app/src/services/worker/provider.service.ts new file mode 100644 index 0000000..de4e577 --- /dev/null +++ b/app/src/services/worker/provider.service.ts @@ -0,0 +1,4 @@ +// Same implementation like in client mode +import __default__ from "../client/provider.service"; + +export default __default__; \ No newline at end of file diff --git a/src/sources/doodstream.ts b/app/src/sources/doodstream.ts similarity index 96% rename from src/sources/doodstream.ts rename to app/src/sources/doodstream.ts index 7cdab20..b6a01cf 100644 --- a/src/sources/doodstream.ts +++ b/app/src/sources/doodstream.ts @@ -38,10 +38,10 @@ export async function getStream(embedUrl: string): Promise { throw "Embed URL cannot be empty"; } - const html: string = await http.get(embedUrl, getHeaders()); + const html: string = await http.get(embedUrl, getHeaders()).text(); const md5Url: string = getPassMd5Url(html, embedUrl); const token: string = getToken(html, embedUrl); - const md5Html: string = await http.get(md5Url, getHeaders()); + const md5Html: string = await http.get(md5Url, getHeaders()).text(); const videoBaseUrl: string = md5Html.trim(); if (!videoBaseUrl) { throw `Empty video base URL returned from ${embedUrl}`; diff --git a/src/sources/filemoon.ts b/app/src/sources/filemoon.ts similarity index 98% rename from src/sources/filemoon.ts rename to app/src/sources/filemoon.ts index 311b8be..41a517c 100644 --- a/src/sources/filemoon.ts +++ b/app/src/sources/filemoon.ts @@ -127,7 +127,7 @@ async function tryByseApi( const baseUrl = `${parsed.protocol}//${parsed.host}`; const apiUrl = `${baseUrl}/api/videos/${fileCode}`; - const content: string = await http.get(apiUrl, getHeaders()); + const content: string = await http.get(apiUrl, getHeaders()).text(); const data = JSON.parse(content); const playback = data?.playback; if (!playback) return null; @@ -213,7 +213,7 @@ export async function getStream(embedUrl: string): Promise { } } - const html: string = await http.get(embedUrl, getHeaders()); + const html: string = await http.get(embedUrl, getHeaders()).text(); const url: string | null = tryExtractFromHtml(html); if (url) { diff --git a/src/sources/index.ts b/app/src/sources/index.ts similarity index 100% rename from src/sources/index.ts rename to app/src/sources/index.ts diff --git a/src/sources/loadx.ts b/app/src/sources/loadx.ts similarity index 95% rename from src/sources/loadx.ts rename to app/src/sources/loadx.ts index 611274c..b51c417 100644 --- a/src/sources/loadx.ts +++ b/app/src/sources/loadx.ts @@ -88,7 +88,7 @@ export async function getStream(embeddedLoadXLink: string): Promise { const [idHash, host] = extractIdHashFromURL(response.url); const postURL: string = `https://${host}/player/index.php?data=${idHash}&do=getVideo`; - const apiResponse: string = await http.post(postURL, [["X-Requested-With", "XMLHttpRequest"]]); + const apiResponse: string = await http.post(postURL, null, [["X-Requested-With", "XMLHttpRequest"]]).text(); return parseVideoResponse(apiResponse); } catch (error) { diff --git a/src/sources/luluvdo.ts b/app/src/sources/luluvdo.ts similarity index 98% rename from src/sources/luluvdo.ts rename to app/src/sources/luluvdo.ts index 1284ce3..49a2809 100644 --- a/src/sources/luluvdo.ts +++ b/app/src/sources/luluvdo.ts @@ -122,7 +122,7 @@ export async function getStream(embeddedLuluvdoLink: string): Promise { const embedURL: string = buildEmbedURL(luluvdoId); const headers: [string, string][] = buildHeaders(); - const html: string = await http.get(embedURL, headers); + const html: string = await http.get(embedURL, headers).text(); return extractVideoURL(html); } catch (error) { throw "Failed to extract video from LuluVDO: " + error; diff --git a/src/sources/speedfiles.ts b/app/src/sources/speedfiles.ts similarity index 97% rename from src/sources/speedfiles.ts rename to app/src/sources/speedfiles.ts index fa93818..fc93712 100644 --- a/src/sources/speedfiles.ts +++ b/app/src/sources/speedfiles.ts @@ -102,7 +102,7 @@ export async function getStream(embeddedSpeedFilesLink: string): Promise try { const validatedURL: string = validateSpeedFilesURL(embeddedSpeedFilesLink); - const html: string = await http.get(validatedURL, []); + const html: string = await http.get(validatedURL, []).text(); checkServerStatus(html); const encodedData: string = extractEncodedData(html); diff --git a/src/sources/vidmoly.ts b/app/src/sources/vidmoly.ts similarity index 99% rename from src/sources/vidmoly.ts rename to app/src/sources/vidmoly.ts index fbd468a..5d6e733 100644 --- a/src/sources/vidmoly.ts +++ b/app/src/sources/vidmoly.ts @@ -12,7 +12,7 @@ const FILE_LINK_PATTERN: RegExp = /file:\s*"(https?:\/\/[^"]+)"/; */ export async function getStream(embeddedVidmolyLink: string): Promise { try { - const html: string = await http.get(embeddedVidmolyLink); + const html: string = await http.get(embeddedVidmolyLink).text(); const match: RegExpMatchArray | null = html.match(FILE_LINK_PATTERN); if (match) { @@ -43,7 +43,7 @@ export async function getStream(embeddedVidmolyLink: string): Promise { */ export async function getPreviewImage(embeddedVidmolyLink: string): Promise { try { - const html: string = await http.get(embeddedVidmolyLink); + const html: string = await http.get(embeddedVidmolyLink).text(); const match: RegExpMatchArray | null = html.match(/image\s*:\s*"([^"]+\.jpg)"/); if (match) { diff --git a/src/sources/vidoza.ts b/app/src/sources/vidoza.ts similarity index 95% rename from src/sources/vidoza.ts rename to app/src/sources/vidoza.ts index cf92c00..99de630 100644 --- a/src/sources/vidoza.ts +++ b/app/src/sources/vidoza.ts @@ -12,7 +12,7 @@ const SOURCE_LINK_PART: RegExp = /src:\s*"([^"]+)"/; */ export async function getStream(embeddedVidozaLink: string): Promise { try { - const html: string = await http.get(embeddedVidozaLink); + const html: string = await http.get(embeddedVidozaLink).text(); if (html.search("sourcesCode:") != -1) { const match: RegExpMatchArray | null = html.match(SOURCE_LINK_PART); if (match) { diff --git a/src/sources/voe.ts b/app/src/sources/voe.ts similarity index 98% rename from src/sources/voe.ts rename to app/src/sources/voe.ts index d7122a0..c2a487e 100644 --- a/src/sources/voe.ts +++ b/app/src/sources/voe.ts @@ -99,7 +99,7 @@ function extractVOEFromScript(html: string): string | null { export async function getStream(embeddedVOELink: string): Promise { try { // Initial request to get redirect URL - const response: string = await http.get(embeddedVOELink); + const response: string = await http.get(embeddedVOELink).text(); // Find redirect URL using compiled regex const redirectMatch: RegExpMatchArray | null = response.match(REDIRECT_PATTERN); @@ -119,7 +119,7 @@ export async function getStream(embeddedVOELink: string): Promise { // Follow redirect and get final HTML try { - html = await http.get(redirectURL); + html = await http.get(redirectURL).text(); } catch (e) { throw `Failed to follow redirect: ${e}`; } @@ -169,7 +169,7 @@ export async function getStream(embeddedVOELink: string): Promise { export async function getPreviewImage(embeddedVOELink: string): Promise { try { // Initial request to get redirect URL - const response: string = await http.get(embeddedVOELink); + const response: string = await http.get(embeddedVOELink).text(); // Find redirect URL using compiled regex const redirectMatch: RegExpMatchArray | null = response.match(REDIRECT_PATTERN); diff --git a/src/utils/array.ts b/app/src/utils/array.ts similarity index 100% rename from src/utils/array.ts rename to app/src/utils/array.ts diff --git a/app/src/utils/error.ts b/app/src/utils/error.ts new file mode 100644 index 0000000..ea2b0f1 --- /dev/null +++ b/app/src/utils/error.ts @@ -0,0 +1,32 @@ +export class UnsupportedPlatformError extends Error { + public readonly name: string = "UnsupportedPlatformError"; + + public constructor(name: string, message?: string) { + const defaultMessage: string = `The function or method "${name}" is not supported on the current platform.`; + + super(message ?? defaultMessage); + + Object.setPrototypeOf(this, UnsupportedPlatformError.prototype); + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} + +export class NotImplementedError extends Error { + public readonly name: string = "NotImplementedError"; + + + public constructor(name: string, message?: string) { + const defaultMessage: string = `The function or method "${name}" is not implemented.`; + + super(message ?? defaultMessage); + + Object.setPrototypeOf(this, NotImplementedError.prototype); + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} \ No newline at end of file diff --git a/src/utils/hash.ts b/app/src/utils/hash.ts similarity index 100% rename from src/utils/hash.ts rename to app/src/utils/hash.ts diff --git a/src/utils/hls-tauri-bridge.ts b/app/src/utils/hls-tauri-bridge.ts similarity index 100% rename from src/utils/hls-tauri-bridge.ts rename to app/src/utils/hls-tauri-bridge.ts diff --git a/app/src/utils/http.ts b/app/src/utils/http.ts new file mode 100644 index 0000000..0b7832e --- /dev/null +++ b/app/src/utils/http.ts @@ -0,0 +1,155 @@ +import * as AppEnv from "@AppEnv"; + +let fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; + +if (AppEnv.isWorkerMode) { + fetch = globalThis.fetch; +} else { + const tauri = await import("@tauri-apps/plugin-http"); + fetch = tauri.fetch; +} + +export class HTTPError extends Error { + public readonly response: Response; + public readonly status: number; + public readonly statusText: string; + public readonly url: string; + public readonly body: any; + + private constructor( + message: string, + response: Response + ) { + super(message); + this.name = 'HTTPError'; + + this.response = response; + this.status = response.status; + this.statusText = response.statusText; + this.url = response.url; + } + + public static async create(response: Response): Promise { + let message: string = `HTTP ${response.status}: ${response.statusText} (${response.url})`; + + try { + let body: string; + const contentType: string | null = response.headers.get("content-type"); + if (contentType && contentType.includes("application/json")) { + body = JSON.stringify(await response.json(), null, 4); + } else { + body = await response.text(); + } + + message += "\n\n" + body; + } catch { + // If body parsing fails, we skip appending it + } + + + return new HTTPError(message, response); + } + + public [Symbol.toPrimitive](hint: string): string | number { + if (hint === 'number') return this.status; + return this.message; + } + + public toString(): string { + return this.message; + } +} + +class HTTPResponse { + private readonly promise: Promise; + + public constructor(promise: Promise) { + this.promise = promise; + } + + public async text(): Promise { + return (await this.getResponse()).text(); + } + + public async json(): Promise { + return (await this.getResponse()).json(); + } + + public async arrayBuffer(): Promise { + return (await this.getResponse()).arrayBuffer(); + } + + public async uint8Array(): Promise { + return new Uint8Array(await this.arrayBuffer()); + } + + public async blob(): Promise { + return (await this.getResponse()).blob(); + } + + public async wait(): Promise { + await this.getResponse(); + } + + private async getResponse(): Promise { + const response: Response = await this.promise; + if (!response.ok) { + throw await HTTPError.create(response); + } + + return response; + } +} + +export function get(url: string, headers: [string, string][] = []): HTTPResponse { + return new HTTPResponse(fetch(url, { + method: "GET", + headers: headers + })); +} + +export function post(url: string, body: RequestInit["body"], headers: [string, string][] = []): HTTPResponse { + return new HTTPResponse(fetch(url, { + method: "POST", + body: body, + headers: headers + })); +} + +export function put(url: string, body: RequestInit["body"], headers: [string, string][] = []): HTTPResponse { + return new HTTPResponse(fetch(url, { + method: "PUT", + body: body, + headers: headers + })); +} + +export function delete$(url: string, headers: [string, string][] = []): HTTPResponse { + return new HTTPResponse(fetch(url, { + method: "DELETE", + headers: headers + })); +} + +export async function head(url: string, headers: [string, string][], followRedirect: boolean = true): Promise { + const response: Response = await fetch(url, { + method: "HEAD", + headers: headers, + redirect: followRedirect ? "follow" : undefined + }); + + if (!response.ok) { + throw await HTTPError.create(response); + } + + return response; +} + +export async function runHealthz(healthzUrl: string): Promise { + try { + await get(healthzUrl).wait(); + return true; + } catch { + return false; + } +} \ No newline at end of file diff --git a/src/utils/markdown.ts b/app/src/utils/markdown.ts similarity index 100% rename from src/utils/markdown.ts rename to app/src/utils/markdown.ts diff --git a/app/src/utils/path.ts b/app/src/utils/path.ts new file mode 100644 index 0000000..ce0b0a9 --- /dev/null +++ b/app/src/utils/path.ts @@ -0,0 +1,74 @@ +import * as path from "@tauri-apps/api/path"; + +import * as AppEnv from "@AppEnv"; + +const PATH_SEPARATOR: string = AppEnv.isTesting || AppEnv.isWorkerMode + ? require("node:path").sep + : path.sep(); + +export async function appDataDir(): Promise { + if (AppEnv.isTesting) { + return "./"; + } + + return await path.appDataDir(); +} + +export function isAbsolutePath(part: string): boolean { + if (AppEnv.isWindows) { + // C:\..., D:\..., or \\server\share + return /^[a-zA-Z]:\\/.test(part) || part.startsWith("\\\\"); + } + + // Unix-like systems + return part.startsWith("/"); +} + +export function extractRoot(part: string): string | null { + if (AppEnv.isWindows) { + if (part.startsWith("\\\\")) { + return "\\\\"; + } + + if (/^[a-zA-Z]:\\/.test(part)) { + return part.slice(0, 3); + } + + return null; + } + + if (part.startsWith("/")) { + return "/"; + } + + return null; +} + +export function join(...paths: string[]): string { + if (paths.length == 0) { + return ""; + } + + const rootParts: string | null = !AppEnv.isWindows ? extractRoot(paths[0]) : null; + const splitRegex: RegExp = new RegExp(`\\${PATH_SEPARATOR}`, "g"); + const parts: string[] = paths.map(part => part.split(splitRegex)).flat(); + + const resultParts: string[] = []; + for (let i: number = 0; i < parts.length; i++) { + const part: string = parts[i]; + if (part == "") { + continue; + } + if (part == "." && i > 0) { + continue; + } + + if (part == ".." && resultParts.length > 0) { + resultParts.shift(); + } + + resultParts.push(part); + } + + return (rootParts ?? "") + resultParts.join(PATH_SEPARATOR); +} \ No newline at end of file diff --git a/src/utils/string.ts b/app/src/utils/string.ts similarity index 100% rename from src/utils/string.ts rename to app/src/utils/string.ts diff --git a/src/utils/throttle.ts b/app/src/utils/throttle.ts similarity index 100% rename from src/utils/throttle.ts rename to app/src/utils/throttle.ts diff --git a/src/views/ListView.model.ts b/app/src/views/ListView.model.ts similarity index 84% rename from src/views/ListView.model.ts rename to app/src/views/ListView.model.ts index 52a775c..2d67da0 100644 --- a/src/views/ListView.model.ts +++ b/app/src/views/ListView.model.ts @@ -9,14 +9,12 @@ import ListView from "@views/ListView.vue"; import {DetailControlModel} from "@controls/DetailControl.model"; import {ListService} from "@contracts/list.contract"; -import {ProviderService} from "@contracts/provider.contract"; import {I18nService} from "@contracts/i18n.contract"; +import {ResourceService} from "@contracts/resource.contract"; import {ListModel} from "@models/list.model"; import {SeriesModel} from "@models/series.model"; -import {DefaultProvider} from "@providers/default"; - import I18n from "@utils/i18n"; export class ListViewModel extends ViewModel { @@ -28,13 +26,13 @@ export class ListViewModel extends ViewModel { } } satisfies RouteAdapter; - private routerService: RouterService; - private dialogService: DialogService; - private alertService: AlertService; + private readonly routerService: RouterService; + private readonly dialogService: DialogService; + private readonly alertService: AlertService; - private providerService: ProviderService; - private listService: ListService; - private i18nService: I18nService; + private readonly resourceService: ResourceService; + private readonly listService: ListService; + private readonly i18nService: I18nService; private list: ListModel | null = this.ref(null); @@ -60,14 +58,13 @@ export class ListViewModel extends ViewModel { this.dialogService = this.ctx.getService(DialogService); this.alertService = this.ctx.getService(AlertService); - this.providerService = this.ctx.getService(ProviderService); + this.resourceService = this.ctx.getService(ResourceService); this.listService = this.ctx.getService(ListService); this.i18nService = this.ctx.getService(I18nService); } protected async mounted(): Promise { - const provider: DefaultProvider = await this.providerService.getProvider(); - this.providerFolder = await provider.getStorageLocation(); + this.providerFolder = await this.resourceService.getResourceLocation(); const listId: number = this.routerService.params.getIntegerOrThrow("id"); diff --git a/src/views/ListView.vue b/app/src/views/ListView.vue similarity index 100% rename from src/views/ListView.vue rename to app/src/views/ListView.vue diff --git a/src/views/PlayerView.model.ts b/app/src/views/PlayerView.model.ts similarity index 93% rename from src/views/PlayerView.model.ts rename to app/src/views/PlayerView.model.ts index d9aabef..4ea46a1 100644 --- a/src/views/PlayerView.model.ts +++ b/app/src/views/PlayerView.model.ts @@ -9,7 +9,7 @@ import {EpisodeModel} from "@models/episode.model"; import {SeasonModel} from "@models/season.model"; import {SeriesService} from "@contracts/series.contract"; -import {SeasonService} from "@contracts/season.contract"; +import {SeasonService, SyncStatus} from "@contracts/season.contract"; import {EpisodeService} from "@contracts/episode.contract"; import {FetchService, Provider} from "@contracts/fetch.contract"; import {I18nService} from "@contracts/i18n.contract"; @@ -192,9 +192,16 @@ export class PlayerViewModel extends ViewModel { this.providerLoading = true; this.providers.clear(); - const providers: Provider[] = await this.fetchService.getProviders(this.series.guid, this.season.season_number, this.episode.episode_number); - this.providers.push(...providers.groupTo2D(provider => provider.language)); - + while (true) { + const [status, providers] = await this.fetchService.getProviders(this.series, this.season, this.episode); + if (status == SyncStatus.Completed) { + this.providers.push(...providers.groupTo2D(provider => provider.language)); + break; + } + + await new Promise(resolve => setTimeout(resolve, 1000)); + } + this.providerLoading = false; } } \ No newline at end of file diff --git a/src/views/PlayerView.vue b/app/src/views/PlayerView.vue similarity index 100% rename from src/views/PlayerView.vue rename to app/src/views/PlayerView.vue diff --git a/src/views/ProfileView.model.ts b/app/src/views/ProfileView.model.ts similarity index 82% rename from src/views/ProfileView.model.ts rename to app/src/views/ProfileView.model.ts index 8a66ee3..95b2f39 100644 --- a/src/views/ProfileView.model.ts +++ b/app/src/views/ProfileView.model.ts @@ -1,5 +1,6 @@ import {Component, nextTick} from "vue"; import {ActionResult, ViewModel} from "vue-mvvm"; +import {DialogService} from "vue-mvvm/dialog"; import {RouteAdapter, RouterService} from "vue-mvvm/router"; import ProfileView from "@views/ProfileView.vue"; @@ -10,6 +11,9 @@ import {UserService} from "@contracts/user.contract"; import type {ProfileModel} from "@models/profile.model"; import {ProfileSetupControlModel} from "@controls/ProfileSetupControl.model"; +import {PinDialogModel} from "@controls/PinDialog.model"; + +import * as AppEnv from "@AppEnv"; export class ProfileViewModel extends ViewModel { public static readonly component: Component = ProfileView; @@ -18,6 +22,8 @@ export class ProfileViewModel extends ViewModel { } private readonly routerService: RouterService; + private readonly dialogService: DialogService; + private readonly userService: UserService; private readonly profileSetupControl: ProfileSetupControlModel | null; @@ -30,6 +36,8 @@ export class ProfileViewModel extends ViewModel { super(); this.routerService = this.ctx.getService(RouterService); + this.dialogService = this.ctx.getService(DialogService); + this.userService = this.ctx.getService(UserService); this.profileSetupControl = this.getUserControl("profile-setup-control"); @@ -56,7 +64,19 @@ export class ProfileViewModel extends ViewModel { } public async onProfileItem(profile: ProfileModel): Promise { + if (AppEnv.isClientMode) { + using dialog: PinDialogModel = this.dialogService.initDialog(PinDialogModel, profile); + await dialog.openDialog(); + + const result: ActionResult = await this.runAction(dialog); + + if (!result.success) { + return; + } + } + await this.userService.setActiveProfile(profile); + await this.routerService.navigateTo(ProviderViewModel); } diff --git a/src/views/ProfileView.vue b/app/src/views/ProfileView.vue similarity index 100% rename from src/views/ProfileView.vue rename to app/src/views/ProfileView.vue diff --git a/src/views/ProviderView.model.ts b/app/src/views/ProviderView.model.ts similarity index 96% rename from src/views/ProviderView.model.ts rename to app/src/views/ProviderView.model.ts index 8da4361..9762629 100644 --- a/src/views/ProviderView.model.ts +++ b/app/src/views/ProviderView.model.ts @@ -17,6 +17,8 @@ import {UserService} from "@contracts/user.contract"; import {ProfileModel} from "@models/profile.model"; +import * as AppEnv from "@AppEnv"; + export class ProviderViewModel extends ViewModel { public static readonly component: Component = ProviderView; public static readonly route: RouteAdapter = { @@ -83,6 +85,9 @@ export class ProviderViewModel extends ViewModel { } public onProfileBtn(): void { + if (AppEnv.isClientMode) { + this.userService.logout(); + } this.routerService.navigateBack(); } } \ No newline at end of file diff --git a/src/views/ProviderView.vue b/app/src/views/ProviderView.vue similarity index 100% rename from src/views/ProviderView.vue rename to app/src/views/ProviderView.vue diff --git a/app/src/views/SeriesSyncClientView.vue b/app/src/views/SeriesSyncClientView.vue new file mode 100644 index 0000000..edf9475 --- /dev/null +++ b/app/src/views/SeriesSyncClientView.vue @@ -0,0 +1,83 @@ + + + diff --git a/src/views/SeriesSyncView.vue b/app/src/views/SeriesSyncStandaloneView.vue similarity index 96% rename from src/views/SeriesSyncView.vue rename to app/src/views/SeriesSyncStandaloneView.vue index c8d3dda..a6853a3 100644 --- a/src/views/SeriesSyncView.vue +++ b/app/src/views/SeriesSyncStandaloneView.vue @@ -1,14 +1,14 @@

- +

    @@ -51,7 +51,7 @@ const vm: SeriesSyncViewModel = useViewModel(SeriesSyncViewModel); :class="{'opacity-50 pointer-events-none': vm.isSyncing}" @click="vm.onAllAvailableRowClick">
    - +
    - + - + {{ season.season_number }}
    @@ -86,7 +86,7 @@ const vm: SeriesSyncViewModel = useViewModel(SeriesSyncViewModel); :class="{'opacity-50 pointer-events-none': vm.isSyncing}" @click="vm.onAllExistingRowClick">
    - +
    - + - + {{ season.season_number }}
    @@ -131,7 +131,7 @@ const vm: SeriesSyncViewModel = useViewModel(SeriesSyncViewModel); :disabled="vm.isPreLoading || vm.isSyncing || vm.selectedSeasons.length == 0" @click="vm.onStartSyncBtn"> - +
    diff --git a/src/views/SeriesSyncView.model.ts b/app/src/views/SeriesSyncView.model.ts similarity index 69% rename from src/views/SeriesSyncView.model.ts rename to app/src/views/SeriesSyncView.model.ts index d69c5aa..0e3af76 100644 --- a/src/views/SeriesSyncView.model.ts +++ b/app/src/views/SeriesSyncView.model.ts @@ -2,11 +2,12 @@ import {Component} from "vue"; import {ViewModel} from "vue-mvvm"; import {RouteAdapter, RouterService} from "vue-mvvm/router"; -import SeriesSyncView from "@views/SeriesSyncView.vue"; +import SeriesSyncStandaloneView from "@views/SeriesSyncStandaloneView.vue"; +import SeriesSyncClientView from "@views/SeriesSyncClientView.vue"; import {StreamViewModel} from "@views/StreamView.model"; import {SeriesService} from "@contracts/series.contract"; -import {SeasonService} from "@contracts/season.contract"; +import {SeasonService, SyncInformation, SyncStatus} from "@contracts/season.contract"; import {FetchService} from "@contracts/fetch.contract"; import {EpisodeService} from "@contracts/episode.contract"; @@ -14,8 +15,9 @@ import {SeriesModel} from "@models/series.model"; import {SeasonFetchModel, SeasonModel} from "@models/season.model"; import {EpisodeFetchModel, EpisodeModel} from "@models/episode.model"; +import {NotImplementedError} from "@utils/error"; + export class SeriesSyncViewModel extends ViewModel { - public static readonly component: Component = SeriesSyncView; public static readonly route = { path: "/streams/:series_id/sync", params: { @@ -23,6 +25,14 @@ export class SeriesSyncViewModel extends ViewModel { } } satisfies RouteAdapter; + public static get component(): Component { + throw new NotImplementedError("get SeriesSyncViewModel.component"); + } +} + +export class SeriesSyncViewStandaloneModel extends SeriesSyncViewModel { + public static readonly component: Component = SeriesSyncStandaloneView; + private readonly routerService: RouterService; private readonly fetchService: FetchService; private readonly seriesService: SeriesService; @@ -165,7 +175,7 @@ export class SeriesSyncViewModel extends ViewModel { this.isSyncing = true; this.syncProgress = 0; - let completed = 0; + let completed: number = 0; for (const season of this.selectedSeasons) { this.syncStatus = `Syncing Season ${season == 0 ? "Filme" : season}...`; @@ -225,4 +235,88 @@ export class SeriesSyncViewModel extends ViewModel { ); } } +} + +export class SeriesSyncViewClientModel extends SeriesSyncViewModel { + public static readonly component: Component = SeriesSyncClientView; + + private readonly routerService: RouterService; + + private readonly fetchService: FetchService; + private readonly seriesService: SeriesService; + private readonly seasonService: SeasonService; + + private refreshInterval: NodeJS.Timeout | null = null; + + private series: SeriesModel | null = this.ref(null); + private syncInformation: SyncInformation | null = this.ref(null); + + public isPreLoading: boolean = this.ref(true); + + public requiresSync: boolean = this.computed(() => !!this.syncInformation && this.syncInformation.requiresSync); + public isQueued: boolean = this.computed(() => this.syncInformation?.status === SyncStatus.Queued); + public isProcessing: boolean = this.computed(() => this.syncInformation?.status === SyncStatus.Processing); + + public canSync: boolean = this.computed(() => !this.isQueued && !this.isProcessing); + + public constructor() { + super(); + + this.routerService = this.ctx.getService(RouterService); + + this.fetchService = this.ctx.getService(FetchService); + this.seriesService = this.ctx.getService(SeriesService); + this.seasonService = this.ctx.getService(SeasonService); + + this.series = null; + } + + protected async mounted(): Promise { + this.isPreLoading = true; + + let seriesId: number | null = this.routerService.params.getIntegerOrDefault("series_id"); + if (!seriesId) { + this.routerService.navigateBack(); + return; + } + + this.series = await this.seriesService.getSeries(seriesId); + if (!this.series) { + this.routerService.navigateBack(); + return; + } + + this.syncInformation = await this.seasonService.getSyncStatus(seriesId); + + this.isPreLoading = false; + + if (!this.refreshInterval) { + this.refreshInterval = setInterval(async () => { + if (!this.series) { + return; + } + this.syncInformation = await this.seasonService.getSyncStatus(this.series.series_id) + }, 10_000); + + } + } + + protected async unmounted() { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + } + + public onBackBtn(): void { + this.routerService.navigateBack(); + } + + public async onSyncRequestBtn(): Promise { + if (!this.series) { + return; + } + await this.fetchService.startRemoteSyncing(this.series.series_id); + this.syncInformation = await this.seasonService.getSyncStatus(this.series.series_id); + } } \ No newline at end of file diff --git a/src/views/SettingsView.model.ts b/app/src/views/SettingsView.model.ts similarity index 100% rename from src/views/SettingsView.model.ts rename to app/src/views/SettingsView.model.ts diff --git a/src/views/SettingsView.vue b/app/src/views/SettingsView.vue similarity index 100% rename from src/views/SettingsView.vue rename to app/src/views/SettingsView.vue diff --git a/src/views/StreamView.model.ts b/app/src/views/StreamView.model.ts similarity index 96% rename from src/views/StreamView.model.ts rename to app/src/views/StreamView.model.ts index 82625f8..6ad61bf 100644 --- a/src/views/StreamView.model.ts +++ b/app/src/views/StreamView.model.ts @@ -13,13 +13,13 @@ import {EpisodeModel} from "@models/episode.model"; import {GenreModel} from "@models/genre.model"; import {WatchtimeModel} from "@models/watchtime.model"; -import {ProviderService} from "@contracts/provider.contract"; import {I18nService} from "@contracts/i18n.contract"; import {GenreService} from "@contracts/genre.contract"; import {SeriesService} from "@contracts/series.contract"; import {SeasonService} from "@contracts/season.contract"; import {EpisodeService} from "@contracts/episode.contract"; import {WatchtimeService} from "@contracts/watchtime.contract"; +import {ResourceService} from "@contracts/resource.contract"; import I18n from "@utils/i18n"; @@ -34,7 +34,8 @@ export class StreamViewModel extends ViewModel { private readonly routerService: RouterService; private readonly alertService: AlertService; - private readonly providerService: ProviderService; + + private readonly resourceService: ResourceService; private readonly i18nService: I18nService; private readonly genreService: GenreService; private readonly seriesService: SeriesService; @@ -68,7 +69,8 @@ export class StreamViewModel extends ViewModel { this.routerService = this.ctx.getService(RouterService); this.alertService = this.ctx.getService(AlertService); - this.providerService = this.ctx.getService(ProviderService); + + this.resourceService = this.ctx.getService(ResourceService); this.i18nService = this.ctx.getService(I18nService); this.genreService = this.ctx.getService(GenreService); this.seriesService = this.ctx.getService(SeriesService); @@ -96,7 +98,7 @@ export class StreamViewModel extends ViewModel { return; } - this.providerFolder = await (await this.providerService.getProvider()).getStorageLocation(); + this.providerFolder = await this.resourceService.getResourceLocation(); this.genres.push(...await this.genreService.getNonMainGenresOfSeries(this.series.series_id)); this.mainGenre = await this.genreService.getMainGenreOfSeries(this.series.series_id); diff --git a/src/views/StreamView.vue b/app/src/views/StreamView.vue similarity index 100% rename from src/views/StreamView.vue rename to app/src/views/StreamView.vue diff --git a/src/views/StreamsView.model.ts b/app/src/views/StreamsView.model.ts similarity index 96% rename from src/views/StreamsView.model.ts rename to app/src/views/StreamsView.model.ts index 4b76804..a59e0f5 100644 --- a/src/views/StreamsView.model.ts +++ b/app/src/views/StreamsView.model.ts @@ -16,6 +16,7 @@ import {I18nService} from "@contracts/i18n.contract"; import {ProviderService} from "@contracts/provider.contract"; import {GenreService} from "@contracts/genre.contract"; import {SettingsService} from "@contracts/settings.contract"; +import {ResourceService} from "@contracts/resource.contract"; import {SeriesModel} from "@models/series.model"; import {GenreModel} from "@models/genre.model"; @@ -36,6 +37,7 @@ export class StreamsViewModel extends ViewModel { private readonly dialogService: DialogService; private readonly toastService: ToastService; + private readonly resourceService: ResourceService; private readonly providerService: ProviderService; private readonly fetchService: FetchService; private readonly i18nService: I18nService; @@ -63,6 +65,7 @@ export class StreamsViewModel extends ViewModel { this.dialogService = this.ctx.getService(DialogService); this.toastService = this.ctx.getService(ToastService); + this.resourceService = this.ctx.getService(ResourceService); this.providerService = this.ctx.getService(ProviderService); this.fetchService = this.ctx.getService(FetchService); this.i18nService = this.ctx.getService(I18nService); @@ -93,8 +96,7 @@ export class StreamsViewModel extends ViewModel { } public async mounted(): Promise { - const provider: DefaultProvider = await this.providerService.getProvider(); - this.providerFolder = await provider.getStorageLocation(); + this.providerFolder = await this.resourceService.getResourceLocation(); this.series.clear(); const intersectionLine: HTMLElement | null = document.getElementById("intersectionLine"); diff --git a/src/views/StreamsView.vue b/app/src/views/StreamsView.vue similarity index 95% rename from src/views/StreamsView.vue rename to app/src/views/StreamsView.vue index a2ac42f..215a1ce 100644 --- a/src/views/StreamsView.vue +++ b/app/src/views/StreamsView.vue @@ -11,9 +11,11 @@ import LucideX from "@icons/LucideX.vue"; import I18n from "@utils/i18n"; -import Text from "@/controls/Text.vue"; +import Text from "@controls/Text.vue"; import ImageHash from "@controls/ImageHash.vue"; +import * as AppEnv from "@AppEnv"; + const vm: StreamsViewModel = useViewModel(StreamsViewModel); @@ -27,7 +29,7 @@ const vm: StreamsViewModel = useViewModel(StreamsViewModel);
    - diff --git a/src/views/SyncView.model.ts b/app/src/views/SyncView.model.ts similarity index 74% rename from src/views/SyncView.model.ts rename to app/src/views/SyncView.model.ts index 32594a4..c51a11d 100644 --- a/src/views/SyncView.model.ts +++ b/app/src/views/SyncView.model.ts @@ -4,6 +4,7 @@ import {RouteAdapter, RouterService} from "vue-mvvm/router"; import SyncView from "@views/SyncView.vue"; +import {ResourceService} from "@contracts/resource.contract"; import {SeriesService} from "@contracts/series.contract"; import {FetchService} from "@contracts/fetch.contract"; import {GenreService} from "@contracts/genre.contract"; @@ -25,6 +26,8 @@ export class SyncViewModel extends ViewModel { } private readonly routerService: RouterService; + + private readonly resourceService: ResourceService; private readonly settingsService: SettingsService; private readonly fetchService: FetchService; private readonly seriesService: SeriesService; @@ -44,6 +47,8 @@ export class SyncViewModel extends ViewModel { super(); this.routerService = this.ctx.getService(RouterService); + + this.resourceService = this.ctx.getService(ResourceService); this.settingsService = this.ctx.getService(SettingsService); this.fetchService = this.ctx.getService(FetchService); this.seriesService = this.ctx.getService(SeriesService); @@ -87,16 +92,25 @@ export class SyncViewModel extends ViewModel { try { if (!await this.seriesService.existByGUID(guid)) { - const [fetchedSeries, fetchedGenres] = await this.fetchService.getSeries(guid); + const [fetchedSeries, fetchedGenres, previewImage] = await this.fetchService.getSeries(guid); const series: SeriesModel = await this.seriesService.insertSeries(fetchedSeries.guid, fetchedSeries.title, fetchedSeries.description, fetchedSeries.preview_image); - await Promise.all(fetchedGenres.map(fetchedGenre => (async () => { - let genre: GenreModel | null = await this.genreService.getGenreByKey(fetchedGenre.key); - if (!genre) { - genre = await this.genreService.insertGenre(fetchedGenre.key); - } - - await this.genreService.insertGenreToSeries(genre, series, fetchedGenre.main) - })())); + await Promise.all([ + ...fetchedGenres.map(fetchedGenre => (async () => { + let genre: GenreModel | null = await this.genreService.getGenreByKey(fetchedGenre.key); + if (!genre) { + genre = await this.genreService.insertGenre(fetchedGenre.key); + } + + await this.genreService.insertGenreToSeries(genre, series, fetchedGenre.main) + })()), + ((async () => { + if (!fetchedSeries.preview_image || !previewImage) { + return; + } + + await this.resourceService.saveResource(fetchedSeries.preview_image, previewImage); + })()) + ]); } } catch (e) { console.error(e); diff --git a/src/views/SyncView.vue b/app/src/views/SyncView.vue similarity index 100% rename from src/views/SyncView.vue rename to app/src/views/SyncView.vue diff --git a/src/views/WatchlistView.model.ts b/app/src/views/WatchlistView.model.ts similarity index 90% rename from src/views/WatchlistView.model.ts rename to app/src/views/WatchlistView.model.ts index f00bf19..78a5e00 100644 --- a/src/views/WatchlistView.model.ts +++ b/app/src/views/WatchlistView.model.ts @@ -7,16 +7,15 @@ import WatchlistView from "@views/WatchlistView.vue"; import {DetailControlModel} from "@controls/DetailControl.model"; -import {ProviderService} from "@contracts/provider.contract"; import {SeriesService} from "@contracts/series.contract"; import {WatchlistService} from "@contracts/watchlist.contract"; import {ListService} from "@contracts/list.contract"; import {I18nService} from "@contracts/i18n.contract"; +import {ResourceService} from "@contracts/resource.contract"; import {SeriesModel} from "@models/series.model"; import {ListModel} from "@models/list.model"; -import {DefaultProvider} from "@providers/default"; import {ListViewModel} from "@views/ListView.model"; import I18n from "@utils/i18n"; @@ -30,7 +29,7 @@ export class WatchlistViewModel extends ViewModel { private readonly routerService: RouterService; private readonly dialogService: DialogService; - private readonly providerService: ProviderService; + private readonly resourceService: ResourceService; private readonly seriesService: SeriesService; private readonly watchlistService: WatchlistService; private readonly listService: ListService; @@ -49,7 +48,7 @@ export class WatchlistViewModel extends ViewModel { this.routerService = this.ctx.getService(RouterService); this.dialogService = this.ctx.getService(DialogService); - this.providerService = this.ctx.getService(ProviderService); + this.resourceService = this.ctx.getService(ResourceService); this.seriesService = this.ctx.getService(SeriesService); this.watchlistService = this.ctx.getService(WatchlistService); this.listService = this.ctx.getService(ListService); @@ -57,8 +56,7 @@ export class WatchlistViewModel extends ViewModel { } protected async mounted(): Promise { - const provider: DefaultProvider = await this.providerService.getProvider(); - this.providerFolder = await provider.getStorageLocation(); + this.providerFolder = await this.resourceService.getResourceLocation(); this.startedSeries = await this.seriesService.getStartedSeries(); diff --git a/src/views/WatchlistView.vue b/app/src/views/WatchlistView.vue similarity index 100% rename from src/views/WatchlistView.vue rename to app/src/views/WatchlistView.vue diff --git a/src/vite-env.d.ts b/app/src/vite-env.d.ts similarity index 84% rename from src/vite-env.d.ts rename to app/src/vite-env.d.ts index db33cd9..985c1ce 100644 --- a/src/vite-env.d.ts +++ b/app/src/vite-env.d.ts @@ -12,4 +12,4 @@ declare module "virtual:services" { export const services: Record>; } -declare const APPLICATION_TARGET: "standalone"; +declare const APPLICATION_TARGET: "standalone" | "client" | "worker"; diff --git a/app/src/worker.ts b/app/src/worker.ts new file mode 100644 index 0000000..79d3489 --- /dev/null +++ b/app/src/worker.ts @@ -0,0 +1,255 @@ +import {type App, type Plugin} from "vue"; +import {createMVVM, DIContainer} from "vue-mvvm"; + +import {DOMParser} from "linkedom"; +import {Command} from "commander"; +import chalk from "chalk"; +import ora, {type Ora} from "ora"; + +import {WorkerConfig} from "@configs/worker"; + +import {ProviderService} from "@contracts/provider.contract"; +import {Provider} from "@contracts/fetch.contract"; + +import {DefaultProvider, EpisodeLanguage, IInformationFetcher} from "@providers/default"; + +import {SeriesModel} from "@models/series.model"; +import {SeasonFetchModel} from "@models/season.model"; +import {EpisodeFetchModel} from "@models/episode.model"; + +// @ts-expect-error +globalThis.DOMParser = DOMParser; + +const container: DIContainer = new DIContainer(); +const config: WorkerConfig = new WorkerConfig(); +const plugin: Plugin = createMVVM(config, { + context: container +}); + +if (!plugin.install) { + throw new Error("Failed to setup MVVM context"); +} + +plugin.install(null as unknown as App); + +const providerService: ProviderService = container.getService(ProviderService); + +interface CliOptions { + provider: string; + output: "text" | "json"; +} + +class OutputHandler { + private spinner: Ora | null = null; + public readonly isJson: boolean = false; + + constructor(format: string) { + this.isJson = format == "json"; + } + + startSpinner(text: string): void { + if (this.isJson) { + return; + } + + this.spinner = ora(text).start(); + } + + stopSpinner(success: boolean = true, text?: string): void { + if (!this.spinner) { + return; + } + + if (success) { + this.spinner.succeed(text); + } else { + this.spinner.fail(text); + } + this.spinner = null; + } + + log(message: string): void { + if (this.isJson) { + return; + } + + console.log(message); + } + + result(data: unknown, textFormatter: () => void): void { + if (this.isJson) { + console.log(JSON.stringify(data)); + } else { + textFormatter(); + } + } + + error(message: string): void { + if (this.isJson) { + console.error(JSON.stringify({error: message})); + } else { + console.error(chalk.red.bold("Error: ") + chalk.red(message)); + } + } +} + +const program = new Command(); + +program + .name("worker") + .description("AniStream Worker CLI") + .version("1.0.0") + .requiredOption("-p, --provider ", "Provider name") + .option("-o, --output ", "Output format (text, json)", "text"); + +async function getFetcher(options: CliOptions): Promise { + const providerName: string = options.provider.toLowerCase(); + const provider: DefaultProvider | undefined = providerService.ALL_PROVIDERS.find(p => p.uniqueKey == providerName); + + if (!provider) { + throw new Error(`Provider ${providerName} not found. Available: ${providerService.ALL_PROVIDERS.map(p => p.uniqueKey).join(", ")}`); + } + + return provider.getFetcher(); +} + +program + .command("catalog") + .description("Get catalog from provider") + .action(async () => { + const options: CliOptions = program.opts(); + const out: OutputHandler = new OutputHandler(options.output); + try { + const fetcher: IInformationFetcher = await getFetcher(options); + out.startSpinner("Fetching catalog..."); + const catalog: string[] = await fetcher.getCatalog(); + out.stopSpinner(true, "Catalog fetched"); + out.result(catalog, () => { + out.log(chalk.blue.bold("\nCatalog:")); + catalog.forEach(item => console.log(chalk.gray(" - ") + chalk.white(item))); + out.log(`\nTotal: ${chalk.green(catalog.length)} items`); + }); + } catch (e: unknown) { + const message: string = e instanceof Error ? e.message : String(e); + out.stopSpinner(false); + out.error(message); + } + }); + +program + .command("series ") + .description("Get series details") + .action(async (guid: string) => { + const options: CliOptions = program.opts(); + const out: OutputHandler = new OutputHandler(options.output); + try { + const fetcher: IInformationFetcher = await getFetcher(options); + out.startSpinner(`Fetching series ${guid}...`); + const [model, genres] = await fetcher.getSeries(guid); + out.stopSpinner(true, `Series ${guid} fetched`); + out.result({series: model, genres}, () => { + out.log(chalk.green.bold(`\n${model.title}`)); + out.log(chalk.gray("=".repeat(model.title.length))); + out.log(`${chalk.yellow.bold("GUID: ")} ${chalk.cyan(model.guid)}`); + out.log(`${chalk.yellow.bold("Genres: ")} ${chalk.magenta(genres.map(g => g.key).join(", "))}`); + out.log(`${chalk.yellow.bold("Description: ")} ${chalk.white(model.description)}`); + if (model.preview_image) { + out.log(`${chalk.yellow.bold("Preview: ")} <${chalk.blue(model.preview_image)}>`); + } + out.log(""); + }); + } catch (e: unknown) { + const message: string = e instanceof Error ? e.message : String(e); + out.stopSpinner(false); + out.error(message); + } + }); + +program + .command("seasons ") + .description("Get seasons for a series") + .action(async (guid: string) => { + const options: CliOptions = program.opts(); + const out: OutputHandler = new OutputHandler(options.output); + try { + const fetcher: IInformationFetcher = await getFetcher(options); + out.startSpinner(`Fetching seasons for ${guid}...`); + // We need a SeriesModel, so we create a dummy one with the GUID + const series: SeriesModel = SeriesModel(guid, "", "", ""); + const seasons: SeasonFetchModel[] = await fetcher.getSeasons(series); + out.stopSpinner(true, `Seasons for ${guid} fetched`); + out.result(seasons, () => { + out.log(chalk.blue.bold(`\nSeasons for ${guid}:`)); + seasons.forEach(s => { + const label = s.season_number == 0 ? "Movies / Specials" : `Season ${s.season_number}`; + console.log(chalk.gray(" - ") + chalk.white(label)); + }); + out.log(`\nTotal: ${chalk.green(seasons.length)} seasons`); + }); + } catch (e: unknown) { + const message: string = e instanceof Error ? e.message : String(e); + out.stopSpinner(false); + out.error(message); + } + }); + +program + .command("episodes ") + .description("Get episodes for a season") + .action(async (guid: string, seasonNumber: string) => { + const options: CliOptions = program.opts(); + const out: OutputHandler = new OutputHandler(options.output); + try { + const fetcher: IInformationFetcher = await getFetcher(options); + const sNum: number = parseInt(seasonNumber); + out.startSpinner(`Fetching episodes for ${guid} S${sNum}...`); + const episodes: EpisodeFetchModel[] = await fetcher.getEpisodes(guid, sNum); + out.stopSpinner(true, `Episodes for ${guid} S${sNum} fetched`); + out.result(episodes, () => { + out.log(chalk.blue.bold(`\nEpisodes for ${guid} - Season ${sNum}:`)); + episodes.forEach(e => { + const epNum = chalk.yellow(`E${e.episode_number.toString().padStart(2, "0")}`); + const titles = `${chalk.white.bold(e.german_title)} ${chalk.gray("/")} ${chalk.white(e.english_title)}`; + console.log(` ${chalk.gray("-")} ${epNum}: ${titles}`); + }); + out.log(`\nTotal: ${chalk.green(episodes.length)} episodes`); + }); + } catch (e: unknown) { + const message: string = e instanceof Error ? e.message : String(e); + out.stopSpinner(false); + out.error(message); + } + }); + +program + .command("providers ") + .description("Fetch video providers for an episode") + .action(async (guid: string, seasonNumber: string, episodeNumber: string) => { + const options: CliOptions = program.opts(); + const out: OutputHandler = new OutputHandler(options.output); + try { + const fetcher: IInformationFetcher = await getFetcher(options); + + const sNum: number = parseInt(seasonNumber); + const eNum: number = parseInt(episodeNumber); + + out.startSpinner(`Fetching providers for ${guid} S${sNum}E${eNum}...`); + const providers: Provider[] = await fetcher.fetchProviders(guid, sNum, eNum); + out.stopSpinner(true, `Providers for ${guid} S${sNum}E${eNum} fetched`); + out.result(providers, () => { + out.log(chalk.blue.bold(`\nProviders for ${guid} S${sNum}E${eNum}:`)); + providers.forEach(p => { + const lang = chalk.magenta(`[${EpisodeLanguage[p.language] || p.language}]`); + const name = chalk.green.bold(p.name.padEnd(15)); + console.log(` ${chalk.gray("-")} ${name} ${lang} ${chalk.cyan(p.embeddedURL)}`); + }); + out.log(`\nTotal: ${chalk.green(providers.length)} providers`); + }); + } catch (e: unknown) { + const message: string = e instanceof Error ? e.message : String(e); + out.stopSpinner(false); + out.error(message); + } + }); + +await program.parseAsync(process.argv); \ No newline at end of file diff --git a/app/tests/mocks/client/api.service.ts b/app/tests/mocks/client/api.service.ts new file mode 100644 index 0000000..246aef0 --- /dev/null +++ b/app/tests/mocks/client/api.service.ts @@ -0,0 +1,87 @@ +import {ApiServiceImpl} from "@services/api/api.service"; + +import {PathParameter} from "@contracts/client/api.contract"; + +import * as http from "@utils/http"; + +export class ApiServiceMock extends ApiServiceImpl { + public async get(def: PathParameter): Promise { + const url: string = this.buildURL(def); + + const res: globalThis.Response = await fetch(url, { + method: "GET", + headers: ApiServiceImpl.HEADERS + }); + + if (!res.ok) { + throw await http.HTTPError.create(res); + } + + return res.json(); + } + + public async post(def: PathParameter, body: Body): Promise { + let data: string | undefined; + + if (body == null) { + data = undefined; + } else if (typeof body == "object") { + data = JSON.stringify(body); + } else { + data = body; + } + + const url: string = this.buildURL(def); + + const res: globalThis.Response = await fetch(url, { + method: "POST", + body: data, + headers: ApiServiceImpl.HEADERS + }); + + if (!res.ok) { + throw await http.HTTPError.create(res); + } + + return await res.json(); + } + + public async put(def: PathParameter, body: Body): Promise { + let data: string; + + if (typeof body == "object") { + data = JSON.stringify(body); + } else { + data = body; + } + + const url: string = this.buildURL(def); + + const res: globalThis.Response = await fetch(url, { + method: "PUT", + body: data, + headers: ApiServiceImpl.HEADERS + }); + + if (!res.ok) { + throw await http.HTTPError.create(res); + } + + return await res.json(); + } + + public async delete(def: PathParameter): Promise { + const url: string = this.buildURL(def); + + const res: globalThis.Response = await fetch(url, { + method: "DELETE", + headers: ApiServiceImpl.HEADERS + }); + + if (!res.ok) { + throw await http.HTTPError.create(res); + } + + return await res.json(); + } +} \ No newline at end of file diff --git a/app/tests/mocks/standalone/db.ts b/app/tests/mocks/standalone/db.ts new file mode 100644 index 0000000..3baa7d8 --- /dev/null +++ b/app/tests/mocks/standalone/db.ts @@ -0,0 +1,82 @@ +import NodeDatabase from "better-sqlite3"; + +import Database, {QueryResult} from "@tauri-apps/plugin-sql"; + +type TauriDatabase = Database; + +export class DatabaseWrapper implements TauriDatabase { + private _handler: NodeDatabase.Database; + private _closed: boolean; + + public readonly path: string; + + public get closed(): boolean { + return this._closed; + } + + public constructor(path: string = ":memory:") { + this.path = path; + + this._handler = new NodeDatabase(this.path); + this._closed = false; + } + + async execute(query: string, bindValues: unknown[] = []): Promise { + let startArgs: number = 0; + + let rowsAffected: number = 0; + let lastInsertRowId: number | undefined = undefined; + + for (let sql of query.split(";")) { + sql = sql.trim(); + if (!sql) { + continue; + } + + let argsAmount: number = (sql.match(/\?/g) || []).length; + + let args: unknown[]; + if (argsAmount > 0) { + args = bindValues.slice(startArgs, startArgs + argsAmount).map(x => typeof x == "boolean" + ? x + ? "true" + : "false" + : x + ); + startArgs += argsAmount; + } else { + args = []; + } + + const stmt: NodeDatabase.Statement = this._handler.prepare(sql); + const result: NodeDatabase.RunResult = stmt.run(...args); + + if (result.lastInsertRowid && typeof result.lastInsertRowid != "bigint") { + lastInsertRowId = result.lastInsertRowid; + } + rowsAffected += result.changes; + } + + return { + rowsAffected: rowsAffected, + lastInsertId: lastInsertRowId, + } + } + + async select(query: string, bindValues: unknown[] = []): Promise { + const stmt: NodeDatabase.Statement = this._handler.prepare(query); + const rows: unknown[] = stmt.all(...bindValues); + + return rows as T; + } + + async close(): Promise { + try { + this._handler.close(); + this._closed = true; + return true; + } catch (e) { + return false; + } + } +} \ No newline at end of file diff --git a/app/tests/mocks/standalone/metadata.service.ts b/app/tests/mocks/standalone/metadata.service.ts new file mode 100644 index 0000000..51eccd9 --- /dev/null +++ b/app/tests/mocks/standalone/metadata.service.ts @@ -0,0 +1,30 @@ +import {DbSession} from "@services/utils/db"; +import {MetadataDbServiceImpl} from "@services/db/metadata.service"; + +import {DatabaseWrapper} from "@test/mocks/standalone/db"; +import {ReadableGlobalContext} from "vue-mvvm"; + +export class MetadataDbServiceMock extends MetadataDbServiceImpl { + private databases: Map = new Map(); + + public constructor(ctx: ReadableGlobalContext) { + super(ctx); + + this.databases = new Map(); + } + + public async openDB(file: string, provider: string): Promise { + const existingDatabase: DatabaseWrapper | undefined = this.databases.get(file); + + if (!!existingDatabase && !existingDatabase.closed) { + return new DbSession(existingDatabase); + } + + const handler: DatabaseWrapper = new DatabaseWrapper(); + const session: DbSession = new DbSession(handler); + + await this.beginMigration(session, provider); + + return session; + } +} \ No newline at end of file diff --git a/app/tests/mocks/standalone/user.service.ts b/app/tests/mocks/standalone/user.service.ts new file mode 100644 index 0000000..406f652 --- /dev/null +++ b/app/tests/mocks/standalone/user.service.ts @@ -0,0 +1,41 @@ +import {DbSession} from "@services/utils/db"; +import {UserDbServiceImpl} from "@services/db/user.service"; + +import {DatabaseWrapper} from "@test/mocks/standalone/db"; + +export class UserDbServiceMock extends UserDbServiceImpl { + private databases: Map = new Map(); + + public constructor() { + super(); + + this.databases = new Map(); + } + + public async openDB(file: string): Promise { + const existingDatabase: DatabaseWrapper | undefined = this.databases.get(file); + + if (!!existingDatabase && !existingDatabase.closed) { + return new DbSession(existingDatabase); + } + + const handler: DatabaseWrapper = new DatabaseWrapper(); + const session: DbSession = new DbSession(handler); + + await this.beginMigration(session); + // await this.insertMigrationProfile(session); + + return session; + } + /* + private async insertMigrationProfile(session: DbSession): Promise { + // language=SQLite + await session.execute(` + INSERT INTO profile (uuid, name, background_color, eye, mouth, theme, lang, tos_accepted, + sync_catalog) + VALUES ('11111111-1111-1111-1111-111111111111', 'Migration', 'FFFFFF', 'eye', 'mouth', + 'aniworld-dark', 'en', 0, 0) + `); + } + */ +} diff --git a/app/tests/mocks/user.service.ts b/app/tests/mocks/user.service.ts new file mode 100644 index 0000000..e9f3dd8 --- /dev/null +++ b/app/tests/mocks/user.service.ts @@ -0,0 +1,49 @@ +import {UserServiceImpl} from "@services/user.service"; +import {ProfileModel} from "@models/profile.model"; + +export class UserServiceMock extends UserServiceImpl { + public async getActiveProfile(): Promise { + return ProfileModel( + 1, + "11111111-1111-1111-1111-111111111111", + "Migration", + "FFFFFF", + "eva", + "bite", + "aniworld-dark", + "en", + true, + false + ); + } + + public async getActiveProfileOrDefault(): Promise { + return ProfileModel( + 1, + "11111111-1111-1111-1111-111111111111", + "Migration", + "FFFFFF", + "eva", + "bite", + "aniworld-dark", + "en", + true, + false + ); + } + + public async getMigrationProfile(): Promise { + return ProfileModel( + 1, + "11111111-1111-1111-1111-111111111111", + "Migration", + "FFFFFF", + "eva", + "bite", + "aniworld-dark", + "en", + true, + false + ); + } +} \ No newline at end of file diff --git a/app/tests/services/episode.test.ts b/app/tests/services/episode.test.ts new file mode 100644 index 0000000..cda3d48 --- /dev/null +++ b/app/tests/services/episode.test.ts @@ -0,0 +1,84 @@ +import {expect} from "vitest"; + +import {TestBase, TestDefinition} from "@test/suite"; + +import {EpisodeService} from "@contracts/episode.contract"; +import {SeasonService} from "@contracts/season.contract"; +import {SeriesService} from "@contracts/series.contract"; + +import type {EpisodeModel} from "@models/episode.model"; +import type {SeasonModel} from "@models/season.model"; +import type {SeriesModel} from "@models/series.model"; + +class EpisodeTests extends TestBase { + private get episodeService(): EpisodeService { + return this.getService(EpisodeService); + } + + private get seasonService(): SeasonService { + return this.getService(SeasonService); + } + + private get seriesService(): SeriesService { + return this.getService(SeriesService); + } + + private async createEpisode() { + const series: SeriesModel = await this.seriesService.insertSeries("a-series", "A Series", "Desc", null); + const season: SeasonModel = await this.seasonService.insertSeason(series.series_id, 1); + + const episode: EpisodeModel = await this.episodeService.insertEpisode(season.season_id, 1, "DE", "EN", "Desc"); + + expect(episode.episode_id).toBe(1); + expect(episode.season_id).toBe(season.season_id); + expect(episode.episode_number).toBe(1); + } + + private async getEpisode() { + const series: SeriesModel = await this.seriesService.insertSeries("a-series", "A Series", "Desc", null); + const season: SeasonModel = await this.seasonService.insertSeason(series.series_id, 1); + const episode: EpisodeModel = await this.episodeService.insertEpisode(season.season_id, 1, "DE", "EN", "Desc"); + + const loaded: EpisodeModel | null = await this.episodeService.getEpisode(episode.episode_id); + + expect(loaded).not.toBeNull(); + expect(loaded!.episode_id).toBe(episode.episode_id); + } + + private async getEpisodes() { + const series: SeriesModel = await this.seriesService.insertSeries("a-series", "A Series", "Desc", null); + const season: SeasonModel = await this.seasonService.insertSeason(series.series_id, 1); + await this.episodeService.insertEpisode(season.season_id, 1, "DE", "EN", "Desc"); + + const episodes: EpisodeModel[] = await this.episodeService.getEpisodes(season.season_id); + + expect(episodes).toHaveLength(1); + expect(episodes[0].episode_number).toBe(1); + } + + private async updateEpisodeMetadata() { + const series: SeriesModel = await this.seriesService.insertSeries("a-series", "A Series", "Desc", null); + const season: SeasonModel = await this.seasonService.insertSeason(series.series_id, 1); + const episode: EpisodeModel = await this.episodeService.insertEpisode(season.season_id, 1, "DE", "EN", "Desc"); + + await this.episodeService.updateEpisodeMetadata(episode.episode_id, "DE2", "EN2", "Desc2"); + + const updated: EpisodeModel | null = await this.episodeService.getEpisode(episode.episode_id); + + expect(updated).not.toBeNull(); + expect(updated!.german_title).toBe("DE2"); + expect(updated!.english_title).toBe("EN2"); + expect(updated!.description).toBe("Desc2"); + } + + public getTests(): TestDefinition[] { + return [ + ["CreateEpisode", this.createEpisode], + ["GetEpisode", this.getEpisode], + ["GetEpisodes", this.getEpisodes], + ["UpdateEpisodeMetadata", this.updateEpisodeMetadata] + ]; + } +} + +TestBase.register(EpisodeTests); diff --git a/app/tests/services/genre.test.ts b/app/tests/services/genre.test.ts new file mode 100644 index 0000000..df38014 --- /dev/null +++ b/app/tests/services/genre.test.ts @@ -0,0 +1,111 @@ +import {expect} from "vitest"; + +import {TestBase, TestDefinition} from "@test/suite"; + +import {GenreService} from "@contracts/genre.contract"; +import {SeriesService} from "@contracts/series.contract"; + +import type {GenreModel} from "@models/genre.model"; +import type {SeriesModel} from "@models/series.model"; + +class GenreTests extends TestBase { + private get genreService(): GenreService { + return this.getService(GenreService); + } + + private get seriesService(): SeriesService { + return this.getService(SeriesService); + } + + private async createGenre() { + const genre: GenreModel = await this.genreService.insertGenre("action"); + + expect(genre.genre_id).toBe(1); + expect(genre.key).toBe("action"); + } + + private async createGenreToSeries() { + const series: SeriesModel = await this.seriesService.insertSeries("s-1", "Series", "Description", null); + const genre: GenreModel = await this.genreService.insertGenre("main"); + + await this.genreService.insertGenreToSeries(genre, series, true); + + const mainGenre: GenreModel | null = await this.genreService.getMainGenreOfSeries(series.series_id); + + expect(mainGenre).not.toBeNull(); + expect(mainGenre!.genre_id).toBe(genre.genre_id); + } + + private async getGenres() { + const genre: GenreModel = await this.genreService.insertGenre("action"); + + const genres: GenreModel[] = await this.genreService.getGenres(); + + expect(genres).toHaveLength(1); + expect(genres[0].genre_id).toBe(genre.genre_id); + } + + private async getGenreById() { + const genre: GenreModel = await this.genreService.insertGenre("action"); + + const byId: GenreModel[] = await this.genreService.getGenres(); + + expect(byId).toHaveLength(1); + expect(byId[0].genre_id).toBe(genre.genre_id); + } + + private async getGenreByKey() { + const genre: GenreModel = await this.genreService.insertGenre("action"); + + const byKey: GenreModel | null = await this.genreService.getGenreByKey(genre.key); + + expect(byKey).not.toBeNull(); + expect(byKey!.key).toBe(genre.key); + } + + private async getMainGenreOfSeries() { + await this.insertSeriesWithGenres(); + + const mainGenre: GenreModel | null = await this.genreService.getMainGenreOfSeries(1); + + expect(mainGenre).not.toBeNull(); + expect(mainGenre!.genre_id).toBe(1); + } + + private async getNonMainGenresOfSeries() { + await this.insertSeriesWithGenres(); + + const nonMainGenres: GenreModel[] = await this.genreService.getNonMainGenresOfSeries(1); + + expect(nonMainGenres).toHaveLength(1); + expect(nonMainGenres[0].genre_id).toBe(2); + } + + private async insertSeriesWithGenres() { + const series: SeriesModel = await this.seriesService.insertSeries("s-1", "Series", "Description", null); + const main: GenreModel = await this.genreService.insertGenre("main"); + const side: GenreModel = await this.genreService.insertGenre("side"); + + await this.genreService.insertGenreToSeries(main, series, true); + await this.genreService.insertGenreToSeries(side, series, false); + + expect(series.series_id).toBe(1); + expect(main.genre_id).toBe(1); + expect(side.genre_id).toBe(2); + } + + public getTests(): TestDefinition[] { + return [ + ["CreateGenre", this.createGenre], + ["CreateGenreToSeries", this.createGenreToSeries], + ["GetGenres", this.getGenres], + ["GetGenreById", this.getGenreById], + ["GetGenreByKey", this.getGenreByKey], + ["GetMainGenreOfSeries", this.getMainGenreOfSeries], + ["GetNonMainGenresOfSeries", this.getNonMainGenresOfSeries] + ]; + } + +} + +TestBase.register(GenreTests); \ No newline at end of file diff --git a/app/tests/services/list.test.ts b/app/tests/services/list.test.ts new file mode 100644 index 0000000..28a9620 --- /dev/null +++ b/app/tests/services/list.test.ts @@ -0,0 +1,181 @@ +import {expect} from "vitest"; + +import {TestBase, TestDefinition} from "@test/suite"; + +import {ListService} from "@contracts/list.contract"; +import {SeriesService} from "@contracts/series.contract"; + +import type {ListModel} from "@models/list.model"; +import type {SeriesModel} from "@models/series.model"; + +class ListTests extends TestBase { + private get listService(): ListService { + return this.getService(ListService); + } + + private get seriesService(): SeriesService { + return this.getService(SeriesService); + } + + private async getLists() { + await this.listService.createList("watchlist"); + await this.listService.createList("favorites"); + + const lists: ListModel[] = await this.listService.getLists(); + + expect(lists).toHaveLength(2); + const names = lists.map(l => l.name); + expect(names).toContain("watchlist"); + expect(names).toContain("favorites"); + } + + private async getList() { + const created: ListModel = await this.listService.createList("watchlist"); + + const list: ListModel | null = await this.listService.getList(created.list_id); + + expect(list).not.toBeNull(); + expect(list!.list_id).toBe(created.list_id); + expect(list!.name).toBe("watchlist"); + } + + private async getListsOfSeries() { + const series = await this.createSeries(); + const list1: ListModel = await this.listService.createList("list1"); + const list2: ListModel = await this.listService.createList("list2"); + + await this.listService.addSeriesToList(series.series_id, list1.list_id); + await this.listService.addSeriesToList(series.series_id, list2.list_id); + + const lists: ListModel[] = await this.listService.getListsOfSeries(series.series_id); + + expect(lists).toHaveLength(2); + const names = lists.map(l => l.name); + expect(names).toContain("list1"); + expect(names).toContain("list2"); + } + + private async createList() { + const list: ListModel = await this.listService.createList("watchlist"); + + expect(list.list_id).toBe(1); + expect(list.name).toBe("watchlist"); + } + + private async updateList() { + const created: ListModel = await this.listService.createList("watchlist"); + + await this.listService.updateList(created.list_id, "updated"); + + const fetched: ListModel | null = await this.listService.getList(created.list_id); + expect(fetched!.name).toBe("updated"); + } + + private async updateListByModel() { + const created: ListModel = await this.listService.createList("watchlist"); + + await this.listService.updateList(created.list_id, "updated"); + + const fetched: ListModel | null = await this.listService.getList(created.list_id); + expect(fetched!.name).toBe("updated"); + } + + private async deleteList() { + const created: ListModel = await this.listService.createList("watchlist"); + + await this.listService.deleteList(created.list_id); + + const list: ListModel | null = await this.listService.getList(created.list_id); + expect(list).toBeNull(); + } + + private async deleteListByModel() { + const created: ListModel = await this.listService.createList("watchlist"); + + await this.listService.deleteList(created.list_id); + + const list: ListModel | null = await this.listService.getList(created.list_id); + expect(list).toBeNull(); + } + + private async getSeriesInList() { + const list: ListModel = await this.listService.createList("watchlist"); + const series = await this.createSeries(); + await this.listService.addSeriesToList(series.series_id, list.list_id); + + const seriesInList: SeriesModel[] = await this.listService.getSeries(list.list_id); + + expect(seriesInList).toHaveLength(1); + expect(seriesInList[0].series_id).toBe(series.series_id); + } + + private async removeSeriesFromList() { + const list: ListModel = await this.listService.createList("watchlist"); + const series = await this.createSeries(); + await this.listService.addSeriesToList(series.series_id, list.list_id); + + await this.listService.removeSeriesFromList(series.series_id, list.list_id); + + const seriesInList: SeriesModel[] = await this.listService.getSeries(list.list_id); + expect(seriesInList).toHaveLength(0); + } + + private async removeSeriesFromListByModel() { + const list: ListModel = await this.listService.createList("watchlist"); + const series = await this.createSeries(); + await this.listService.addSeriesToList(series.series_id, list.list_id); + + await this.listService.removeSeriesFromList(series.series_id, list.list_id); + + const seriesInList: SeriesModel[] = await this.listService.getSeries(list.list_id); + expect(seriesInList).toHaveLength(0); + } + + private async getPreviewHashes() { + const list: ListModel = await this.listService.createList("watchlist"); + const series = await this.createSeries(); + await this.listService.addSeriesToList(series.series_id, list.list_id); + + const images: string[] = await this.listService.getPreviewHashes(list.list_id); + + expect(images).toHaveLength(1); + expect(images[0]).toBe("ABCDEFG"); + } + + private async getPreviewHashesByModel() { + const list: ListModel = await this.listService.createList("watchlist"); + const series = await this.createSeries(); + await this.listService.addSeriesToList(series.series_id, list.list_id); + + const images: string[] = await this.listService.getPreviewHashes(list.list_id); + + expect(images).toHaveLength(1); + expect(images[0]).toBe("ABCDEFG"); + } + + private async createSeries(): Promise { + const series: SeriesModel = await this.seriesService.insertSeries("guid", "Title", "Desc", "ABCDEFG"); + expect(series.series_id).toBeGreaterThan(0); + return series; + } + + public getTests(): TestDefinition[] { + return [ + ["GetLists", this.getLists], + ["GetList", this.getList], + ["GetListsOfSeries", this.getListsOfSeries], + ["CreateList", this.createList], + ["UpdateList", this.updateList], + ["UpdateListByModel", this.updateListByModel], + ["DeleteList", this.deleteList], + ["DeleteListByModel", this.deleteListByModel], + ["GetSeriesInList", this.getSeriesInList], + ["RemoveSeriesFromList", this.removeSeriesFromList], + ["RemoveSeriesFromListByModel", this.removeSeriesFromListByModel], + ["GetPreviewHashes", this.getPreviewHashes], + ["GetPreviewHashesByModel", this.getPreviewHashesByModel] + ]; + } +} + +TestBase.register(ListTests); diff --git a/app/tests/services/provider.test.ts b/app/tests/services/provider.test.ts new file mode 100644 index 0000000..337ff10 --- /dev/null +++ b/app/tests/services/provider.test.ts @@ -0,0 +1,38 @@ +import {expect} from "vitest"; + +import {TestBase, TestDefinition} from "@test/suite"; + +import {ProviderService} from "@contracts/provider.contract"; + +class ProviderTests extends TestBase { + // ProviderServiceTests in .NET uses base(false) which means it doesn't set a default provider in setUp. + public constructor() { + super(false); + } + + private get providerService(): ProviderService { + return this.getService(ProviderService); + } + + private async rejectsGettingNotSetProvider() { + // In JS, getProvider() returns a Promise. + // Let's see if it throws when no provider is set. + await expect(this.providerService.getProvider()).rejects.toThrow(); + } + + private async setActiveProvider() { + await this.providerService.setProvider(this.providerService.STO); + + const active = await this.providerService.getProvider(); + expect(active).toBe(this.providerService.STO); + } + + public getTests(): TestDefinition[] { + return [ + ["RejectsGettingNotSetProvider", this.rejectsGettingNotSetProvider], + ["SetActiveProvider", this.setActiveProvider] + ]; + } +} + +TestBase.register(ProviderTests); diff --git a/app/tests/services/season.test.ts b/app/tests/services/season.test.ts new file mode 100644 index 0000000..ce1c029 --- /dev/null +++ b/app/tests/services/season.test.ts @@ -0,0 +1,61 @@ +import {expect} from "vitest"; + +import {TestBase, TestDefinition} from "@test/suite"; + +import {SeasonService} from "@contracts/season.contract"; +import {SeriesService} from "@contracts/series.contract"; + +import type {SeasonModel} from "@models/season.model"; +import type {SeriesModel} from "@models/series.model"; + +class SeasonTests extends TestBase { + private get seasonService(): SeasonService { + return this.getService(SeasonService); + } + + private get seriesService(): SeriesService { + return this.getService(SeriesService); + } + + private async createSeason() { + const series: SeriesModel = await this.seriesService.insertSeries("g-ep", "Episodes", "Desc", null); + expect(series.series_id).toBe(1); + + const season: SeasonModel = await this.seasonService.insertSeason(series.series_id, 1); + expect(season.season_id).toBe(1); + expect(season.series_id).toBe(series.series_id); + expect(season.season_number).toBe(1); + } + + private async getSeasons() { + const series: SeriesModel = await this.seriesService.insertSeries("g-ep", "Episodes", "Desc", null); + await this.seasonService.insertSeason(series.series_id, 1); + + const bySeriesId: SeasonModel[] = await this.seasonService.getSeasons(series.series_id); + + expect(bySeriesId).toHaveLength(1); + expect(bySeriesId[0].season_id).toBe(1); + expect(bySeriesId[0].season_number).toBe(1); + } + + private async getSeason() { + const series: SeriesModel = await this.seriesService.insertSeries("g-ep", "Episodes", "Desc", null); + const season: SeasonModel = await this.seasonService.insertSeason(series.series_id, 1); + + const byId: SeasonModel | null = await this.seasonService.getSeason(season.season_id); + + expect(byId).not.toBeNull(); + expect(byId!.season_id).toBe(season.season_id); + expect(byId!.season_number).toBe(season.season_number); + } + + public getTests(): TestDefinition[] { + return [ + ["CreateSeason", this.createSeason], + ["GetSeasons", this.getSeasons], + ["GetSeason", this.getSeason] + ]; + } +} + +TestBase.register(SeasonTests); diff --git a/app/tests/services/series.test.ts b/app/tests/services/series.test.ts new file mode 100644 index 0000000..c693109 --- /dev/null +++ b/app/tests/services/series.test.ts @@ -0,0 +1,92 @@ +import {expect} from "vitest"; + +import {TestBase, TestDefinition} from "@test/suite"; + +import {SeriesService} from "@contracts/series.contract"; +import {GenreService} from "@contracts/genre.contract"; + +import type {SeriesModel} from "@models/series.model"; +import type {GenreModel} from "@models/genre.model"; + +class SeriesTests extends TestBase { + private get seriesService(): SeriesService { + return this.getService(SeriesService); + } + + private get genreService(): GenreService { + return this.getService(GenreService); + } + + private async createSeries() { + const series: SeriesModel = await this.seriesService.insertSeries("a-series", "A Series", "Desc", null); + expect(series.series_id).toBe(1); + expect(series.title).toBe("A Series"); + expect(series.description).toBe("Desc"); + expect(series.preview_image).toBeNull(); + } + + private async getSeriesById() { + const created: SeriesModel = await this.seriesService.insertSeries("g-a", "Alpha Show", "", null); + + const series: SeriesModel | null = await this.seriesService.getSeries(created.series_id); + + expect(series).not.toBeNull(); + expect(series!.series_id).toBe(created.series_id); + } + + private async getSeriesByGuid() { + const created: SeriesModel = await this.seriesService.insertSeries("g-a", "Alpha Show", "", null); + + const exists: boolean = await this.seriesService.existByGUID(created.guid); + expect(exists).toBe(true); + } + + private async getSeriesByIds() { + const alpha: SeriesModel = await this.seriesService.insertSeries("g-a", "Alpha Show", "", null); + await this.seriesService.insertSeries("g-b", "Beta Show", "", null); + const gamma: SeriesModel = await this.seriesService.insertSeries("g-c", "Gamma", "", null); + + const series: SeriesModel[] = await this.seriesService.getSeriesByIds([alpha.series_id, gamma.series_id]); + + const titles = series.map(s => s.title).sort(); + expect(titles).toEqual(["Alpha Show", "Gamma"]); + } + + private async getSeriesChunk() { + const alpha: SeriesModel = await this.seriesService.insertSeries("g-a", "Alpha Show", "", null); + await this.seriesService.insertSeries("g-b", "Beta Show", "", null); + const gamma: SeriesModel = await this.seriesService.insertSeries("g-c", "Gamma", "", null); + const adventure: GenreModel = await this.genreService.insertGenre("adventure"); + await this.genreService.insertGenreToSeries(adventure, alpha, true); + await this.genreService.insertGenreToSeries(adventure, gamma, false); + + // JS contract has getFilteredSeriesChunk(offset, limit, searchText, genresIds) + // and getSeriesChunk(offset, limit) + const filtered: SeriesModel[] = await this.seriesService.getFilteredSeriesChunk(0, 10, "a", [adventure.genre_id]); + const paged: SeriesModel[] = await this.seriesService.getSeriesChunk(1, 1); + + const filteredTitles = filtered.map(s => s.title).sort(); + expect(filteredTitles).toEqual(["Alpha Show", "Gamma"]); + + expect(paged).toHaveLength(1); + expect(paged[0].title).toBe("Beta Show"); + } + + private async getStartedSeries() { + const series: SeriesModel[] = await this.seriesService.getStartedSeries(); + expect(Array.isArray(series)).toBe(true); + } + + public getTests(): TestDefinition[] { + return [ + ["CreateSeries", this.createSeries], + ["GetSeriesById", this.getSeriesById], + ["GetSeriesByGuid", this.getSeriesByGuid], + ["GetSeriesByIds", this.getSeriesByIds], + ["GetSeriesChunk", this.getSeriesChunk], + ["GetStartedSeries", this.getStartedSeries] + ]; + } +} + +TestBase.register(SeriesTests); diff --git a/app/tests/services/services.test.ts b/app/tests/services/services.test.ts new file mode 100644 index 0000000..ccf69d6 --- /dev/null +++ b/app/tests/services/services.test.ts @@ -0,0 +1,33 @@ +import {expect} from "vitest"; + +import {TestBase, TestDefinition} from "@test/suite"; +import {AsyncServiceKey, ServiceKey, syncio} from "vue-mvvm"; + +const contractModules = import.meta.glob("@contracts/*.contract.ts", { + eager: true +}); + +const contractImports: unknown[] = Object.values(contractModules).map(value => Object.values(value as Record)).flat(); + +const contracts: Array | AsyncServiceKey> = contractImports.filter(imp => imp instanceof ServiceKey || imp instanceof AsyncServiceKey); + +class ServicesTest extends TestBase { + private async resolveService(contract: ServiceKey | AsyncServiceKey): Promise { + await syncio.ensureSync(this.getService(contract)); + return 1; + } + + private async requiredServiceImplemented(): Promise { + for (const contract of contracts) { + await expect(this.resolveService(contract)).resolves.toBe(1); + } + } + + public getTests(): TestDefinition[] { + return [ + ["RequiredServiceImplemented", this.requiredServiceImplemented] + ]; + } +} + +TestBase.register(ServicesTest); \ No newline at end of file diff --git a/app/tests/services/user.test.ts b/app/tests/services/user.test.ts new file mode 100644 index 0000000..ea2faf1 --- /dev/null +++ b/app/tests/services/user.test.ts @@ -0,0 +1,92 @@ +import {expect} from "vitest"; + +import {TestBase, TestDefinition} from "@test/suite"; + +import {UserService} from "@contracts/user.contract"; + +import type {ProfileModel} from "@models/profile.model"; + +class UserTests extends TestBase { + private get userService(): UserService { + return this.getService(UserService); + } + + private async createProfile() { + const john: ProfileModel = await this.userService.createProfile("john", "fff", "eyes1" as any, "mouth1" as any, "dark", "en"); + + expect(john.profile_id).toBe(1); + expect(john.name).toBe("john"); + } + + private async getProfiles() { + await this.userService.createProfile("john", "fff", "eyes1" as any, "mouth1" as any, "dark", "en"); + await this.userService.createProfile("jane", "000", "eyes2" as any, "mouth2" as any, "light", "de"); + + const profiles: ProfileModel[] = await this.userService.getProfiles(); + + expect(profiles).toHaveLength(2); + } + + private async getProfileByUUID() { + const john: ProfileModel = await this.userService.createProfile("john", "fff", "eyes1" as any, "mouth1" as any, "dark", "en"); + await this.userService.createProfile("jane", "000", "eyes2" as any, "mouth2" as any, "light", "de"); + const profileByUuid: ProfileModel | null = await this.userService.getProfileByUUID(john.uuid); + + expect(profileByUuid).not.toBeNull(); + expect(profileByUuid!.name).toBe("john"); + } + + private async getProfileByName() { + const jane = await this.userService.createProfile("jane", "000", "eyes2" as any, "mouth2" as any, "light", "de"); + + const profiles: ProfileModel[] = await this.userService.getProfiles(); + const found = profiles.find(p => p.name === "jane"); + + expect(found).not.toBeUndefined(); + expect(found!.name).toBe("jane"); + expect(found!.uuid).toBe(jane.uuid); + } + + private async getProfileById() { + const john: ProfileModel = await this.userService.createProfile("john", "fff", "eyes1" as any, "mouth1" as any, "dark", "en"); + + const profiles: ProfileModel[] = await this.userService.getProfiles(); + const found = profiles.find(p => p.profile_id === john.profile_id); + + expect(found).not.toBeUndefined(); + expect(found!.name).toBe("john"); + } + + private async getActiveProfile() { + const active = await this.userService.getActiveProfile(); + expect(active).not.toBeNull(); + } + + private async updateProfile() { + const john: ProfileModel = await this.userService.createProfile("john", "fff", "eyes1" as any, "mouth1" as any, "dark", "en"); + + await this.userService.updateProfile(john.profile_id, "johnny", "000", "eyes2" as any, "mouth2" as any, "light", "de", true, true); + + const profiles = await this.userService.getProfiles(); + const updated = profiles.find(p => p.profile_id == john.profile_id); + + expect(updated).not.toBeUndefined(); + expect(updated!.name).toBe("johnny"); + expect(updated!.background_color).toBe("000"); + expect(updated!.theme).toBe("light"); + } + + public getTests(): TestDefinition[] { + return [ + ["CreateProfile", this.createProfile], + ["GetProfiles", this.getProfiles], + ["GetProfileByUUID", this.getProfileByUUID], + ["GetProfileByName", this.getProfileByName], + ["GetProfileById", this.getProfileById], + ["GetActiveProfile", this.getActiveProfile], + ["UpdateProfile", this.updateProfile] + ]; + } +} + +TestBase.register(UserTests); diff --git a/app/tests/services/watchlist.test.ts b/app/tests/services/watchlist.test.ts new file mode 100644 index 0000000..20c19ff --- /dev/null +++ b/app/tests/services/watchlist.test.ts @@ -0,0 +1,70 @@ +import {expect} from "vitest"; + +import {TestBase, TestDefinition} from "@test/suite"; + +import {WatchlistService} from "@contracts/watchlist.contract"; +import {SeriesService} from "@contracts/series.contract"; + +import type {SeriesModel} from "@models/series.model"; + +class WatchlistTests extends TestBase { + private get watchlistService(): WatchlistService { + return this.getService(WatchlistService); + } + + private get seriesService(): SeriesService { + return this.getService(SeriesService); + } + + private async addSeriesToWatchlist() { + const series: SeriesModel = await this.seriesService.insertSeries("test-series", "Test Series", "Description", null); + + await this.watchlistService.addToWatchlist(series.series_id); + + const isOnList: boolean = await this.watchlistService.isSeriesOnWatchlist(series.series_id); + expect(isOnList).toBe(true); + } + + private async removeSeriesFromWatchlist() { + const series: SeriesModel = await this.seriesService.insertSeries("test-series", "Test Series", "Description", null); + await this.watchlistService.addToWatchlist(series.series_id); + + await this.watchlistService.removeFromWatchlist(series.series_id); + + const isOnList: boolean = await this.watchlistService.isSeriesOnWatchlist(series.series_id); + expect(isOnList).toBe(false); + } + + private async getSeriesIdsFromWatchlist() { + const series1: SeriesModel = await this.seriesService.insertSeries("series-1", "Series 1", "", null); + const series2: SeriesModel = await this.seriesService.insertSeries("series-2", "Series 2", "", null); + + await this.watchlistService.addToWatchlist(series1.series_id); + await this.watchlistService.addToWatchlist(series2.series_id); + + const seriesIds: number[] = await this.watchlistService.getSeriesIds(); + + expect(seriesIds).toContain(series1.series_id); + expect(seriesIds).toContain(series2.series_id); + expect(seriesIds.length).toBe(2); + } + + private async isSeriesOnWatchlistReturnsFalseWhenNotAdded() { + const series: SeriesModel = await this.seriesService.insertSeries("test-series", "Test Series", "Description", null); + + const isOnList: boolean = await this.watchlistService.isSeriesOnWatchlist(series.series_id); + + expect(isOnList).toBe(false); + } + + public getTests(): TestDefinition[] { + return [ + ["AddSeriesToWatchlist", this.addSeriesToWatchlist], + ["RemoveSeriesFromWatchlist", this.removeSeriesFromWatchlist], + ["GetSeriesIdsFromWatchlist", this.getSeriesIdsFromWatchlist], + ["IsSeriesOnWatchlistReturnsFalseWhenNotAdded", this.isSeriesOnWatchlistReturnsFalseWhenNotAdded] + ]; + } +} + +TestBase.register(WatchlistTests); diff --git a/app/tests/services/watchtime.test.ts b/app/tests/services/watchtime.test.ts new file mode 100644 index 0000000..5572e74 --- /dev/null +++ b/app/tests/services/watchtime.test.ts @@ -0,0 +1,111 @@ +import {expect} from "vitest"; + +import {TestBase, TestDefinition} from "@test/suite"; + +import {WatchtimeService} from "@contracts/watchtime.contract"; +import {SeriesService} from "@contracts/series.contract"; +import {SeasonService} from "@contracts/season.contract"; +import {EpisodeService} from "@contracts/episode.contract"; + +import type {WatchtimeModel} from "@models/watchtime.model"; +import type {SeriesModel} from "@models/series.model"; +import type {SeasonModel} from "@models/season.model"; +import type {EpisodeModel} from "@models/episode.model"; + +class WatchtimeTests extends TestBase { + private get watchtimeService(): WatchtimeService { + return this.getService(WatchtimeService); + } + + private get seriesService(): SeriesService { + return this.getService(SeriesService); + } + + private get seasonService(): SeasonService { + return this.getService(SeasonService); + } + + private get episodeService(): EpisodeService { + return this.getService(EpisodeService); + } + + private async createWatchtime() { + const series: SeriesModel = await this.seriesService.insertSeries("s1", "Series 1", "", null); + const season: SeasonModel = await this.seasonService.insertSeason(series.series_id, 1); + const episode: EpisodeModel = await this.episodeService.insertEpisode(season.season_id, 1, "E1", "E1", ""); + + const watchtime: WatchtimeModel = await this.watchtimeService.createWatchtimeOfEpisode(episode.episode_id, 50, 10.5); + + expect(watchtime.watchtime_id).not.toBe(0); + expect(watchtime.episode_id).toBe(episode.episode_id); + expect(watchtime.percentage_watched).toBe(50); + expect(watchtime.stopped_time).toBe(10.5); + } + + private async getWatchtimeOfEpisode() { + const series: SeriesModel = await this.seriesService.insertSeries("s1", "Series 1", "", null); + const season: SeasonModel = await this.seasonService.insertSeason(series.series_id, 1); + const episode: EpisodeModel = await this.episodeService.insertEpisode(season.season_id, 1, "E1", "E1", ""); + await this.watchtimeService.createWatchtimeOfEpisode(episode.episode_id, 75, 20.0); + + const watchtime: WatchtimeModel | null = await this.watchtimeService.getWatchtimeOfEpisode(episode.episode_id); + + expect(watchtime).not.toBeNull(); + expect(watchtime!.percentage_watched).toBe(75); + } + + private async getWatchtimesOfSeries() { + const series: SeriesModel = await this.seriesService.insertSeries("s1", "Series 1", "", null); + const season: SeasonModel = await this.seasonService.insertSeason(series.series_id, 1); + const ep1: EpisodeModel = await this.episodeService.insertEpisode(season.season_id, 1, "E1", "E1", ""); + const ep2: EpisodeModel = await this.episodeService.insertEpisode(season.season_id, 2, "E2", "E2", ""); + + await this.watchtimeService.createWatchtimeOfEpisode(ep1.episode_id, 100, 30.0); + await this.watchtimeService.createWatchtimeOfEpisode(ep2.episode_id, 20, 5.0); + + const watchtimes: WatchtimeModel[] = await this.watchtimeService.getWatchtimesOfSeries(series.series_id); + + expect(watchtimes).toHaveLength(2); + } + + private async updateWatchtime() { + const series: SeriesModel = await this.seriesService.insertSeries("s1", "Series 1", "", null); + const season: SeasonModel = await this.seasonService.insertSeason(series.series_id, 1); + const episode: EpisodeModel = await this.episodeService.insertEpisode(season.season_id, 1, "E1", "E1", ""); + const watchtime: WatchtimeModel = await this.watchtimeService.createWatchtimeOfEpisode(episode.episode_id, 10, 2.0); + + await this.watchtimeService.updateWatchtime(watchtime.watchtime_id, 90, 25.0); + + const updated: WatchtimeModel | null = await this.watchtimeService.getWatchtimeOfEpisode(episode.episode_id); + expect(updated).not.toBeNull(); + expect(updated!.percentage_watched).toBe(90); + expect(updated!.stopped_time).toBe(25.0); + } + + private async getTotalWatchProgression() { + const series: SeriesModel = await this.seriesService.insertSeries("s1", "Series 1", "", null); + const season: SeasonModel = await this.seasonService.insertSeason(series.series_id, 1); + const ep1: EpisodeModel = await this.episodeService.insertEpisode(season.season_id, 1, "E1", "E1", ""); + const ep2: EpisodeModel = await this.episodeService.insertEpisode(season.season_id, 2, "E2", "E2", ""); + await this.episodeService.insertEpisode(season.season_id, 3, "E3", "E3", ""); + + await this.watchtimeService.createWatchtimeOfEpisode(ep1.episode_id, 85, 100); + await this.watchtimeService.createWatchtimeOfEpisode(ep2.episode_id, 50, 60); + + const progression: number = await this.watchtimeService.getTotalWatchProgression(series.series_id); + + expect(progression).toBe(33); + } + + public getTests(): TestDefinition[] { + return [ + ["CreateWatchtime", this.createWatchtime], + ["GetWatchtimeOfEpisode", this.getWatchtimeOfEpisode], + ["GetWatchtimesOfSeries", this.getWatchtimesOfSeries], + ["UpdateWatchtime", this.updateWatchtime], + ["GetTotalWatchProgression", this.getTotalWatchProgression] + ]; + } +} + +TestBase.register(WatchtimeTests); diff --git a/app/tests/suite.ts b/app/tests/suite.ts new file mode 100644 index 0000000..ea6e37b --- /dev/null +++ b/app/tests/suite.ts @@ -0,0 +1,9 @@ +export * from "@test/utils/harness"; + +if (APPLICATION_TARGET == "standalone") { + await import("@test/utils/standalone"); +} else { + await import("@test/utils/client"); +} + +import "@test/utils/harness"; \ No newline at end of file diff --git a/app/tests/utils/client.ts b/app/tests/utils/client.ts new file mode 100644 index 0000000..fe73609 --- /dev/null +++ b/app/tests/utils/client.ts @@ -0,0 +1,50 @@ +import {App, Plugin} from "vue"; +import {ReadableGlobalContext, ServiceKey, createMVVM, DIContainer} from "vue-mvvm"; + +import {ServiceTestHarness, TestBase} from "@test/utils/harness"; +import {ApiServiceMock} from "@test/mocks/client/api.service"; +import {UserServiceMock} from "@test/mocks/user.service"; + +import {TestConfig} from "@configs/test"; + +import {ApiService} from "@contracts/client/api.contract"; +import {UserService} from "@contracts/user.contract"; + +import {ApiServiceImpl} from "@services/api/api.service"; +import {TestContext} from "vitest"; + + +class ClientTestHarness implements ServiceTestHarness { + private container!: DIContainer; + private config!: TestConfig; + + setUp(ctx: TestContext): void | Promise { + this.container = new DIContainer(); + this.config = new TestConfig(); + + const plugin: Plugin = createMVVM(this.config, { + context: this.container + }); + + if (!plugin.install) { + throw new Error("Failed to setup MVVM context"); + } + + plugin.install(null as unknown as App); + + this.config.mockService(ApiService, ctx => new ApiServiceMock(ctx)); + this.config.mockService(UserService, ctx => new UserServiceMock(ctx)); + + ApiServiceImpl.HEADERS.push(["Testing-ID", ctx.task.id]); + } + + tearDown(): void | Promise { + } + + getService(key: ServiceKey): T { + const ctx: ReadableGlobalContext = this.config.ctx; + return ctx.getService(key); + } +} + +TestBase.registerHarness("client", () => new ClientTestHarness()); \ No newline at end of file diff --git a/app/tests/utils/harness.ts b/app/tests/utils/harness.ts new file mode 100644 index 0000000..5b07372 --- /dev/null +++ b/app/tests/utils/harness.ts @@ -0,0 +1,91 @@ +import {describe, test, TestContext} from "vitest"; +import {syncio} from "vue-mvvm"; + +import {ProviderService} from "@contracts/provider.contract"; + +export interface ServiceTestHarness { + setUp(ctx: TestContext): void | Promise; + + tearDown(ctx: TestContext): void | Promise; + + getService(key: unknown): T; +} + +export type TestMethod = (this: TestBase) => Promise | void; + +export type TestDefinition = [ + name: string, + method: TestMethod +]; + +export abstract class TestBase { + public static harnessFactory: (() => ServiceTestHarness) | null = null; + + private setProvider: boolean; + private harness: ServiceTestHarness; + + public constructor(setProvider: boolean = true) { + if (!TestBase.harnessFactory) { + throw `No harness factory defined for target '${TestBase.applicationTarget}'`; + } + + this.setProvider = setProvider; + this.harness = TestBase.harnessFactory(); + } + + public async setUp(ctx: TestContext): Promise { + await syncio.ensureSync(this.harness.setUp(ctx)); + + if (this.setProvider) { + const service: ProviderService = this.harness.getService(ProviderService); + await service.setProvider(service.STO); + } + } + + public async tearDown(ctx: TestContext): Promise { + await syncio.ensureSync(this.harness.tearDown(ctx)); + } + + public abstract getTests(): TestDefinition[]; + + protected getService(key: unknown): T { + return this.harness.getService(key); + } + + public static register(testType: new () => T): void { + const discoveryInstance = new testType(); + + describe(`${testType.name}`, () => { + for (const [name, method] of discoveryInstance.getTests()) { + test(name, async (ctx) => { + const testInstance = new testType(); + + const boundMethod = method.bind(testInstance); + + try { + await testInstance.setUp(ctx); + await boundMethod(); + } finally { + await testInstance.tearDown(ctx); + } + }); + } + }); + } + + public static registerHarness(target: string, factory: () => ServiceTestHarness): void { + if (target != TestBase.applicationTarget) { + return; + } + TestBase.harnessFactory = factory; + } + + private static get applicationTarget() { + if (!process.env.APPLICATION_TARGET) { + throw "APPLICATION_TARGET is not defined"; + } + + return process.env.APPLICATION_TARGET; + } +} + diff --git a/app/tests/utils/standalone.ts b/app/tests/utils/standalone.ts new file mode 100644 index 0000000..90388a5 --- /dev/null +++ b/app/tests/utils/standalone.ts @@ -0,0 +1,51 @@ +import {App, Plugin} from "vue"; +import {ReadableGlobalContext, ServiceKey, createMVVM, DIContainer} from "vue-mvvm"; + +import {ServiceTestHarness, TestBase} from "@test/utils/harness"; +import {UserDbServiceMock} from "@test/mocks/standalone/user.service"; +import {MetadataDbServiceMock} from "@test/mocks/standalone/metadata.service"; +import {UserServiceMock} from "@test/mocks/user.service"; + +import {TestConfig} from "@configs/test"; + +import {UserDbService} from "@contracts/standalone/user.contract"; +import {MetadataDbService} from "@contracts/standalone/metadata.contract"; +import {UserService} from "@contracts/user.contract"; + + +class StandaloneTestHarness implements ServiceTestHarness { + private container!: DIContainer; + private config!: TestConfig; + + public constructor() { + } + + setUp(): void | Promise { + this.container = new DIContainer(); + this.config = new TestConfig(); + + const plugin: Plugin = createMVVM(this.config, { + context: this.container + }); + + if (!plugin.install) { + throw new Error("Failed to setup MVVM context"); + } + + plugin.install(null as unknown as App); + + this.config.mockService(UserDbService, () => new UserDbServiceMock()); + this.config.mockService(MetadataDbService, ctx => new MetadataDbServiceMock(ctx)); + this.config.mockService(UserService, ctx => new UserServiceMock(ctx)); + } + + tearDown(): void | Promise { + } + + getService(key: ServiceKey): T { + const ctx: ReadableGlobalContext = this.config.ctx; + return ctx.getService(key); + } +} + +TestBase.registerHarness("standalone", () => new StandaloneTestHarness()); \ No newline at end of file diff --git a/app/tests/vitest.global-setup.ts b/app/tests/vitest.global-setup.ts new file mode 100644 index 0000000..06424a0 --- /dev/null +++ b/app/tests/vitest.global-setup.ts @@ -0,0 +1,74 @@ +import {execSync, spawn} from "child_process"; +import * as path from "path"; + +let server: ReturnType | null = null; + +function resolveDotnet(): string { + try { + const cmd: string = process.platform === "win32" ? "where dotnet" : "which dotnet"; + return execSync(cmd).toString().trim().split("\n")[0].trim(); + } catch { + throw new Error("dotnet not found in PATH"); + } +} + +export async function setup(): Promise { + if (process.env["APPLICATION_TARGET"] != "client") { + return; + } + + const dotnetPath: string = resolveDotnet(); + + console.log(`__dirname: ${__dirname}`); + + server = spawn(dotnetPath, ["run", "--project", "./AniStream/AniStream.csproj", "-c", "Test"], { + cwd: path.join(__dirname, "..", "..", "server"), + env: { + ...process.env, + DATABASE_DRIVER: "sqlite", + DATABASE_METADATA_CONNECTION_STRING: "", + DATABASE_PROFILE_CONNECTION_STRING: "", + DATABASE_MIGRATION_PATH: path.join(__dirname, "..", "..", "migration"), + ASSETS_PATH: "", + SIDECAR_PATH: "" + }, + stdio: "inherit" + }); + + if (!await waitForPort(5000, 60, 1000)) { + throw `Port 5000 never opened`; + } +} + +export async function teardown(): Promise { + console.log("Terminate server"); + if (server) { + server.kill("SIGTERM"); + } +} + +function waitForPort(port: number, retries: number = 20, delay: number = 1000): Promise { + return new Promise(async (resolve, reject) => { + async function attempt(remaining: number) { + try { + const {default: net} = await import("net"); + const socket = net.connect(port, "localhost"); + socket.on("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.on("error", () => { + if (remaining <= 0) { + return resolve(false); + } + + setTimeout(() => attempt(remaining - 1), delay); + }); + } catch (e) { + reject(e); + } + } + + await attempt(retries); + }); +} \ No newline at end of file diff --git a/tsconfig.json b/app/tsconfig.json similarity index 83% rename from tsconfig.json rename to app/tsconfig.json index b9238b4..09e7a58 100644 --- a/tsconfig.json +++ b/app/tsconfig.json @@ -23,7 +23,7 @@ "noFallthroughCasesInSwitch": true, "paths": { "@/*": ["./src/*"], - "@services/*": ["./src/services/standalone/*", "./src/services/shared/*"], + "@services/*": ["./src/services/standalone/*", "./src/services/client/*", "./src/services/shared/*"], "@views/*": ["./src/views/*"], "@models/*": ["./src/models/*"], "@icons/*": ["./src/icons/*"], @@ -33,14 +33,18 @@ "@providers/*": ["./src/providers/*"], "@sources": ["./src/sources/index.ts"], "@sources/*": ["./src/sources/*"], - "@contracts/*": ["./src/contracts/*"] + "@contracts/*": ["./src/contracts/*"], + "@configs/*": ["./src/configs/*"], + "@AppEnv": ["./src/AppEnv.ts"], + "@test/*": ["./tests/*"] } }, "include": [ "src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", - "src/**/*.vue" + "src/**/*.vue", + "tests/**/*.ts" ], "references": [ { diff --git a/tsconfig.node.json b/app/tsconfig.node.json similarity index 100% rename from tsconfig.node.json rename to app/tsconfig.node.json diff --git a/vite.config.ts b/app/vite.config.ts similarity index 53% rename from vite.config.ts rename to app/vite.config.ts index c600888..d203105 100644 --- a/vite.config.ts +++ b/app/vite.config.ts @@ -1,9 +1,9 @@ import * as path from "path"; +import * as fs from "fs"; -import {defineConfig, PluginOption, UserConfig} from "vite"; +import {BuildEnvironmentOptions, ConfigEnv, defineConfig, PluginOption, Rolldown, UserConfig, transformWithOxc} from "vite"; import vue from "@vitejs/plugin-vue"; import tailwindcss from "@tailwindcss/vite"; -import * as fs from "node:fs"; const host: string = process.env.TAURI_DEV_HOST; @@ -63,9 +63,15 @@ function virtualServiceLoader(applicationTarget: string): PluginOption { if (id == resolvedVirtualModuleId) { // language=TypeScript return ` - const s1 = import.meta.glob("/src/services/${applicationTarget}/**/*.service.ts", {eager: true, import: "default"}); - const s2 = import.meta.glob("/src/services/shared/**/*.service.ts", {eager: true, import: "default"}); - + const s1 = import.meta.glob("/src/services/${applicationTarget}/**/*.service.ts", { + eager: true, + import: "default" + }); + const s2 = import.meta.glob("/src/services/shared/**/*.service.ts", { + eager: true, + import: "default" + }); + export const services = { ...s2, ...s1 @@ -78,13 +84,112 @@ function virtualServiceLoader(applicationTarget: string): PluginOption { } } +function raiiTransformer(): PluginOption { + return { + name: "raii-transformer", + enforce: "post", + transform: { + filter: { + id: /.ts$/, + }, + async handler(code: string, id: string): Promise { + if (!code.includes("using ")) { + return null; + } + + const jsResult = await transformWithOxc(code, id, { + sourceType: "module" + }); + + const {transformAsync} = await import ("@babel/core"); + const {default: resourceMgmt} = await import("@babel/plugin-proposal-explicit-resource-management"); + + const result = await transformAsync(jsResult.code, { + filename: id, + babelrc: false, + configFile: false, + sourceMaps: true, + inputSourceMap: { + ...jsResult.map, + file: id + }, + plugins: [ + resourceMgmt + ] + }); + + if (!result) { + return null; + } + + return { + code: result.code, + map: result.map + } + } + } + } +} + +function activePlugins(applicationTarget: string): PluginOption[] { + if (applicationTarget == "worker") { + return [ + raiiTransformer(), + dynamicServiceResolver(applicationTarget) + ]; + } + + return [ + vue(), + tailwindcss(), + raiiTransformer(), + dynamicServiceResolver(applicationTarget), + virtualServiceLoader(applicationTarget) + ]; +} + +function buildEnv(applicationTarget: string): BuildEnvironmentOptions { + if (applicationTarget != "worker") { + return {}; + } + + return { + lib: { + entry: path.resolve(__dirname, "src", "worker.ts"), + formats: ["es"], + fileName: () => "worker.js" + }, + assetsDir: "", + cssCodeSplit: false, + rolldownOptions: { + external: [ + /^node:.*/, + "commander", + "ora", + "chalk" + ], + input: { + main: path.resolve(__dirname, "src", "worker.ts") + }, + output: { + codeSplitting: false, + sourcemap: true, + } + } + }; +} + // https://vite.dev/config/ -export default defineConfig(async (): Promise => { +export default defineConfig(async (env: ConfigEnv): Promise => { const APPLICATION_TARGET: string = process.env.APPLICATION_TARGET || "standalone"; console.log(`ℹ️ Application Target: ${APPLICATION_TARGET}`); + if (env.command == "serve" && APPLICATION_TARGET == "worker") { + throw "Worker can only be build and not served"; + } + return { - plugins: [vue(), tailwindcss(), dynamicServiceResolver(APPLICATION_TARGET), virtualServiceLoader(APPLICATION_TARGET)], + plugins: activePlugins(APPLICATION_TARGET), root: __dirname, resolve: { @@ -98,10 +203,15 @@ export default defineConfig(async (): Promise => { "@langs": path.join(__dirname, "src", "langs"), "@providers": path.join(__dirname, "src", "providers"), "@sources": path.join(__dirname, "src", "sources"), - "@contracts": path.join(__dirname, "src", "contracts") + "@contracts": path.join(__dirname, "src", "contracts"), + "@configs": path.join(__dirname, "src", "configs"), + "@AppEnv": path.join(__dirname, "src", "AppEnv.ts"), + "@test": path.join(__dirname, "tests") } }, + build: buildEnv(APPLICATION_TARGET), + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // // 1. prevent Vite from obscuring rust errors @@ -125,6 +235,12 @@ export default defineConfig(async (): Promise => { }, define: { APPLICATION_TARGET: JSON.stringify(APPLICATION_TARGET) + }, + optimizeDeps: { + exclude: ["better-sqlite3"] + }, + test: { + globalSetup: "./tests/vitest.global-setup.ts" } } }); diff --git a/migration/sqlite/metadata/1.sql b/migration/sqlite/metadata/1.sql new file mode 100644 index 0000000..8b75876 --- /dev/null +++ b/migration/sqlite/metadata/1.sql @@ -0,0 +1,110 @@ +CREATE TABLE episode +( + episode_id INTEGER PRIMARY KEY AUTOINCREMENT, + season_id INTEGER NOT NULL, + episode_number INTEGER NOT NULL, + german_title TEXT NOT NULL, + english_title TEXT NOT NULL, + description TEXT NOT NULL, + FOREIGN KEY (season_id) REFERENCES season (season_id) ON DELETE RESTRICT +); + +CREATE TABLE genre +( + genre_id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL +); + +CREATE TABLE genre_to_series +( + genre_id INTEGER NOT NULL, + series_id INTEGER NOT NULL, + main_genre BOOLEAN NOT NULL, + PRIMARY KEY (genre_id, series_id), + FOREIGN KEY (genre_id) REFERENCES genre (genre_id) ON DELETE RESTRICT, + FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT +); + +CREATE TABLE list +( + list_id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + tenant_id TEXT NOT NULL +); + +CREATE TABLE list_to_series +( + list_id INTEGER NOT NULL, + series_id INTEGER NOT NULL, + PRIMARY KEY (list_id, series_id), + FOREIGN KEY (list_id) REFERENCES list (list_id) ON DELETE CASCADE, + FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT +); + +CREATE TABLE season +( + season_id INTEGER PRIMARY KEY AUTOINCREMENT, + series_id INTEGER NOT NULL, + season_number INTEGER NOT NULL, + FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT +); + +CREATE TABLE series +( + series_id INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + preview_image TEXT UNIQUE +); + +CREATE TABLE watchlist +( + watchlist_id INTEGER PRIMARY KEY AUTOINCREMENT, + series_id INTEGER UNIQUE NOT NULL, + tenant_id TEXT NOT NULL, + FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT +); + +CREATE TABLE watchtime +( + watchtime_id INTEGER PRIMARY KEY AUTOINCREMENT, + episode_id INTEGER NOT NULL, + percentage_watched INTEGER NOT NULL, + stopped_time DOUBLE PRECISION NOT NULL, + tenant_id TEXT NOT NULL, + FOREIGN KEY (episode_id) REFERENCES episode (episode_id) ON DELETE CASCADE +); + +CREATE TABLE sync_series_job +( + sync_series_job_id INTEGER PRIMARY KEY AUTOINCREMENT, + series_id INTEGER NOT NULL, + status INTEGER NOT NULL, + started DATETIME NOT NULL, + completed DATETIME, + error TEXT, + FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE CASCADE +); + +CREATE TABLE sync_provider_job +( + sync_provider_job_id INTEGER PRIMARY KEY AUTOINCREMENT, + episode_id INTEGER NOT NULL, + status INTEGER NOT NULL, + started DATETIME NOT NULL, + completed DATETIME, + error TEXT, + expires DATETIME, + FOREIGN KEY (episode_id) REFERENCES episode (episode_id) ON DELETE CASCADE +); + +CREATE TABLE sync_provider_job_result +( + sync_provider_job_result_id INTEGER PRIMARY KEY AUTOINCREMENT, + sync_provider_job_id INTEGER NOT NULL, + provider TEXT NOT NULL, + url TEXT NOT NULL, + language_code INTEGER NOT NULL, + FOREIGN KEY (sync_provider_job_id) REFERENCES sync_provider_job (sync_provider_job_id) ON DELETE CASCADE +); diff --git a/migration/sqlite/profile/1.sql b/migration/sqlite/profile/1.sql new file mode 100644 index 0000000..9ebf87c --- /dev/null +++ b/migration/sqlite/profile/1.sql @@ -0,0 +1,15 @@ +CREATE TABLE profile +( + profile_id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + password TEXT NOT NULL, + password_salt TEXT NOT NULL, + background_color TEXT NOT NULL, + eye TEXT NOT NULL, + mouth TEXT NOT NULL, + theme TEXT NOT NULL, + lang TEXT NOT NULL, + tos_accepted BOOLEAN NOT NULL, + sync_catalog BOOLEAN NOT NULL +) \ No newline at end of file diff --git a/migration/standalone/metadata/1.sql b/migration/standalone/metadata/1.sql new file mode 100644 index 0000000..8118c1a --- /dev/null +++ b/migration/standalone/metadata/1.sql @@ -0,0 +1,47 @@ +PRAGMA user_version = 1; + +CREATE TABLE series +( + series_id INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + preview_image TEXT UNIQUE +); + +CREATE TABLE season +( + season_id INTEGER PRIMARY KEY AUTOINCREMENT, + series_id INTEGER NOT NULL, + season_number INTEGER NOT NULL, + FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT +); + +CREATE TABLE episode +( + episode_id INTEGER PRIMARY KEY AUTOINCREMENT, + season_id INTEGER NOT NULL, + episode_number INTEGER NOT NULL, + german_title TEXT NOT NULL, + english_title TEXT NOT NULL, + description TEXT NOT NULL, + percentage_watched INTEGER NOT NULL, + stopped_time INTEGER NOT NULL, + FOREIGN KEY (season_id) REFERENCES season (season_id) ON DELETE RESTRICT +); + +CREATE TABLE genre +( + genre_id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL +); + +CREATE TABLE genre_to_series +( + genre_to_series_id INTEGER PRIMARY KEY AUTOINCREMENT, + genre_id INTEGER NOT NULL, + series_id INTEGER NOT NULL, + main_genre BOOLEAN NOT NULL, + FOREIGN KEY (genre_id) REFERENCES genre (genre_id) ON DELETE RESTRICT, + FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT +); \ No newline at end of file diff --git a/migration/standalone/metadata/2.sql b/migration/standalone/metadata/2.sql new file mode 100644 index 0000000..d1e72f6 --- /dev/null +++ b/migration/standalone/metadata/2.sql @@ -0,0 +1,6 @@ +CREATE TABLE watchlist +( + watchlist_id INTEGER PRIMARY KEY AUTOINCREMENT, + series_id INTEGER UNIQUE NOT NULL, + FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT +); \ No newline at end of file diff --git a/migration/standalone/metadata/3.sql b/migration/standalone/metadata/3.sql new file mode 100644 index 0000000..a44a74b --- /dev/null +++ b/migration/standalone/metadata/3.sql @@ -0,0 +1,52 @@ +CREATE TABLE watchlist_new +( + watchlist_id INTEGER PRIMARY KEY AUTOINCREMENT, + series_id INTEGER UNIQUE NOT NULL, + tenant_id TEXT NOT NULL, + FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT +); + +INSERT INTO watchlist_new (watchlist_id, series_id, tenant_id) +SELECT watchlist_id, series_id, ? +FROM watchlist; + +DROP TABLE watchlist; + +ALTER TABLE watchlist_new + RENAME TO watchlist; + +CREATE TABLE episode_new +( + episode_id INTEGER PRIMARY KEY AUTOINCREMENT, + season_id INTEGER NOT NULL, + episode_number INTEGER NOT NULL, + german_title TEXT NOT NULL, + english_title TEXT NOT NULL, + description TEXT NOT NULL, + FOREIGN KEY (season_id) REFERENCES season (season_id) ON DELETE RESTRICT +); + +INSERT INTO episode_new (episode_id, season_id, episode_number, german_title, english_title, description) +SELECT episode_id, season_id, episode_number, german_title, english_title, description +FROM episode; + +ALTER TABLE episode + RENAME TO episode_old; +ALTER TABLE episode_new + RENAME TO episode; + +CREATE TABLE watchtime +( + watchtime_id INTEGER PRIMARY KEY AUTOINCREMENT, + episode_id INTEGER NOT NULL, + percentage_watched INTEGER NOT NULL, + stopped_time INTEGER NOT NULL, + tenant_id TEXT NOT NULL, + FOREIGN KEY (episode_id) REFERENCES episode (episode_id) ON DELETE CASCADE +); + +INSERT INTO watchtime (episode_id, percentage_watched, stopped_time, tenant_id) +SELECT episode_id, percentage_watched, stopped_time, ? +FROM episode_old; + +DROP TABLE episode_old; \ No newline at end of file diff --git a/migration/standalone/metadata/4.sql b/migration/standalone/metadata/4.sql new file mode 100644 index 0000000..cd0f95a --- /dev/null +++ b/migration/standalone/metadata/4.sql @@ -0,0 +1,15 @@ +CREATE TABLE list +( + list_id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + tenant_id TEXT NOT NULL +); + +CREATE TABLE list_to_series +( + list_id INTEGER NOT NULL, + series_id INTEGER NOT NULL, + PRIMARY KEY (list_id, series_id), + FOREIGN KEY (list_id) REFERENCES list (list_id) ON DELETE CASCADE, + FOREIGN KEY (series_id) REFERENCES series (series_id) ON DELETE RESTRICT +); \ No newline at end of file diff --git a/migration/standalone/metadata/5.sql b/migration/standalone/metadata/5.sql new file mode 100644 index 0000000..9cc753f --- /dev/null +++ b/migration/standalone/metadata/5.sql @@ -0,0 +1,31 @@ +UPDATE genre_to_series +SET main_genre = 1 +WHERE main_genre IN ('true', 'True', 't', 'Y'); +UPDATE genre_to_series +SET main_genre = 0 +WHERE main_genre IN ('false', 'False', 'f', 'N'); + +UPDATE genre_to_series +SET main_genre = 1 +WHERE main_genre IN ('true', 'True', 't', 'Y'); +UPDATE genre_to_series +SET main_genre = 0 +WHERE main_genre IN ('false', 'False', 'f', 'N'); + +CREATE TABLE watchtime_new +( + watchtime_id INTEGER PRIMARY KEY AUTOINCREMENT, + episode_id INTEGER NOT NULL, + percentage_watched INTEGER NOT NULL, + stopped_time DOUBLE PRECISION NOT NULL, + tenant_id TEXT NOT NULL, + FOREIGN KEY (episode_id) REFERENCES episode (episode_id) ON DELETE CASCADE +); + +INSERT INTO watchtime_new (episode_id, percentage_watched, stopped_time, tenant_id) +SELECT episode_id, percentage_watched, stopped_time, tenant_id +FROM watchtime; + +DROP TABLE watchtime; + +ALTER TABLE watchtime_new RENAME TO watchtime; diff --git a/migration/standalone/profile/1.sql b/migration/standalone/profile/1.sql new file mode 100644 index 0000000..9a7c850 --- /dev/null +++ b/migration/standalone/profile/1.sql @@ -0,0 +1,14 @@ +PRAGMA user_version = 1; + +CREATE TABLE profile +( + profile_id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + background_color TEXT NOT NULL, + eye TEXT NOT NULL, + mouth TEXT NOT NULL, + theme TEXT NOT NULL, + lang TEXT NOT NULL, + tos_accepted BOOLEAN NOT NULL +); \ No newline at end of file diff --git a/migration/standalone/profile/2.sql b/migration/standalone/profile/2.sql new file mode 100644 index 0000000..b442ea1 --- /dev/null +++ b/migration/standalone/profile/2.sql @@ -0,0 +1,31 @@ +CREATE TABLE profile_new +( + profile_id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + background_color TEXT NOT NULL, + eye TEXT NOT NULL, + mouth TEXT NOT NULL, + theme TEXT NOT NULL, + lang TEXT NOT NULL, + tos_accepted BOOLEAN NOT NULL, + sync_catalog BOOLEAN NOT NULL +); + +INSERT INTO profile_new (profile_id, uuid, name, background_color, eye, mouth, theme, lang, tos_accepted, sync_catalog) +SELECT profile_id, + uuid, + name, + background_color, + eye, + mouth, + theme, + lang, + tos_accepted, + false +FROM profile; + +DROP TABLE profile; + +ALTER TABLE profile_new + RENAME TO profile; \ No newline at end of file diff --git a/migration/standalone/profile/3.sql b/migration/standalone/profile/3.sql new file mode 100644 index 0000000..223a1c5 --- /dev/null +++ b/migration/standalone/profile/3.sql @@ -0,0 +1,5 @@ +UPDATE profile SET tos_accepted = 1 WHERE tos_accepted IN ('true', 'True', 't', 'Y'); +UPDATE profile SET tos_accepted = 0 WHERE tos_accepted IN ('false', 'False', 'f', 'N'); + +UPDATE profile SET sync_catalog = 1 WHERE sync_catalog IN ('true', 'True', 't', 'Y'); +UPDATE profile SET sync_catalog = 0 WHERE sync_catalog IN ('false', 'False', 'f', 'N'); \ No newline at end of file diff --git a/server/AniStream.Integration/AniStream.Integration.csproj b/server/AniStream.Integration/AniStream.Integration.csproj new file mode 100644 index 0000000..18148cf --- /dev/null +++ b/server/AniStream.Integration/AniStream.Integration.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + diff --git a/server/AniStream.Integration/TestingAuthHandler.cs b/server/AniStream.Integration/TestingAuthHandler.cs new file mode 100644 index 0000000..9f9bf68 --- /dev/null +++ b/server/AniStream.Integration/TestingAuthHandler.cs @@ -0,0 +1,32 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AniStream.Integration; + +public sealed class TestingAuthHandler : AuthenticationHandler +{ + public TestingAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder + ) : base(options, logger, encoder) + { + + } + + protected override Task HandleAuthenticateAsync() + { + Claim[] claims = + [ + new Claim(ClaimTypes.Name, "TestUser") + ]; + ClaimsIdentity identity = new ClaimsIdentity(claims, Guid.NewGuid().ToString()); + ClaimsPrincipal principal = new ClaimsPrincipal(identity); + AuthenticationTicket ticket = new AuthenticationTicket(principal, "Test"); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} \ No newline at end of file diff --git a/server/AniStream.Integration/TestingExtensions.cs b/server/AniStream.Integration/TestingExtensions.cs new file mode 100644 index 0000000..942ab68 --- /dev/null +++ b/server/AniStream.Integration/TestingExtensions.cs @@ -0,0 +1,39 @@ +using AniStream.Contexts; +using AniStream.Integration.Utils; +using AniStream.Services; +using AniStream.Utils; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace AniStream.Integration; + +public static class TestingExtensions +{ + public static WebApplicationBuilder AddTestingMode( + this WebApplicationBuilder builder, + AutoLoader.Options options + ) + { + builder.Services.AddSingleton(); + + builder.Services.Replace(ServiceDescriptor.Scoped>(sp => + new MetadataDbContextFactory( + options.MigrationPath, + sp.GetRequiredService(), + sp.GetRequiredService() + ) + )); + + builder.Services.Replace(ServiceDescriptor.Scoped>(sp => + new ProfileDbContextFactory( + options.MigrationPath, + sp.GetRequiredService(), + sp.GetRequiredService() + ) + )); + + return builder; + } +} \ No newline at end of file diff --git a/server/AniStream.Integration/TestingMiddleware.cs b/server/AniStream.Integration/TestingMiddleware.cs new file mode 100644 index 0000000..17ef970 --- /dev/null +++ b/server/AniStream.Integration/TestingMiddleware.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace AniStream.Integration; + +public sealed class TestingMiddleware +{ + public const string HEADER = "Testing-ID"; + public const string ITEM_KEY = "TestingId"; + + private readonly RequestDelegate _next; + + public TestingMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + if (!context.Request.Path.StartsWithSegments("/api")) + { + await _next(context); + return; + } + + if (!context.Request.Headers.TryGetValue(HEADER, out StringValues value)) + { + throw new Exception("Testing-ID header not found"); + } + + string? id = value.FirstOrDefault(); + if (!string.IsNullOrEmpty(id)) + { + context.Items[ITEM_KEY] = id; + } + + await _next(context); + } +} \ No newline at end of file diff --git a/server/AniStream.Integration/TestingSession.cs b/server/AniStream.Integration/TestingSession.cs new file mode 100644 index 0000000..ef2a9c5 --- /dev/null +++ b/server/AniStream.Integration/TestingSession.cs @@ -0,0 +1,30 @@ +using Microsoft.Data.Sqlite; + +namespace AniStream.Integration; + +public sealed class TestingSession : IDisposable +{ + public readonly SqliteConnection MetadataConnection; + public readonly SqliteConnection ProfileConnection; + + private int _metadataMigrated; + private int _profileMigrated; + + public TestingSession() + { + MetadataConnection = new SqliteConnection("DataSource=:memory:"); + MetadataConnection.Open(); + + ProfileConnection = new SqliteConnection("DataSource=:memory:"); + ProfileConnection.Open(); + } + + public bool ClaimMetadataMigration() => Interlocked.Exchange(ref _metadataMigrated, 1) == 0; + public bool ClaimProfileMigration() => Interlocked.Exchange(ref _profileMigrated, 1) == 0; + + public void Dispose() + { + MetadataConnection.Dispose(); + ProfileConnection.Dispose(); + } +} \ No newline at end of file diff --git a/server/AniStream.Integration/TestingSessionStore.cs b/server/AniStream.Integration/TestingSessionStore.cs new file mode 100644 index 0000000..39d5c6d --- /dev/null +++ b/server/AniStream.Integration/TestingSessionStore.cs @@ -0,0 +1,23 @@ +using System.Collections.Concurrent; + +namespace AniStream.Integration; + +public sealed class TestingSessionStore : IDisposable +{ + private readonly ConcurrentDictionary _sessions; + + public TestingSession GetOrCreate(string testingId) => _sessions.GetOrAdd(testingId, _ => new TestingSession()); + + public TestingSessionStore() + { + _sessions = new ConcurrentDictionary(); + } + + public void Dispose() + { + foreach (TestingSession session in _sessions.Values) + { + session.Dispose(); + } + } +} \ No newline at end of file diff --git a/server/AniStream.Integration/Utils/MetadataDbContextFactory.cs b/server/AniStream.Integration/Utils/MetadataDbContextFactory.cs new file mode 100644 index 0000000..6c91183 --- /dev/null +++ b/server/AniStream.Integration/Utils/MetadataDbContextFactory.cs @@ -0,0 +1,46 @@ +using AniStream.Contexts; +using AniStream.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Integration.Utils; + +internal sealed class MetadataDbContextFactory : DbContextFactory +{ + private readonly IHttpContextAccessor _http; + private readonly TestingSessionStore _store; + + public MetadataDbContextFactory( + string migrationPath, + IHttpContextAccessor http, + TestingSessionStore store + ) : base("sqlite", migrationPath, "metadata") + { + _http = http; + _store = store; + } + + public override async Task GetContext() + { + string? testingId = _http.HttpContext?.Items[TestingMiddleware.ITEM_KEY] as string; + + if (testingId is null) + { + throw new Exception("Missing testing ID"); + } + + TestingSession session = _store.GetOrCreate(testingId); + + DbContextOptionsBuilder builder = new DbContextOptionsBuilder(); + builder.UseSqlite(session.MetadataConnection); + + MetadataDbContext context = new MetadataDbContext(builder.Options); + + if (session.ClaimMetadataMigration()) + { + await MigrateDatabaseIfRequired(context); + } + + return context; + } +} \ No newline at end of file diff --git a/server/AniStream.Integration/Utils/ProfileDbContextFactory.cs b/server/AniStream.Integration/Utils/ProfileDbContextFactory.cs new file mode 100644 index 0000000..d7a54c3 --- /dev/null +++ b/server/AniStream.Integration/Utils/ProfileDbContextFactory.cs @@ -0,0 +1,46 @@ +using AniStream.Contexts; +using AniStream.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Integration.Utils; + +public sealed class ProfileDbContextFactory : DbContextFactory +{ + private readonly IHttpContextAccessor _http; + private readonly TestingSessionStore _store; + + public ProfileDbContextFactory( + string migrationPath, + IHttpContextAccessor http, + TestingSessionStore store + ) : base("sqlite", migrationPath, "profile") + { + _http = http; + _store = store; + } + + public override async Task GetContext() + { + string? testingId = _http.HttpContext?.Items[TestingMiddleware.ITEM_KEY] as string; + + if (testingId is null) + { + throw new Exception("Missing testing ID"); + } + + TestingSession session = _store.GetOrCreate(testingId); + + DbContextOptionsBuilder builder = new DbContextOptionsBuilder(); + builder.UseSqlite(session.ProfileConnection); + + ProfileDbContext context = new ProfileDbContext(builder.Options); + + if (session.ClaimProfileMigration()) + { + await MigrateDatabaseIfRequired(context); + } + + return context; + } +} \ No newline at end of file diff --git a/server/AniStream.Models/AniStream.Models.csproj b/server/AniStream.Models/AniStream.Models.csproj new file mode 100644 index 0000000..e22a762 --- /dev/null +++ b/server/AniStream.Models/AniStream.Models.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + AniStream + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/server/AniStream.Models/Migrators/SqliteMigrator.cs b/server/AniStream.Models/Migrators/SqliteMigrator.cs new file mode 100644 index 0000000..a7f8f6b --- /dev/null +++ b/server/AniStream.Models/Migrators/SqliteMigrator.cs @@ -0,0 +1,67 @@ +using System.Data; +using AniStream.Utils; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Migrators; + +public sealed class SqliteMigrator : DbMigrator +{ + public SqliteMigrator(string migrationPath, string databaseSchema) : base(migrationPath, databaseSchema, "sqlite") + { + } + + public override async Task GetCurrentVersion(DbContext context) + { + if (!CheckDbFileExists(context)) + { + return 0; + } + + await foreach (int version in context.Database.SqlQuery($"PRAGMA user_version").AsAsyncEnumerable()) + { + return version; + } + + throw new InvalidOperationException("Failed to retrieve database version"); + } + + public override async Task Migrate(DbContext context, int fromVersion) + { + SqliteConnection connection = (SqliteConnection)context.Database.GetDbConnection(); + if (connection.State != ConnectionState.Open) + { + connection.Open(); + } + + while (fromVersion < DbContextFactory.SchemaVersion) + { + fromVersion++; + string sql = ReadMigrationFile(fromVersion); + + await using (SqliteCommand command = connection.CreateCommand()) + { + command.CommandText = sql; + await command.ExecuteNonQueryAsync(); + } + + await using (SqliteCommand command = connection.CreateCommand()) + { + command.CommandText = $"PRAGMA user_version = {fromVersion};"; + await command.ExecuteNonQueryAsync(); + } + } + } + + private bool CheckDbFileExists(DbContext context) + { + string dbFile = GetDatabaseFile(context); + return File.Exists(dbFile); + } + + private string GetDatabaseFile(DbContext context) + { + SqliteConnection connection = (SqliteConnection)context.Database.GetDbConnection(); + return connection.DataSource; + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/EpisodeModel.cs b/server/AniStream.Models/Models/EpisodeModel.cs new file mode 100644 index 0000000..e06e4eb --- /dev/null +++ b/server/AniStream.Models/Models/EpisodeModel.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[Table("episode")] +[PrimaryKey(nameof(EpisodeId))] +public sealed class EpisodeModel +{ + public int EpisodeId { get; set; } + + public int SeasonId { get; set; } + + public int EpisodeNumber { get; set; } + + public string GermanTitle { get; set; } + + public string EnglishTitle { get; set; } + + public string Description { get; set; } + + public EpisodeModel( + int episodeId, + int seasonId, + int episodeNumber, + string germanTitle, + string englishTitle, + string description + ) + { + EpisodeId = episodeId; + SeasonId = seasonId; + EpisodeNumber = episodeNumber; + GermanTitle = germanTitle; + EnglishTitle = englishTitle; + Description = description; + } + + public EpisodeModel( + int seasonId, + int episodeNumber, + string germanTitle, + string englishTitle, + string description + ) : this( + 0, + seasonId, + episodeNumber, + germanTitle, + englishTitle, + description + ) + { + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/GenreModel.cs b/server/AniStream.Models/Models/GenreModel.cs new file mode 100644 index 0000000..6fc6ae3 --- /dev/null +++ b/server/AniStream.Models/Models/GenreModel.cs @@ -0,0 +1,25 @@ + +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[Table("genre")] +[PrimaryKey(nameof(GenreId))] +public sealed class GenreModel +{ + public int GenreId { get; set; } + + public string Key { get; set; } + + public GenreModel(string key) : this(0, key) + { + + } + + public GenreModel(int genreId, string key) + { + GenreId = genreId; + Key = key; + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/GenreToSeriesModel.cs b/server/AniStream.Models/Models/GenreToSeriesModel.cs new file mode 100644 index 0000000..b878e7a --- /dev/null +++ b/server/AniStream.Models/Models/GenreToSeriesModel.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[Table("genre_to_series")] +[PrimaryKey(nameof(GenreId), nameof(SeriesId))] +public sealed class GenreToSeries +{ + public int GenreId { get; set; } + + public int SeriesId { get; set; } + + public bool MainGenre { get; set; } + + public GenreToSeries(int genreId, int seriesId, bool mainGenre) + { + GenreId = genreId; + SeriesId = seriesId; + MainGenre = mainGenre; + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/ListModel.cs b/server/AniStream.Models/Models/ListModel.cs new file mode 100644 index 0000000..1d17ca9 --- /dev/null +++ b/server/AniStream.Models/Models/ListModel.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[Table("list")] +[PrimaryKey(nameof(ListId))] +public sealed class ListModel +{ + public int ListId { get; set; } + + public string Name { get; set; } + + public string TenantId { get; set; } + + public ListModel(string name, string tenantId) : this(0, name, tenantId) + { + } + + public ListModel(int listId, string name, string tenantId) + { + ListId = listId; + Name = name; + TenantId = tenantId; + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/ListToSeriesModel.cs b/server/AniStream.Models/Models/ListToSeriesModel.cs new file mode 100644 index 0000000..1271a16 --- /dev/null +++ b/server/AniStream.Models/Models/ListToSeriesModel.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[Table("list_to_series")] +[PrimaryKey(nameof(ListId), nameof(SeriesId))] +public sealed class ListToSeriesModel +{ + public int ListId { get; set; } + + public int SeriesId { get; set; } + + public ListToSeriesModel(int listId, int seriesId) + { + ListId = listId; + SeriesId = seriesId; + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/ProfileModel.cs b/server/AniStream.Models/Models/ProfileModel.cs new file mode 100644 index 0000000..c895c1c --- /dev/null +++ b/server/AniStream.Models/Models/ProfileModel.cs @@ -0,0 +1,92 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[PrimaryKey(nameof(ProfileId))] +[Table("profile")] +public sealed class ProfileModel +{ + public int ProfileId { get; set; } + + public string Uuid { get; set; } + + public string Name { get; set; } + + public string Password { get; set; } + + public string PasswordSalt { get; set; } + + public string BackgroundColor { get; set; } + + public string Eye { get; set; } + + public string Mouth { get; set; } + + public string Theme { get; set; } + + public string Lang { get; set; } + + public bool TosAccepted { get; set; } + + public bool SyncCatalog { get; set; } + + public ProfileModel( + int profileId, + string uuid, + string name, + string password, + string passwordSalt, + string backgroundColor, + string eye, + string mouth, + string theme, + string lang, + bool tosAccepted, + bool syncCatalog + ) + { + ProfileId = profileId; + Uuid = uuid; + Name = name; + Password = password; + PasswordSalt = passwordSalt; + BackgroundColor = backgroundColor; + Eye = eye; + Mouth = mouth; + Theme = theme; + Lang = lang; + TosAccepted = tosAccepted; + SyncCatalog = syncCatalog; + } + + public ProfileModel( + string uuid, + string name, + string password, + string passwordSalt, + string backgroundColor, + string eye, + string mouth, + string theme, + string lang, + bool tosAccepted, + bool syncCatalog + ) : this( + 0, + uuid, + name, + password, + passwordSalt, + backgroundColor, + eye, + mouth, + theme, + lang, + tosAccepted, + syncCatalog + ) + { + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/SeasonModel.cs b/server/AniStream.Models/Models/SeasonModel.cs new file mode 100644 index 0000000..dc74f4d --- /dev/null +++ b/server/AniStream.Models/Models/SeasonModel.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[Table("season")] +[PrimaryKey(nameof(SeasonId))] +public sealed class SeasonModel +{ + public int SeasonId { get; set; } + + public int SeriesId { get; set; } + + public int SeasonNumber { get; set; } + + public SeasonModel(int seasonId, int seriesId, int seasonNumber) + { + SeasonId = seasonId; + SeriesId = seriesId; + SeasonNumber = seasonNumber; + } + + public SeasonModel(int seriesId, int seasonNumber) : this(0, seriesId, seasonNumber) + { + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/SeriesModel.cs b/server/AniStream.Models/Models/SeriesModel.cs new file mode 100644 index 0000000..db16890 --- /dev/null +++ b/server/AniStream.Models/Models/SeriesModel.cs @@ -0,0 +1,49 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[Table("series")] +[PrimaryKey(nameof(SeriesId))] +public sealed class SeriesModel +{ + public int SeriesId { get; set; } + + public string Guid { get; set; } + + public string Title { get; set; } + + public string Description { get; set; } + + public string? PreviewImage { get; set; } + + public SeriesModel( + int seriesId, + string guid, + string title, + string description, + string? previewImage + ) + { + SeriesId = seriesId; + Guid = guid; + Title = title; + Description = description; + PreviewImage = previewImage; + } + + public SeriesModel( + string guid, + string title, + string description, + string? previewImage + ) : this ( + 0, + guid, + title, + description, + previewImage + ) + { + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/SyncJobStatus.cs b/server/AniStream.Models/Models/SyncJobStatus.cs new file mode 100644 index 0000000..a13e41f --- /dev/null +++ b/server/AniStream.Models/Models/SyncJobStatus.cs @@ -0,0 +1,9 @@ +namespace AniStream.Models; + +public enum SyncJobStatus +{ + Queued, + Processing, + Completed, + Failed +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/SyncProviderJobModel.cs b/server/AniStream.Models/Models/SyncProviderJobModel.cs new file mode 100644 index 0000000..a788d13 --- /dev/null +++ b/server/AniStream.Models/Models/SyncProviderJobModel.cs @@ -0,0 +1,61 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[Table("sync_provider_job")] +[PrimaryKey(nameof(SyncProviderJobId))] +public sealed class SyncProviderJobModel +{ + public int SyncProviderJobId { get; set; } + + public int EpisodeId { get; set; } + + public SyncJobStatus Status { get; set; } + + public DateTime Started { get; set; } + + public DateTime? Completed { get; set; } + + public string? Error { get; set; } + + public DateTime? Expires { get; set; } + + public SyncProviderJobModel( + int syncProviderJobId, + int episodeId, + SyncJobStatus status, + DateTime started, + DateTime? completed, + string? error, + DateTime? expires + ) + { + SyncProviderJobId = syncProviderJobId; + EpisodeId = episodeId; + Status = status; + Started = started; + Completed = completed; + Error = error; + Expires = expires; + } + + public SyncProviderJobModel( + int episodeId, + SyncJobStatus status, + DateTime started, + DateTime? completed, + string? error, + DateTime? expires + ) : this( + 0, + episodeId, + status, + started, + completed, + error, + expires + ) + { + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/SyncProviderJobResultModel.cs b/server/AniStream.Models/Models/SyncProviderJobResultModel.cs new file mode 100644 index 0000000..15f4db0 --- /dev/null +++ b/server/AniStream.Models/Models/SyncProviderJobResultModel.cs @@ -0,0 +1,49 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[Table("sync_provider_job_result")] +[PrimaryKey(nameof(SyncProviderJobResultId))] +public sealed class SyncProviderJobResultModel +{ + public int SyncProviderJobResultId { get; set; } + + public int SyncProviderJobId { get; set; } + + public string Provider { get; set; } + + public string Url { get; set; } + + public int LanguageCode { get; set; } + + public SyncProviderJobResultModel( + int syncProviderJobResultId, + int syncProviderJobId, + string provider, + string url, + int languageCode + ) + { + SyncProviderJobResultId = syncProviderJobResultId; + SyncProviderJobId = syncProviderJobId; + Provider = provider; + Url = url; + LanguageCode = languageCode; + } + + public SyncProviderJobResultModel( + int syncProviderJobId, + string provider, + string url, + int languageCode + ) : this( + 0, + syncProviderJobId, + provider, + url, + languageCode + ) + { + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/SyncSeriesJobModel.cs b/server/AniStream.Models/Models/SyncSeriesJobModel.cs new file mode 100644 index 0000000..504ab9d --- /dev/null +++ b/server/AniStream.Models/Models/SyncSeriesJobModel.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[Table("sync_series_job")] +[PrimaryKey(nameof(SyncSeriesJobId))] +public sealed class SyncSeriesJobModel +{ + public int SyncSeriesJobId { get; set; } + + public int SeriesId { get; set; } + + public SyncJobStatus Status { get; set; } + + public DateTime Started { get; set; } + + public DateTime? Completed { get; set; } + + public string? Error { get; set; } + + public SyncSeriesJobModel( + int syncSeriesJobId, + int seriesId, + SyncJobStatus status, + DateTime started, + DateTime? completed, + string? error + ) + { + SyncSeriesJobId = syncSeriesJobId; + SeriesId = seriesId; + Status = status; + Started = started; + Completed = completed; + Error = error; + } + + public SyncSeriesJobModel( + int seriesId, + SyncJobStatus status, + DateTime started, + DateTime? completed, + string? error + ) : this( + 0, + seriesId, + status, + started, + completed, + error + ) + { + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/WatchListModel.cs b/server/AniStream.Models/Models/WatchListModel.cs new file mode 100644 index 0000000..56f9a0b --- /dev/null +++ b/server/AniStream.Models/Models/WatchListModel.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[Table("watchlist")] +[PrimaryKey(nameof(WatchlistId))] +public sealed class WatchListModel +{ + public int WatchlistId { get; set; } + + public int SeriesId { get; set; } + + public string TenantId { get; set; } + + public WatchListModel(int seriesId, string tenantId) : this(0, seriesId, tenantId) + { + } + + public WatchListModel(int watchlistId, int seriesId, string tenantId) + { + WatchlistId = watchlistId; + SeriesId = seriesId; + TenantId = tenantId; + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Models/WatchTimeModel.cs b/server/AniStream.Models/Models/WatchTimeModel.cs new file mode 100644 index 0000000..9cf8ca1 --- /dev/null +++ b/server/AniStream.Models/Models/WatchTimeModel.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Models; + +[Table("watchtime")] +[PrimaryKey(nameof(WatchtimeId))] +public sealed class WatchTimeModel +{ + public int WatchtimeId { get; set; } + public int EpisodeId { get; set; } + public int PercentageWatched { get; set; } + public double StoppedTime { get; set; } + public string TenantId { get; set; } + + public WatchTimeModel( + int watchtimeId, + int episodeId, + int percentageWatched, + double stoppedTime, + string tenantId + ) + { + WatchtimeId = watchtimeId; + EpisodeId = episodeId; + PercentageWatched = percentageWatched; + StoppedTime = stoppedTime; + TenantId = tenantId; + } + + public WatchTimeModel( + int episodeId, + int percentageWatched, + double stoppedTime, + string tenantId + ) : this( + 0, + episodeId, + percentageWatched, + stoppedTime, + tenantId + ) + { + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Utils/DbContextFactory.cs b/server/AniStream.Models/Utils/DbContextFactory.cs new file mode 100644 index 0000000..b12fd81 --- /dev/null +++ b/server/AniStream.Models/Utils/DbContextFactory.cs @@ -0,0 +1,82 @@ +using AniStream.Migrators; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Utils; + +public abstract class DbContextFactory where T : DbContext +{ + public const int SchemaVersion = 1; + + private readonly DbType _dbType; + private readonly string _migrationFolder; + private readonly string _databaseSchema; + + protected DbContextFactory(string dbType, string migrationFolder, string databaseSchema) + { + if (!Directory.Exists(migrationFolder)) + { + throw new ArgumentException($"Migration folder '{migrationFolder}' does not exist", nameof(migrationFolder)); + } + + if (databaseSchema != "metadata" && databaseSchema != "profile") + { + throw new ArgumentException($"Unknown database schema '{databaseSchema}', must be 'profile' or 'metadata'", nameof(databaseSchema)); + } + + _migrationFolder = migrationFolder; + _databaseSchema = databaseSchema; + + switch (dbType.ToLower()) + { + case "sqlite": + _dbType = DbType.Sqlite; + break; + default: + throw new ArgumentException($"Unknown database driver '{_dbType}'", nameof(dbType)); + } + } + + public abstract Task GetContext(); + + protected void UseDbVariant(DbContextOptionsBuilder builder, string connectionString) + { + switch (_dbType) + { + case DbType.Sqlite: + builder.UseSqlite(connectionString); + break; + default: + throw new InvalidOperationException($"Cannot setup EF for '{_dbType}'"); + + } + } + + protected async Task MigrateDatabaseIfRequired(T context) + { + DbMigrator migrator = GetMigrator(); + int currentVersion = await migrator.GetCurrentVersion(context); + if (currentVersion > SchemaVersion) + { + throw new Exception($"Database schema is newer than expected ({currentVersion} > {SchemaVersion})"); + } + + if (currentVersion < SchemaVersion) + { + await migrator.Migrate(context, currentVersion); + } + } + + private DbMigrator GetMigrator() + { + return _dbType switch + { + DbType.Sqlite => new SqliteMigrator(_migrationFolder, _databaseSchema), + _ => throw new InvalidOperationException($"Cannot find migrator for '{_dbType}'") + }; + } + + private enum DbType + { + Sqlite + } +} \ No newline at end of file diff --git a/server/AniStream.Models/Utils/IDbMigrator.cs b/server/AniStream.Models/Utils/IDbMigrator.cs new file mode 100644 index 0000000..d23b0ef --- /dev/null +++ b/server/AniStream.Models/Utils/IDbMigrator.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Utils; + +public abstract class DbMigrator +{ + private readonly string _driverKeyword; + private readonly string _databaseSchema; + private readonly string _migrationPath; + + protected DbMigrator(string migrationPath, string databaseSchema, string driverKeyword) + { + _migrationPath = migrationPath; + _databaseSchema = databaseSchema; + _driverKeyword = driverKeyword; + } + + public abstract Task GetCurrentVersion(DbContext context); + + public abstract Task Migrate(DbContext context, int fromVersion); + + protected string ReadMigrationFile(int version) + { + if (version <= 0) + { + throw new ArgumentOutOfRangeException(nameof(version), "Version must be positive"); + } + + string fullPath = Path.Join(_migrationPath, _driverKeyword, _databaseSchema, $"{version}.sql"); + if (!File.Exists(fullPath)) + { + throw new ArgumentException($"Migration file not found for version '{version}' ('{fullPath}')"); + } + + return File.ReadAllText(fullPath); + } +} diff --git a/server/AniStream.Models/Utils/SnakeCaseConvention.cs b/server/AniStream.Models/Utils/SnakeCaseConvention.cs new file mode 100644 index 0000000..069b711 --- /dev/null +++ b/server/AniStream.Models/Utils/SnakeCaseConvention.cs @@ -0,0 +1,39 @@ +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; + +namespace AniStream.Utils; + +public sealed partial class SnakeCaseConvention : IModelFinalizingConvention +{ + public void ProcessModelFinalizing(IConventionModelBuilder builder, IConventionContext context) + { + foreach (IConventionEntityType entity in builder.Metadata.GetEntityTypes()) + { + entity.SetTableName(entity.GetTableName()?.ToLowerInvariant()); + + foreach (IConventionProperty property in entity.GetProperties()) + { + property.SetColumnName(ToSnakeCase(property.Name)); + } + } + } + + public static string ToSnakeCase(string input) + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + + return SnakeCaseRegex() + .Replace(input, "$1_$2") + .ToLowerInvariant() + .Replace("__", "_"); + } + + [GeneratedRegex("([a-z0-9])([A-Z])")] + private static partial Regex SnakeCaseRegex(); +} \ No newline at end of file diff --git a/server/AniStream.Services/AniStream.Services.csproj b/server/AniStream.Services/AniStream.Services.csproj new file mode 100644 index 0000000..acf40ff --- /dev/null +++ b/server/AniStream.Services/AniStream.Services.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + AniStream + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/server/AniStream.Services/Contexts/MetadataDbContext.cs b/server/AniStream.Services/Contexts/MetadataDbContext.cs new file mode 100644 index 0000000..85c49c3 --- /dev/null +++ b/server/AniStream.Services/Contexts/MetadataDbContext.cs @@ -0,0 +1,69 @@ +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Utils; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Contexts; + +public sealed class MetadataDbContext : DbContext +{ + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder.Conventions.Add(_ => new SnakeCaseConvention()); + } + + public MetadataDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Genres { get; set; } + + public DbSet GenresToSeries { get; set; } + + public DbSet Series { get; set; } + + public DbSet Seasons { get; set; } + + public DbSet Episodes { get; set; } + + public DbSet Lists { get; set; } + + public DbSet ListsToSeries { get; set; } + + public DbSet WatchTimes { get; set; } + + public DbSet WatchLists { get; set; } + + public DbSet SyncSeriesJobs { get; set; } + + public DbSet SyncProviderJobs { get; set; } + + public DbSet SyncProviderJobResults { get; set; } +} + +internal sealed class MetadataDbContextFactory : DbContextFactory +{ + private readonly string _connectionString; + private readonly IProviderService _providerService; + + public MetadataDbContextFactory(string dbType, string migrationFolder, string connectionString, IProviderService providerService) : base(dbType, + migrationFolder, "metadata") + { + _connectionString = connectionString; + _providerService = providerService; + } + + public override async Task GetContext() + { + string providerName = _providerService.GetActiveProvider(); + string actualConnString = String.Format(_connectionString, providerName); + + DbContextOptionsBuilder builder = new DbContextOptionsBuilder(); + UseDbVariant(builder, actualConnString); + + MetadataDbContext context = new MetadataDbContext(builder.Options); + await MigrateDatabaseIfRequired(context); + + return context; + } +} \ No newline at end of file diff --git a/server/AniStream.Services/Contexts/ProfileDbContext.cs b/server/AniStream.Services/Contexts/ProfileDbContext.cs new file mode 100644 index 0000000..fcf1793 --- /dev/null +++ b/server/AniStream.Services/Contexts/ProfileDbContext.cs @@ -0,0 +1,40 @@ +using AniStream.Models; +using AniStream.Utils; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Contexts; + +public sealed class ProfileDbContext : DbContext +{ + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder.Conventions.Add(_ => new SnakeCaseConvention()); + } + + public ProfileDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Profiles { get; set; } +} + +internal sealed class ProfileDbContextFactory : DbContextFactory +{ + private readonly string _connectionString; + + public ProfileDbContextFactory(string dbType, string migrationFolder, string connectionString) : base(dbType, migrationFolder, "profile") + { + _connectionString = connectionString; + } + + public override async Task GetContext() + { + DbContextOptionsBuilder builder = new DbContextOptionsBuilder(); + UseDbVariant(builder, _connectionString); + + ProfileDbContext context = new ProfileDbContext(builder.Options); + await MigrateDatabaseIfRequired(context); + + return context; + } +} \ No newline at end of file diff --git a/server/AniStream.Services/Contracts/ICredentialService.cs b/server/AniStream.Services/Contracts/ICredentialService.cs new file mode 100644 index 0000000..edd39ae --- /dev/null +++ b/server/AniStream.Services/Contracts/ICredentialService.cs @@ -0,0 +1,10 @@ +using AniStream.Models; + +namespace AniStream.Contracts; + +public interface ICredentialsService +{ + public Task ValidateCredentials(string uuid, string password); + + public Task GetCurrentUuid(); +} \ No newline at end of file diff --git a/server/AniStream.Services/Contracts/IEpisodeService.cs b/server/AniStream.Services/Contracts/IEpisodeService.cs new file mode 100644 index 0000000..14927a0 --- /dev/null +++ b/server/AniStream.Services/Contracts/IEpisodeService.cs @@ -0,0 +1,37 @@ +using AniStream.Models; + +namespace AniStream.Contracts; + +public interface IEpisodeService +{ + public Task GetEpisode(int episodeId); + + public Task GetEpisodes(int seasonId); + + public Task CreateEpisode( + int seasonId, + int episodeNumber, + string germanTitle, + string englishTitle, + string description + ); + + public Task UpdateEpisode( + int episodeId, + int? seasonId = null, + int? episodeNumber = null, + string? germanTitle = null, + string? englishTitle = null, + string? description = null + ); + + public Task UpdateEpisode( + EpisodeModel episode, + int? seasonId = null, + int? episodeNumber = null, + string? germanTitle = null, + string? englishTitle = null, + string? description = null + ); + +} diff --git a/server/AniStream.Services/Contracts/IGenreService.cs b/server/AniStream.Services/Contracts/IGenreService.cs new file mode 100644 index 0000000..a3d7d7e --- /dev/null +++ b/server/AniStream.Services/Contracts/IGenreService.cs @@ -0,0 +1,20 @@ +using AniStream.Models; + +namespace AniStream.Contracts; + +public interface IGenreService +{ + public Task CreateGenre(string key); + + public Task CreateGenreToSeries(int genreId, int seriesId, bool mainGenre); + + public Task GetGenres(); + + public Task GetGenre(int genreId); + + public Task GetGenre(string key); + + public Task GetMainGenreOfSeries(int seriesId); + + public Task GetNonMainGenresOfSeries(int seriesId); +} \ No newline at end of file diff --git a/server/AniStream.Services/Contracts/IListService.cs b/server/AniStream.Services/Contracts/IListService.cs new file mode 100644 index 0000000..064cc26 --- /dev/null +++ b/server/AniStream.Services/Contracts/IListService.cs @@ -0,0 +1,38 @@ +using AniStream.Models; + +namespace AniStream.Contracts; + +public interface IListService +{ + public Task GetLists(); + + public Task GetList(int listId); + + public Task GetListsOfSeries(int seriesId); + + public Task CreateList(string name); + + public Task UpdateList(int listId, string name); + + public Task UpdateList(ListModel list, string name); + + public Task DeleteList(int listId); + + public Task DeleteList(ListModel list); + + public Task GetSeries(int listId); + + public Task GetSeries(ListModel list); + + public Task AddSeriesToList(int listId, int seriesId); + + public Task AddSeriesToList(ListModel list, int seriesId); + + public Task RemoveSeriesFromList(int listId, int seriesId); + + public Task RemoveSeriesFromList(ListModel list, int seriesId); + + public Task GetPreviewImages(int listId); + + public Task GetPreviewImages(ListModel list); +} \ No newline at end of file diff --git a/server/AniStream.Services/Contracts/IProviderService.cs b/server/AniStream.Services/Contracts/IProviderService.cs new file mode 100644 index 0000000..764dca4 --- /dev/null +++ b/server/AniStream.Services/Contracts/IProviderService.cs @@ -0,0 +1,10 @@ +namespace AniStream.Contracts; + +public interface IProviderService +{ + public string GetActiveProvider(); + + public void SetActiveProvider(string providerName); + + public string[] GetProviders(); +} \ No newline at end of file diff --git a/server/AniStream.Services/Contracts/IProviderSyncService.cs b/server/AniStream.Services/Contracts/IProviderSyncService.cs new file mode 100644 index 0000000..a0ecb14 --- /dev/null +++ b/server/AniStream.Services/Contracts/IProviderSyncService.cs @@ -0,0 +1,42 @@ +using AniStream.Models; + +namespace AniStream.Contracts; + +public interface IProviderSyncService +{ + public Task GetSyncJob(int syncProviderJobId); + + public Task GetSyncJobByEpisode(int episodeId); + + public Task GetSyncJobByEpisode(EpisodeModel episode); + + public Task RequestSync(int episodeId); + + public Task RequestSync(EpisodeModel episode); + + public Task IsSyncing(int episodeId); + + public Task IsSyncing(EpisodeModel episode); + + public Task GetSyncJobs(SyncJobStatus status); + + public Task UpdateSyncJob( + int syncProviderJobId, + SyncJobStatus? status = null, + DateTime? finished = null, + DateTime? expires = null, + string? error = null + ); + + public Task UpdateSyncJob( + SyncProviderJobModel syncJob, + SyncJobStatus? status = null, + DateTime? finished = null, + DateTime? expires = null, + string? error = null + ); + + public Task CreateSyncResult(int syncProviderJobId, string provider, string url, int languageCode); + + public Task GetSyncResults(int episodeId); +} \ No newline at end of file diff --git a/server/AniStream.Services/Contracts/IResourceService.cs b/server/AniStream.Services/Contracts/IResourceService.cs new file mode 100644 index 0000000..ea1b02c --- /dev/null +++ b/server/AniStream.Services/Contracts/IResourceService.cs @@ -0,0 +1,6 @@ +namespace AniStream.Contracts; + +public interface IResourceService +{ + public Stream? GetResource(string hash); +} \ No newline at end of file diff --git a/server/AniStream.Services/Contracts/ISeasonService.cs b/server/AniStream.Services/Contracts/ISeasonService.cs new file mode 100644 index 0000000..19fdf87 --- /dev/null +++ b/server/AniStream.Services/Contracts/ISeasonService.cs @@ -0,0 +1,12 @@ +using AniStream.Models; + +namespace AniStream.Contracts; + +public interface ISeasonService +{ + public Task GetSeason(int seasonId); + + public Task GetSeasons(int seriesId); + + public Task CreateSeason(int seriesId, int seasonNumber); +} \ No newline at end of file diff --git a/server/AniStream.Services/Contracts/ISeriesService.cs b/server/AniStream.Services/Contracts/ISeriesService.cs new file mode 100644 index 0000000..7cfebfc --- /dev/null +++ b/server/AniStream.Services/Contracts/ISeriesService.cs @@ -0,0 +1,30 @@ +using AniStream.Models; + +namespace AniStream.Contracts; + +public interface ISeriesService +{ + public Task GetSeries(int seriesId); + + public Task GetSeries(string guid); + + public Task CreateSeries( + string guid, + string title, + string description, + string? previewImage + ); + + public Task GetSeriesChunk( + int offset, + int limit, + string? searchText, + int[]? genreIds + ); + + public Task GetStartedSeries(); + + public Task GetSeriesByIds(int[] seriesIds); + + public Task RequiresSync(int seriesId); +} \ No newline at end of file diff --git a/server/AniStream.Services/Contracts/ISeriesSyncService.cs b/server/AniStream.Services/Contracts/ISeriesSyncService.cs new file mode 100644 index 0000000..4a3a9c5 --- /dev/null +++ b/server/AniStream.Services/Contracts/ISeriesSyncService.cs @@ -0,0 +1,36 @@ +using AniStream.Models; + +namespace AniStream.Contracts; + +public interface ISeriesSyncService +{ + public Task GetSyncJob(int syncSeriesJobId); + + public Task GetSyncJobBySeries(int seriesId); + + public Task GetSyncJobBySeries(SeriesModel series); + + public Task RequestSync(int seriesId); + + public Task RequestSync(SeriesModel series); + + public Task IsSyncing(int seriesId); + + public Task IsSyncing(SeriesModel series); + + public Task GetSyncJobs(SyncJobStatus status); + + public Task UpdateSyncJob( + int syncSeriesJobId, + SyncJobStatus? status = null, + DateTime? finished = null, + string? error = null + ); + + public Task UpdateSyncJob( + SyncSeriesJobModel syncJob, + SyncJobStatus? status = null, + DateTime? finished = null, + string? error = null + ); +} \ No newline at end of file diff --git a/server/AniStream.Services/Contracts/IUserService.cs b/server/AniStream.Services/Contracts/IUserService.cs new file mode 100644 index 0000000..3346016 --- /dev/null +++ b/server/AniStream.Services/Contracts/IUserService.cs @@ -0,0 +1,54 @@ +using AniStream.Models; + +namespace AniStream.Contracts; + +public interface IUserService +{ + public Task CreateProfile( + string uuid, + string name, + string password, + string passwordSalt, + string backgroundColor, + string eye, + string mouth, + string theme, + string lang, + bool tosAccepted, + bool syncCatalog + ); + + public Task GetActiveProfile(); + + public Task GetProfiles(); + + public Task GetProfileByUsername(string username); + + public Task GetProfile(string uuid); + + public Task GetProfile(int profileId); + + public Task UpdateProfile( + int profileId, + string? name = null, + string? backgroundColor = null, + string? eye = null, + string? mouth = null, + string? theme = null, + string? lang = null, + bool? tosAccepted = null, + bool? syncCatalog = null + ); + + public Task UpdateProfile( + ProfileModel profile, + string? name = null, + string? backgroundColor = null, + string? eye = null, + string? mouth = null, + string? theme = null, + string? lang = null, + bool? tosAccepted = null, + bool? syncCatalog = null + ); +} \ No newline at end of file diff --git a/server/AniStream.Services/Contracts/IWatchListService.cs b/server/AniStream.Services/Contracts/IWatchListService.cs new file mode 100644 index 0000000..94b9484 --- /dev/null +++ b/server/AniStream.Services/Contracts/IWatchListService.cs @@ -0,0 +1,12 @@ +namespace AniStream.Contracts; + +public interface IWatchListService +{ + public Task GetSeriesIds(); + + public Task IsSeriesOnList(int seriesId); + + public Task AddSeries(int seriesId); + + public Task RemoveSeries(int seriesId); +} \ No newline at end of file diff --git a/server/AniStream.Services/Contracts/IWatchTimeService.cs b/server/AniStream.Services/Contracts/IWatchTimeService.cs new file mode 100644 index 0000000..8110831 --- /dev/null +++ b/server/AniStream.Services/Contracts/IWatchTimeService.cs @@ -0,0 +1,30 @@ +using AniStream.Models; + +namespace AniStream.Contracts; + +public interface IWatchTimeService +{ + public Task GetWatchTime(int episodeId); + + public Task GetWatchTimesOfSeries(int seriesId); + + public Task GetWatchTimesOfSeason(int seasonId); + + public Task GetWatchTimeOfEpisode(int episodeId); + + public Task GetTotalWatchProgression(int seriesId); + + public Task CreateWatchTime(int episodeId, int percentageWatched, double stoppedTime); + + public Task UpdateWatchTime(int watchtimeId, int? percentageWatched = null, double? stoppedTime = null); + + public Task UpdateWatchTime(WatchTimeModel watchtime, int? percentageWatched = null, double? stoppedTime = null); + + public Task UpdateWatchTimes(WatchTimeModel[] watchTimes, int? percentageWatched = null, double? stoppedTime = null); + + public Task UpdateWatchTimeOfEpisode(int episodeId, int? percentageWatched = null, double? stoppedTime = null); + + public Task UpdateWatchTimeOfSeason(int seasonId, int? percentageWatched = null, double? stoppedTime = null); + + public Task UpdateWatchTimeOfSeries(int seriesId, int? percentageWatched = null, double? stoppedTime = null); +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/AutoLoader.cs b/server/AniStream.Services/Services/AutoLoader.cs new file mode 100644 index 0000000..bfdab37 --- /dev/null +++ b/server/AniStream.Services/Services/AutoLoader.cs @@ -0,0 +1,61 @@ +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace AniStream.Services; + +public static class AutoLoader +{ + public static void LoadServices(IServiceCollection services, Options options) + { + services.AddScoped>(_ => + new ProfileDbContextFactory( + options.DatabaseDriver, + options.MigrationPath, + options.DatabaseProfileConnectionString + ) + ); + + services.AddScoped>(sp => + { + IProviderService providerService = sp.GetRequiredService(); + + return new MetadataDbContextFactory( + options.DatabaseDriver, + options.MigrationPath, + options.DatabaseMetadataConnectionString, + providerService + ); + }); + + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + ResourceServiceImpl.AssetsPath = options.AssetsPath; + } + + public struct Options + { + public string DatabaseDriver; + + public string AssetsPath; + + public string MigrationPath; + + public string DatabaseProfileConnectionString; + + public string DatabaseMetadataConnectionString; + } +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/EpisodeServiceImpl.cs b/server/AniStream.Services/Services/EpisodeServiceImpl.cs new file mode 100644 index 0000000..46810a2 --- /dev/null +++ b/server/AniStream.Services/Services/EpisodeServiceImpl.cs @@ -0,0 +1,113 @@ +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Utils; + +namespace AniStream.Services; + +class EpisodeServiceImpl : IEpisodeService +{ + private readonly DbContextFactory _dbFactory; + + public EpisodeServiceImpl(DbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + + public async Task CreateEpisode(int seasonId, int episodeNumber, string germanTitle, string englishTitle, string description) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + EpisodeModel episode = new EpisodeModel(seasonId, episodeNumber, germanTitle, englishTitle, description); + + db.Episodes.Add(episode); + await db.SaveChangesAsync(); + + return episode; + } + + public async Task GetEpisode(int episodeId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from episode in db.Episodes where episode.EpisodeId == episodeId select episode; + + return query.FirstOrDefault(); + } + + public async Task GetEpisodes(int seasonId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from episode in db.Episodes where episode.SeasonId == seasonId select episode; + + return query.ToArray(); + } + + public async Task UpdateEpisode( + int episodeId, + int? seasonId = null, + int? episodeNumber = null, + string? germanTitle = null, + string? englishTitle = null, + string? description = null + ) + { + EpisodeModel? episode = await GetEpisode(episodeId); + if (episode is null) + { + throw new ArgumentException($"Episode with ID '{episodeId}' dont exist", nameof(episodeId)); + } + + return await UpdateEpisode( + episode, + seasonId, + episodeNumber, + germanTitle, + englishTitle, + description + ); + } + + public async Task UpdateEpisode( + EpisodeModel episode, + int? seasonId = null, + int? episodeNumber = null, + string? germanTitle = null, + string? englishTitle = null, + string? description = null + ) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + if (seasonId is not null) + { + episode.SeasonId = (int)seasonId; + } + + if (episodeNumber is not null) + { + episode.EpisodeNumber = (int)episodeNumber; + } + + if (germanTitle is not null) + { + episode.GermanTitle = germanTitle; + } + + if (englishTitle is not null) + { + episode.EnglishTitle = englishTitle; + } + + if (description is not null) + { + episode.Description = description; + } + + db.Episodes.Update(episode); + await db.SaveChangesAsync(); + + return episode; + } +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/GenreServiceImpl.cs b/server/AniStream.Services/Services/GenreServiceImpl.cs new file mode 100644 index 0000000..20bb6d2 --- /dev/null +++ b/server/AniStream.Services/Services/GenreServiceImpl.cs @@ -0,0 +1,83 @@ +using System.Threading.Tasks; +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Utils; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Services; + +public sealed class GenreServiceImpl : IGenreService +{ + private readonly DbContextFactory _dbFactory; + + public GenreServiceImpl(DbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + + public async Task CreateGenre(string key) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + GenreModel model = new GenreModel(key); + + db.Genres.Add(model); + await db.SaveChangesAsync(); + + return model; + } + + public async Task CreateGenreToSeries(int genreId, int seriesId, bool mainGenre) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + GenreToSeries genreToSeries = new GenreToSeries(genreId, seriesId, mainGenre); + db.GenresToSeries.Add(genreToSeries); + + await db.SaveChangesAsync(); + } + + public async Task GetGenres() + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + return await db.Genres.ToArrayAsync(); + } + + public async Task GetGenre(int genreId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from genre in db.Genres where genre.GenreId == genreId select genre; + return await query.FirstOrDefaultAsync(); + } + + public async Task GetGenre(string key) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from genre in db.Genres where genre.Key == key select genre; + return await query.FirstOrDefaultAsync(); + } + + public async Task GetMainGenreOfSeries(int seriesId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from gs in db.GenresToSeries + join g in db.Genres on gs.GenreId equals g.GenreId + where gs.SeriesId == seriesId && gs.MainGenre + select g; + return await query.FirstOrDefaultAsync(); + } + + public async Task GetNonMainGenresOfSeries(int seriesId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from gs in db.GenresToSeries + join g in db.Genres on gs.GenreId equals g.GenreId + where gs.SeriesId == seriesId && !gs.MainGenre + select g; + return await query.ToArrayAsync(); + } +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/ListServiceImpl.cs b/server/AniStream.Services/Services/ListServiceImpl.cs new file mode 100644 index 0000000..c7e05b5 --- /dev/null +++ b/server/AniStream.Services/Services/ListServiceImpl.cs @@ -0,0 +1,203 @@ +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Utils; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Services; + +public sealed class ListServiceImpl : IListService +{ + private DbContextFactory _dbFactory; + private readonly ICredentialsService _credentialsService; + + public ListServiceImpl(DbContextFactory dbFactory, ICredentialsService credentialsService) + { + _dbFactory = dbFactory; + _credentialsService = credentialsService; + } + + public async Task GetLists() + { + string tenantId = await _credentialsService.GetCurrentUuid(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from list in db.Lists where list.TenantId == tenantId select list; + + return await query.ToArrayAsync(); + } + + public async Task GetList(int listId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from list in db.Lists where list.ListId == listId select list; + + return await query.FirstOrDefaultAsync(); + } + + public async Task GetListsOfSeries(int seriesId) + { + string tenantId = await _credentialsService.GetCurrentUuid(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from listToSeries in db.ListsToSeries + join list in db.Lists on listToSeries.ListId equals list.ListId + where listToSeries.SeriesId == seriesId && list.TenantId == tenantId + select list; + + return await query.ToArrayAsync(); + } + + public async Task CreateList(string name) + { + string tenantId = await _credentialsService.GetCurrentUuid(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + ListModel list = new ListModel(name, tenantId); + + db.Lists.Add(list); + await db.SaveChangesAsync(); + + return list; + } + + public async Task UpdateList(int listId, string name) + { + ListModel? list = await GetList(listId); + if (list is null) + { + throw new Exception($"List with ID '{listId}' not found"); + } + + return await UpdateList(list, name); + } + + public async Task UpdateList(ListModel list, string name) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + list.Name = name; + + db.Lists.Update(list); + await db.SaveChangesAsync(); + + return list; + } + + public async Task DeleteList(int listId) + { + ListModel? list = await GetList(listId); + if (list is null) + { + throw new Exception($"List with ID '{listId}' not found"); + } + + await DeleteList(list); + } + + public async Task DeleteList(ListModel list) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + db.Lists.Remove(list); + await db.SaveChangesAsync(); + } + + public async Task GetSeries(int listId) + { + ListModel? list = await GetList(listId); + + if (list is null) + { + throw new Exception($"List with ID '{listId}' not found"); + } + + return await GetSeries(list); + } + + public async Task GetSeries(ListModel list) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from listToSeries in db.ListsToSeries + join series in db.Series on listToSeries.SeriesId equals series.SeriesId + where listToSeries.ListId == list.ListId + select series; + + return await query.ToArrayAsync(); + } + + public async Task AddSeriesToList(int listId, int seriesId) + { + ListModel? list = await GetList(listId); + if (list is null) + { + throw new Exception($"List with ID '{listId}' not found"); + } + + await AddSeriesToList(list, seriesId); + } + + public async Task AddSeriesToList(ListModel list, int seriesId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + ListToSeriesModel listToSeries = new ListToSeriesModel(list.ListId, seriesId); + + db.ListsToSeries.Add(listToSeries); + await db.SaveChangesAsync(); + } + + public async Task RemoveSeriesFromList(int listId, int seriesId) + { + ListModel? list = await GetList(listId); + if (list is null) + { + throw new Exception($"List with ID '{listId}' not found"); + } + + await RemoveSeriesFromList(list, seriesId); + } + + public async Task RemoveSeriesFromList(ListModel list, int seriesId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from lts in db.ListsToSeries + where lts.ListId == list.ListId && lts.SeriesId == seriesId + select lts; + + ListToSeriesModel? listToSeries = await query.FirstOrDefaultAsync(); + if (listToSeries is null) + { + return; + } + + db.ListsToSeries.Remove(listToSeries); + await db.SaveChangesAsync(); + } + + public async Task GetPreviewImages(int listId) + { + ListModel? list = await GetList(listId); + if (list is null) + { + throw new Exception($"List with ID '{listId}' not found"); + } + + return await GetPreviewImages(list); + } + + public async Task GetPreviewImages(ListModel list) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from listToSeries in db.ListsToSeries + join series in db.Series on listToSeries.SeriesId equals series.SeriesId + where listToSeries.ListId == list.ListId + select series.PreviewImage; + + return await query.Take(4).ToArrayAsync(); + } +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/ProviderServiceImpl.cs b/server/AniStream.Services/Services/ProviderServiceImpl.cs new file mode 100644 index 0000000..b51e0b9 --- /dev/null +++ b/server/AniStream.Services/Services/ProviderServiceImpl.cs @@ -0,0 +1,35 @@ +using AniStream.Contracts; + +namespace AniStream.Services; + +public sealed class ProviderServiceImpl : IProviderService +{ + public string? _providerName = null; + + public string GetActiveProvider() + { + if (_providerName is null) + { + throw new Exception("Active provider is not set. Did you forget to set it?"); + } + + return _providerName; + } + + public void SetActiveProvider(string providerName) + { + // TODO maybe resolve dynamic + if (providerName != "aniworld" && providerName != "sto") + { + throw new ArgumentException($"Invalid provider name '{providerName}'", nameof(providerName)); + } + + _providerName = providerName; + } + + public string[] GetProviders() + { + // TODO maybe resolve dynamic + return ["sto", "aniworld"]; + } +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/ProviderSyncServiceImpl.cs b/server/AniStream.Services/Services/ProviderSyncServiceImpl.cs new file mode 100644 index 0000000..ebbf07c --- /dev/null +++ b/server/AniStream.Services/Services/ProviderSyncServiceImpl.cs @@ -0,0 +1,199 @@ +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Utils; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Services; + +public sealed class ProviderSyncServiceImpl : IProviderSyncService +{ + private readonly DbContextFactory _dbFactory; + private readonly IEpisodeService _episodeService; + + public ProviderSyncServiceImpl(DbContextFactory dbFactory, IEpisodeService episodeService) + { + _dbFactory = dbFactory; + _episodeService = episodeService; + } + + public async Task GetSyncJob(int syncProviderJobId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from job in db.SyncProviderJobs where job.SyncProviderJobId == syncProviderJobId select job; + + return await query.FirstOrDefaultAsync(); + } + + public async Task GetSyncJobByEpisode(int episodeId) + { + EpisodeModel? episode = await _episodeService.GetEpisode(episodeId); + if (episode is null) + { + throw new ArgumentException($"Episode with ID {episodeId} not found", nameof(episodeId)); + } + + return await GetSyncJobByEpisode(episode); + } + + public async Task GetSyncJobByEpisode(EpisodeModel episode) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from job in db.SyncProviderJobs + where job.EpisodeId == episode.EpisodeId + select job; + + return await query.FirstOrDefaultAsync(); + } + + public async Task RequestSync(int episodeId) + { + EpisodeModel? episode = await _episodeService.GetEpisode(episodeId); + if (episode is null) + { + throw new ArgumentException($"Episode with ID {episodeId} not found", nameof(episodeId)); + } + + await RequestSync(episode); + } + + public async Task RequestSync(EpisodeModel episode) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + SyncProviderJobModel job = new SyncProviderJobModel(episode.EpisodeId, SyncJobStatus.Queued, DateTime.UtcNow, null, null, null); + + db.SyncProviderJobs.Add(job); + await db.SaveChangesAsync(); + } + + public async Task IsSyncing(int episodeId) + { + EpisodeModel? episode = await _episodeService.GetEpisode(episodeId); + if (episode is null) + { + throw new ArgumentException($"Episode with ID {episodeId} not found", nameof(episodeId)); + } + + return await IsSyncing(episode); + } + + public async Task IsSyncing(EpisodeModel episode) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from job in db.SyncProviderJobs + where job.EpisodeId == episode.EpisodeId && + job.Status != SyncJobStatus.Completed && + job.Status != SyncJobStatus.Failed + select job; + + return await query.AnyAsync(); + } + + public async Task GetSyncJobs(SyncJobStatus status) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from job in db.SyncProviderJobs + where job.Status == status + select job; + + return await query.ToArrayAsync(); + } + + public async Task UpdateSyncJob( + int syncProviderJobId, + SyncJobStatus? status = null, + DateTime? finished = null, + DateTime? expires = null, + string? error = null + ) + { + SyncProviderJobModel? job = await GetSyncJob(syncProviderJobId); + if (job is null) + { + throw new ArgumentException($"Job with ID {syncProviderJobId} not found", nameof(syncProviderJobId)); + } + + return await UpdateSyncJob( + job, + status, + finished, + expires, + error + ); + } + + public async Task UpdateSyncJob( + SyncProviderJobModel syncJob, + SyncJobStatus? status = null, + DateTime? finished = null, + DateTime? expires = null, + string? error = null + ) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + if (status is not null) + { + syncJob.Status = (SyncJobStatus)status; + } + + if (finished is not null) + { + syncJob.Completed = (DateTime)finished; + } + + if (expires is not null) + { + syncJob.Expires = (DateTime)expires; + } + + if (error is not null) + { + syncJob.Error = error; + } + + db.SyncProviderJobs.Update(syncJob); + await db.SaveChangesAsync(); + + return syncJob; + } + + public async Task CreateSyncResult(int syncProviderJobId, string provider, string url, int languageCode) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + SyncProviderJobResultModel result = new SyncProviderJobResultModel(syncProviderJobId, provider, url, languageCode); + + db.SyncProviderJobResults.Add(result); + await db.SaveChangesAsync(); + } + + public async Task GetSyncResults(int episodeId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query1 = from job in db.SyncProviderJobs + where job.EpisodeId == episodeId && + job.Status == SyncJobStatus.Completed && + job.Expires.HasValue + orderby job.SyncProviderJobId descending + select job; + + SyncProviderJobModel? latestJob = await query1.FirstOrDefaultAsync(); + if (latestJob?.Expires is null || latestJob.Expires.Value < DateTime.UtcNow) + { + return []; + } + + IQueryable query = from result in db.SyncProviderJobResults + where result.SyncProviderJobId == latestJob.SyncProviderJobId + select result; + + return await query.ToArrayAsync(); + } +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/ResourceServiceImpl.cs b/server/AniStream.Services/Services/ResourceServiceImpl.cs new file mode 100644 index 0000000..694c859 --- /dev/null +++ b/server/AniStream.Services/Services/ResourceServiceImpl.cs @@ -0,0 +1,33 @@ +using AniStream.Contracts; + +namespace AniStream.Services; + +public sealed class ResourceServiceImpl : IResourceService +{ + public static string? AssetsPath = null; + + private readonly IProviderService _providerService; + + public ResourceServiceImpl(IProviderService providerService) + { + _providerService = providerService; + } + + public Stream? GetResource(string hash) + { + if (AssetsPath is null) + { + throw new InvalidOperationException("Assets path is not set."); + } + + string provider = _providerService.GetActiveProvider(); + + string path = Path.Combine(AssetsPath, provider, hash); + if (!File.Exists(path)) + { + return null; + } + + return File.OpenRead(path); + } +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/SeasonServiceImpl.cs b/server/AniStream.Services/Services/SeasonServiceImpl.cs new file mode 100644 index 0000000..9a686e5 --- /dev/null +++ b/server/AniStream.Services/Services/SeasonServiceImpl.cs @@ -0,0 +1,45 @@ +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Utils; + +namespace AniStream.Services; + +public class SeasonServiceImpl : ISeasonService +{ + private DbContextFactory _dbFactory; + + public SeasonServiceImpl(DbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + + public async Task GetSeason(int seasonId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from season in db.Seasons where season.SeasonId == seasonId select season; + return query.FirstOrDefault(); + } + + public async Task GetSeasons(int seriesId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from season in db.Seasons where season.SeriesId == seriesId select season; + + return query.ToArray(); + } + + public async Task CreateSeason(int seriesId, int seasonNumber) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + SeasonModel season = new SeasonModel(seriesId, seasonNumber); + db.Seasons.Add(season); + + await db.SaveChangesAsync(); + + return season; + } +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/SeriesServiceImpl.cs b/server/AniStream.Services/Services/SeriesServiceImpl.cs new file mode 100644 index 0000000..0ae4d3e --- /dev/null +++ b/server/AniStream.Services/Services/SeriesServiceImpl.cs @@ -0,0 +1,156 @@ +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Utils; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Services; + +public sealed class SeriesServiceImpl : ISeriesService +{ + private readonly DbContextFactory _dbFactory; + private readonly ICredentialsService _credentialsService; + + public SeriesServiceImpl(DbContextFactory dbFactory, ICredentialsService credentialsService) + { + _dbFactory = dbFactory; + _credentialsService = credentialsService; + } + + public async Task CreateSeries(string guid, string title, string description, string? previewImage) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + SeriesModel series = new SeriesModel(guid, title, description, previewImage); + + db.Series.Add(series); + await db.SaveChangesAsync(); + + return series; + } + + public async Task GetSeries(int seriesId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from series in db.Series where series.SeriesId == seriesId select series; + return query.FirstOrDefault(); + } + + public async Task GetSeries(string guid) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from series in db.Series where series.Guid == guid select series; + return query.FirstOrDefault(); + } + + public async Task GetSeriesByIds(int[] seriesIds) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from series in db.Series where seriesIds.Contains(series.SeriesId) select series; + return query.ToArray(); + } + + public async Task GetSeriesChunk( + int offset, + int limit, + string? searchText, + int[]? genreIds + ) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = db.Series.AsQueryable(); + + if (searchText is not null) + { + query = query.Where(s => s.Title.Contains(searchText)); + } + + if (genreIds is not null) + { + query = from s in query where db.GenresToSeries.Any(gs => gs.SeriesId == s.SeriesId && genreIds.Contains(gs.GenreId)) select s; + } + + return query + .OrderBy(s => s.Title) + .Skip(offset) + .Take(limit) + .ToArray(); + } + + public async Task GetStartedSeries() + { + string tenantId = await _credentialsService.GetCurrentUuid(); + + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = db.Series + .Join( + db.Seasons.Where(se => se.SeasonNumber > 0), + s => s.SeriesId, + se => se.SeriesId, + (s, se) => new + { + s, + se + }) + .Join( + db.Episodes, + x => x.se.SeasonId, + e => e.SeasonId, + (x, e) => new + { + x.s, + x.se, + e + }) + .GroupJoin( + db.WatchTimes.Where(wt => wt.TenantId == tenantId), + x => x.e.EpisodeId, + wt => wt.EpisodeId, + (x, wt) => new + { + x.s, + x.e, + wt + }) + .SelectMany( + x => x.wt.DefaultIfEmpty(), + (x, wt) => new + { + x.s, + x.e, + wt + }) + .GroupBy(x => x.s) + .Where(g => + g.Max(x => x.wt != null + ? x.wt.PercentageWatched + : 0 + ) > + 0 && + g.Min(x => x.wt != null + ? x.wt.PercentageWatched + : 0 + ) <= + 80) + .Select(g => g.Key); + + return query.ToArray(); + } + + public async Task RequiresSync(int seriesId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from series in db.Series + join season in db.Seasons on series.SeriesId equals season.SeriesId + where series.SeriesId == seriesId + select season.SeasonId; + + return !await query.AnyAsync(); + } +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/SeriesSyncServiceImpl.cs b/server/AniStream.Services/Services/SeriesSyncServiceImpl.cs new file mode 100644 index 0000000..7a39360 --- /dev/null +++ b/server/AniStream.Services/Services/SeriesSyncServiceImpl.cs @@ -0,0 +1,153 @@ +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Utils; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Services; + +public sealed class SeriesSyncServiceImpl : ISeriesSyncService +{ + private readonly DbContextFactory _dbFactory; + private readonly ISeriesService _seriesService; + + public SeriesSyncServiceImpl(DbContextFactory dbFactory, ISeriesService seriesService) + { + _dbFactory = dbFactory; + _seriesService = seriesService; + } + + public async Task GetSyncJob(int syncSeriesJobId) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from job in db.SyncSeriesJobs where job.SyncSeriesJobId == syncSeriesJobId select job; + + return await query.FirstOrDefaultAsync(); + } + + public async Task GetSyncJobBySeries(int seriesId) + { + SeriesModel? series = await _seriesService.GetSeries(seriesId); + if (series is null) + { + throw new ArgumentException($"Series with ID '{seriesId}' not found", nameof(seriesId)); + } + + return await GetSyncJobBySeries(series); + } + + public async Task GetSyncJobBySeries(SeriesModel series) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from job in db.SyncSeriesJobs + where job.SeriesId == series.SeriesId + orderby job.SyncSeriesJobId descending + select job; + + return await query.FirstOrDefaultAsync(); + } + + public async Task RequestSync(int seriesId) + { + SeriesModel? series = await _seriesService.GetSeries(seriesId); + if (series is null) + { + throw new ArgumentException($"Series with ID '{seriesId}' not found", nameof(seriesId)); + } + + await RequestSync(series); + } + + public async Task RequestSync(SeriesModel series) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + SyncSeriesJobModel job = new SyncSeriesJobModel(series.SeriesId, SyncJobStatus.Queued, DateTime.UtcNow, null, null); + + db.SyncSeriesJobs.Add(job); + await db.SaveChangesAsync(); + } + + public async Task IsSyncing(int seriesId) + { + SeriesModel? series = await _seriesService.GetSeries(seriesId); + if (series is null) + { + throw new ArgumentException($"Series with ID '{seriesId}' not found", nameof(seriesId)); + } + + return await IsSyncing(series); + } + + public async Task IsSyncing(SeriesModel series) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from job in db.SyncSeriesJobs + where job.SeriesId == series.SeriesId && + job.Status != SyncJobStatus.Completed && + job.Status != SyncJobStatus.Failed + select job; + + return await query.AnyAsync(); + } + + public async Task GetSyncJobs(SyncJobStatus status) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from job in db.SyncSeriesJobs + where job.Status == status + select job; + + return await query.ToArrayAsync(); + } + + public async Task UpdateSyncJob( + int syncSeriesJobId, + SyncJobStatus? status = null, + DateTime? finished = null, + string? error = null + ) + { + SyncSeriesJobModel? job = await GetSyncJob(syncSeriesJobId); + if (job is null) + { + throw new ArgumentException($"SyncJob with ID '{syncSeriesJobId}' not found", nameof(syncSeriesJobId)); + } + + return await UpdateSyncJob(job, status, finished, error); + } + + public async Task UpdateSyncJob( + SyncSeriesJobModel syncJob, + SyncJobStatus? status = null, + DateTime? finished = null, + string? error = null + ) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + if (status is not null) + { + syncJob.Status = (SyncJobStatus)status; + } + + if (finished is not null) + { + syncJob.Completed = (DateTime)finished; + } + + if (error is not null) + { + syncJob.Error = error; + } + + db.SyncSeriesJobs.Update(syncJob); + await db.SaveChangesAsync(); + + return syncJob; + } +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/UserServiceImpl.cs b/server/AniStream.Services/Services/UserServiceImpl.cs new file mode 100644 index 0000000..79188db --- /dev/null +++ b/server/AniStream.Services/Services/UserServiceImpl.cs @@ -0,0 +1,168 @@ +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Utils; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Services; + +public class UserServiceImpl : IUserService +{ + private readonly DbContextFactory _dbFactory; + + public UserServiceImpl(DbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + + public async Task CreateProfile( + string uuid, + string name, + string password, + string passwordSalt, + string backgroundColor, + string eye, + string mouth, + string theme, + string lang, + bool tosAccepted, + bool syncCatalog + ) + { + await using ProfileDbContext db = await _dbFactory.GetContext(); + + ProfileModel profile = new ProfileModel(uuid, name, password, passwordSalt, backgroundColor, eye, mouth, theme, lang, tosAccepted, syncCatalog); + + db.Profiles.Add(profile); + await db.SaveChangesAsync(); + + return profile; + } + + public Task GetActiveProfile() + { + throw new NotImplementedException(); + } + + public async Task GetProfiles() + { + await using ProfileDbContext db = await _dbFactory.GetContext(); + + return db.Profiles.ToArray(); + } + + public async Task GetProfileByUsername(string username) + { + await using ProfileDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from profile in db.Profiles where profile.Name == username select profile; + return await query.FirstOrDefaultAsync(); + } + + public async Task GetProfile(string uuid) + { + await using ProfileDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from profile in db.Profiles where profile.Uuid == uuid select profile; + return await query.FirstOrDefaultAsync(); + } + + public async Task GetProfile(int profileId) + { + await using ProfileDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from profile in db.Profiles where profile.ProfileId == profileId select profile; + return await query.FirstOrDefaultAsync(); + } + + public async Task UpdateProfile( + int profileId, + string? name = null, + string? backgroundColor = null, + string? eye = null, + string? mouth = null, + string? theme = null, + string? lang = null, + bool? tosAccepted = null, + bool? syncCatalog = null + ) + { + ProfileModel? profile = await GetProfile(profileId); + if (profile is null) + { + throw new ArgumentException($"Profile with ID '{profileId}' not found", nameof(profileId)); + } + + return await UpdateProfile( + profile, + name, + backgroundColor, + eye, + mouth, + theme, + lang, + tosAccepted, + syncCatalog + ); + } + + public async Task UpdateProfile( + ProfileModel profile, + string? name = null, + string? backgroundColor = null, + string? eye = null, + string? mouth = null, + string? theme = null, + string? lang = null, + bool? tosAccepted = null, + bool? syncCatalog = null + ) + { + await using ProfileDbContext db = await _dbFactory.GetContext(); + + if (name is not null) + { + profile.Name = name; + } + + if (backgroundColor is not null) + { + profile.BackgroundColor = backgroundColor; + } + + if (eye is not null) + { + profile.Eye = eye; + } + + if (mouth is not null) + { + profile.Mouth = mouth; + } + + if (theme is not null) + { + profile.Theme = theme; + } + + if (lang is not null) + { + profile.Lang = lang; + } + + if (tosAccepted is not null) + { + profile.TosAccepted = (bool)tosAccepted; + } + + if (syncCatalog is not null) + { + profile.SyncCatalog = (bool)syncCatalog; + } + + db.Profiles.Update(profile); + await db.SaveChangesAsync(); + + return profile; + } +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/WatchListServiceImpl.cs b/server/AniStream.Services/Services/WatchListServiceImpl.cs new file mode 100644 index 0000000..e41fca5 --- /dev/null +++ b/server/AniStream.Services/Services/WatchListServiceImpl.cs @@ -0,0 +1,71 @@ +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Utils; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Services; + +public sealed class WatchListServiceImpl : IWatchListService +{ + private readonly DbContextFactory _dbFactory; + private readonly ICredentialsService _credentialsService; + + public WatchListServiceImpl(DbContextFactory dbFactory, ICredentialsService credentialsService) + { + _dbFactory = dbFactory; + _credentialsService = credentialsService; + } + + public async Task GetSeriesIds() + { + string tenantId = await _credentialsService.GetCurrentUuid(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from watchlist in db.WatchLists + where watchlist.TenantId == tenantId + select watchlist.SeriesId; + return await query.ToArrayAsync(); + } + + public async Task IsSeriesOnList(int seriesId) + { + string tenantId = await _credentialsService.GetCurrentUuid(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from watchlist in db.WatchLists + where watchlist.TenantId == tenantId && watchlist.SeriesId == seriesId + select watchlist.SeriesId; + return await query.AnyAsync(); + } + + public async Task AddSeries(int seriesId) + { + string tenantId = await _credentialsService.GetCurrentUuid(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + WatchListModel watchlist = new WatchListModel(seriesId, tenantId); + + db.WatchLists.Add(watchlist); + await db.SaveChangesAsync(); + } + + public async Task RemoveSeries(int seriesId) + { + string tenantId = await _credentialsService.GetCurrentUuid(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from w in db.WatchLists + where w.TenantId == tenantId && w.SeriesId == seriesId + select w; + + WatchListModel? watchlist = query.FirstOrDefault(); + if (watchlist is null) + { + return; + } + + db.WatchLists.Remove(watchlist); + await db.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/server/AniStream.Services/Services/WatchTimeServiceImpl.cs b/server/AniStream.Services/Services/WatchTimeServiceImpl.cs new file mode 100644 index 0000000..9301e5e --- /dev/null +++ b/server/AniStream.Services/Services/WatchTimeServiceImpl.cs @@ -0,0 +1,205 @@ +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Utils; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Services; + +public sealed class WatchTimeServiceImpl : IWatchTimeService +{ + private readonly DbContextFactory _dbFactory; + private readonly ICredentialsService _credentialsService; + + public WatchTimeServiceImpl(DbContextFactory dbFactory, ICredentialsService credentialsService) + { + _dbFactory = dbFactory; + _credentialsService = credentialsService; + } + + public async Task GetWatchTime(int episodeId) + { + string tenantId = await _credentialsService.GetCurrentUuid(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from watchtime in db.WatchTimes + where watchtime.TenantId == tenantId && watchtime.EpisodeId == episodeId + select watchtime; + + return await query.FirstOrDefaultAsync(); + } + + public async Task GetWatchTimesOfSeries(int seriesId) + { + string tenantId = await _credentialsService.GetCurrentUuid(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from watchtime in db.WatchTimes + join episode in db.Episodes on watchtime.EpisodeId equals episode.EpisodeId + join season in db.Seasons on episode.SeasonId equals season.SeasonId + where watchtime.TenantId == tenantId && season.SeriesId == seriesId + select watchtime; + + return await query.ToArrayAsync(); + } + + public async Task GetWatchTimesOfSeason(int seasonId) + { + string tenantId = await _credentialsService.GetCurrentUuid(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from watchtime in db.WatchTimes + join episode in db.Episodes on watchtime.EpisodeId equals episode.EpisodeId + where watchtime.TenantId == tenantId && episode.SeasonId == seasonId + select watchtime; + + return await query.ToArrayAsync(); + } + + public async Task GetWatchTimeOfEpisode(int episodeId) + { + string tenantId = await _credentialsService.GetCurrentUuid(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable query = from watchtime in db.WatchTimes + where watchtime.TenantId == tenantId && watchtime.EpisodeId == episodeId + select watchtime; + + return await query.FirstOrDefaultAsync(); + } + + public async Task GetTotalWatchProgression(int seriesId) + { + string tenantId = await _credentialsService.GetCurrentUuid(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + IQueryable> query = from episode in db.Episodes + join watchtime in db.WatchTimes on episode.EpisodeId equals watchtime.EpisodeId into watchtimeGroup + from watchtime in watchtimeGroup.DefaultIfEmpty() + join season in db.Seasons on episode.SeasonId equals season.SeasonId into seasonGroup + from season in seasonGroup.DefaultIfEmpty() + where season.SeriesId == seriesId && season.SeasonNumber > 0 + group new + { + episode, + watchtime + } by 1 + into g + select new Tuple( + g.Select(x => x.episode.EpisodeId).Distinct().Count(), + g.Sum(x => x.watchtime != null && x.watchtime.TenantId == tenantId && x.watchtime.PercentageWatched > 80 + ? 1 + : 0) + ); + + (int totalEpisodes, int completedEpisodes) = await query.FirstOrDefaultAsync() ?? new Tuple(0, 0); + + if (totalEpisodes == 0) + { + return 0; + } + + return (int)Math.Round(100.0 * completedEpisodes / totalEpisodes); + } + + public async Task CreateWatchTime(int episodeId, int percentageWatched, double stoppedTime) + { + string tenantId = await _credentialsService.GetCurrentUuid(); + await using MetadataDbContext db = await _dbFactory.GetContext(); + + WatchTimeModel watchtime = new WatchTimeModel(episodeId, percentageWatched, stoppedTime, tenantId); + + db.WatchTimes.Add(watchtime); + await db.SaveChangesAsync(); + + return watchtime; + } + + public async Task UpdateWatchTime(int watchtimeId, int? percentageWatched = null, double? stoppedTime = null) + { + WatchTimeModel? watchtime = await GetWatchTime(watchtimeId); + if (watchtime is null) + { + throw new ArgumentException($"WatchTime with ID '{watchtimeId}' dont exist", nameof(watchtimeId)); + } + + return await UpdateWatchTime(watchtime, percentageWatched, stoppedTime); + } + + public async Task UpdateWatchTime(WatchTimeModel watchtime, int? percentageWatched = null, double? stoppedTime = null) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + if (percentageWatched is not null) + { + watchtime.PercentageWatched = (int)percentageWatched; + } + + if (stoppedTime is not null) + { + watchtime.StoppedTime = (double)stoppedTime; + } + + db.Update(watchtime); + await db.SaveChangesAsync(); + + return watchtime; + } + + public async Task UpdateWatchTimes(WatchTimeModel[] watchTimes, int? percentageWatched = null, double? stoppedTime = null) + { + await using MetadataDbContext db = await _dbFactory.GetContext(); + + foreach (WatchTimeModel watchtime in watchTimes) + { + if (percentageWatched is not null) + { + watchtime.PercentageWatched = (int)percentageWatched; + } + + if (stoppedTime is not null) + { + watchtime.StoppedTime = (double)stoppedTime; + } + + db.Update(watchtime); + } + + await db.SaveChangesAsync(); + } + + public async Task UpdateWatchTimeOfEpisode(int episodeId, int? percentageWatched = null, double? stoppedTime = null) + { + WatchTimeModel? watchtime = await GetWatchTimeOfEpisode(episodeId); + if (watchtime is null) + { + throw new ArgumentException($"WatchTime of Episode with ID '{episodeId}' dont exist", nameof(episodeId)); + } + + await UpdateWatchTime(watchtime, percentageWatched, stoppedTime); + } + + public async Task UpdateWatchTimeOfSeason(int seasonId, int? percentageWatched = null, double? stoppedTime = null) + { + WatchTimeModel[] watchTimes = await GetWatchTimesOfSeason(seasonId); + + if (watchTimes.Length == 0) + { + return; + } + + await UpdateWatchTimes(watchTimes, percentageWatched, stoppedTime); + } + + public async Task UpdateWatchTimeOfSeries(int seriesId, int? percentageWatched = null, double? stoppedTime = null) + { + WatchTimeModel[] watchTimes = await GetWatchTimesOfSeries(seriesId); + + if (watchTimes.Length == 0) + { + return; + } + + await UpdateWatchTimes(watchTimes, percentageWatched, stoppedTime); + } +} \ No newline at end of file diff --git a/server/AniStream.Shared/AniStream.Shared.csproj b/server/AniStream.Shared/AniStream.Shared.csproj new file mode 100644 index 0000000..71b3023 --- /dev/null +++ b/server/AniStream.Shared/AniStream.Shared.csproj @@ -0,0 +1,10 @@ + + + + net9.0 + enable + enable + AniStream.Shared + + + diff --git a/server/AniStream.Shared/AppConfig.cs b/server/AniStream.Shared/AppConfig.cs new file mode 100644 index 0000000..4dce11e --- /dev/null +++ b/server/AniStream.Shared/AppConfig.cs @@ -0,0 +1,40 @@ +namespace AniStream.Shared; + +public sealed class AppConfig +{ + public static AppConfig CurrentConfig { get; private set; } + + public readonly string MigrationPath; + public readonly string DatabaseDriver; + public readonly string DatabaseMetadataConnectionString; + public readonly string DatabaseProfileConnectionString; + public readonly string AssetsPath; + public readonly string SidecarPath; + + private AppConfig() + { + MigrationPath = GetEnvironmentVariable("DATABASE_MIGRATION_PATH"); + DatabaseDriver = GetEnvironmentVariable("DATABASE_DRIVER"); + DatabaseMetadataConnectionString = GetEnvironmentVariable("DATABASE_METADATA_CONNECTION_STRING"); + DatabaseProfileConnectionString = GetEnvironmentVariable("DATABASE_PROFILE_CONNECTION_STRING"); + AssetsPath = GetEnvironmentVariable("ASSETS_PATH"); + SidecarPath = GetEnvironmentVariable("SIDECAR_PATH"); + } + + private string GetEnvironmentVariable(string name) + { + return Environment.GetEnvironmentVariable(name) ?? throw new Exception($"Missing environment variable: '{name}'"); + } + + private string GetEnvironmentVariableOrDefault(string name, string defaultValue) + { + return Environment.GetEnvironmentVariable(name) ?? defaultValue; + } + + public static void Initialize() + { + CurrentConfig = new AppConfig(); + Console.WriteLine($"Migration path: '{CurrentConfig.MigrationPath}'"); + Console.WriteLine($"Assets path: '{CurrentConfig.AssetsPath}'"); + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/AniStream.Tests.csproj b/server/AniStream.Tests/AniStream.Tests.csproj new file mode 100644 index 0000000..f6f8cb0 --- /dev/null +++ b/server/AniStream.Tests/AniStream.Tests.csproj @@ -0,0 +1,33 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + migration/%(RecursiveDir)/%(Filename)%(Extension) + PreserveNewest + + + + + + + + diff --git a/server/AniStream.Tests/EpisodeServiceTests.cs b/server/AniStream.Tests/EpisodeServiceTests.cs new file mode 100644 index 0000000..a649d83 --- /dev/null +++ b/server/AniStream.Tests/EpisodeServiceTests.cs @@ -0,0 +1,90 @@ +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Tests.Utils; + +namespace AniStream.Tests; + +public sealed class EpisodeServiceTests : TestBase +{ + private readonly ISeriesService _seriesService; + private readonly ISeasonService _seasonService; + private readonly IEpisodeService _episodeService; + + public EpisodeServiceTests() + { + _seriesService = GetService(); + _seasonService = GetService(); + _episodeService = GetService(); + } + + [Fact] + public async Task CreateEpisode() + { + SeriesModel series = await _seriesService.CreateSeries("a-series", "A Seris", "Desc", null); + SeasonModel season = await _seasonService.CreateSeason(series.SeriesId, 1); + + EpisodeModel episode = await _episodeService.CreateEpisode(season.SeasonId, 1, "DE", "EN", "Desc"); + + Assert.Equal(1, episode.EpisodeId); + Assert.Equal(season.SeasonId, episode.SeasonId); + Assert.Equal(1, episode.EpisodeNumber); + } + + [Fact] + public async Task GetEpisode() + { + SeriesModel series = await _seriesService.CreateSeries("a-series", "A Seris", "Desc", null); + SeasonModel season = await _seasonService.CreateSeason(series.SeriesId, 1); + EpisodeModel episode = await _episodeService.CreateEpisode(season.SeasonId, 1, "DE", "EN", "Desc"); + + EpisodeModel? loaded = await _episodeService.GetEpisode(episode.EpisodeId); + + Assert.NotNull(loaded); + Assert.Equal(episode.EpisodeId, loaded.EpisodeId); + } + + [Fact] + public async Task GetEpisodes() + { + SeriesModel series = await _seriesService.CreateSeries("a-series", "A Seris", "Desc", null); + SeasonModel season = await _seasonService.CreateSeason(series.SeriesId, 1); + await _episodeService.CreateEpisode(season.SeasonId, 1, "DE", "EN", "Desc"); + + EpisodeModel[] episodes = await _episodeService.GetEpisodes(season.SeasonId); + + Assert.Single(episodes); + Assert.Equal(1, episodes[0].EpisodeNumber); + } + + [Fact] + public async Task UpdateEpisodeById() + { + SeriesModel series = await _seriesService.CreateSeries("a-series", "A Seris", "Desc", null); + SeasonModel season = await _seasonService.CreateSeason(series.SeriesId, 1); + EpisodeModel episode = await _episodeService.CreateEpisode(season.SeasonId, 1, "DE", "EN", "Desc"); + + EpisodeModel updated = await _episodeService.UpdateEpisode(episode.EpisodeId, episodeNumber: 2, englishTitle: "EN2"); + + Assert.Equal(2, updated.EpisodeNumber); + Assert.Equal("EN2", updated.EnglishTitle); + } + + [Fact] + public async Task UpdateEpisodeByModel() + { + SeriesModel series = await _seriesService.CreateSeries("a-series", "A Seris", "Desc", null); + SeasonModel season = await _seasonService.CreateSeason(series.SeriesId, 1); + EpisodeModel episode = await _episodeService.CreateEpisode(season.SeasonId, 1, "DE", "EN", "Desc"); + + EpisodeModel updated = await _episodeService.UpdateEpisode(episode, episodeNumber: 2, englishTitle: "EN2"); + + Assert.Equal(2, updated.EpisodeNumber); + Assert.Equal("EN2", updated.EnglishTitle); + } + + [Fact] + public async Task UpdateEpisodeByIdThrowsForUnknownEpisode() + { + await Assert.ThrowsAsync(() => _episodeService.UpdateEpisode(999, englishTitle: "X")); + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/GenreServiceTests.cs b/server/AniStream.Tests/GenreServiceTests.cs new file mode 100644 index 0000000..8649260 --- /dev/null +++ b/server/AniStream.Tests/GenreServiceTests.cs @@ -0,0 +1,110 @@ +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Tests.Utils; + +namespace AniStream.Tests; + +public sealed class GenreServiceTests : TestBase +{ + private readonly IGenreService _genreService; + private readonly ISeriesService _seriesService; + + public GenreServiceTests() + { + _genreService = GetService(); + _seriesService = GetService(); + } + + [Fact] + public async Task CreateGenre() + { + GenreModel genre = await _genreService.CreateGenre("action"); + + Assert.Equal(1, genre.GenreId); + Assert.Equal("action", genre.Key); + } + + [Fact] + public async Task CreateGenreToSeries() + { + SeriesModel series = await _seriesService.CreateSeries("s-1", "Series", "Description", null); + GenreModel genre = await _genreService.CreateGenre("main"); + + await _genreService.CreateGenreToSeries(genre.GenreId, series.SeriesId, true); + + GenreModel? mainGenre = await _genreService.GetMainGenreOfSeries(series.SeriesId); + + Assert.NotNull(mainGenre); + Assert.Equal(genre.GenreId, mainGenre.GenreId); + } + + [Fact] + public async Task GetGenres() + { + GenreModel genre = await _genreService.CreateGenre("action"); + + GenreModel[] genres = await _genreService.GetGenres(); + + Assert.Single(genres); + Assert.Equal(genre.GenreId, genres[0].GenreId); + } + + [Fact] + public async Task GetGenreById() + { + GenreModel genre = await _genreService.CreateGenre("action"); + + GenreModel? byId = await _genreService.GetGenre(genre.GenreId); + + Assert.NotNull(byId); + Assert.Equal(genre.GenreId, byId.GenreId); + } + + [Fact] + public async Task GetGenreByKey() + { + GenreModel genre = await _genreService.CreateGenre("action"); + + GenreModel? byKey = await _genreService.GetGenre(genre.Key); + + Assert.NotNull(byKey); + Assert.Equal(genre.Key, byKey.Key); + } + + [Fact] + public async Task GetMainGenreOfSeries() + { + await InsertSeriesWithGenres(); + + GenreModel? mainGenre = await _genreService.GetMainGenreOfSeries(1); + + Assert.NotNull(mainGenre); + Assert.Equal(1, mainGenre.GenreId); + } + + [Fact] + public async Task GetNonMainGenresOfSeries() + { + await InsertSeriesWithGenres(); + + GenreModel[] nonMainGenres = await _genreService.GetNonMainGenresOfSeries(1); + + Assert.Single(nonMainGenres); + Assert.Equal(2, nonMainGenres[0].GenreId); + } + + private async Task InsertSeriesWithGenres() + { + SeriesModel series = await _seriesService.CreateSeries("s-1", "Series", "Description", null); + GenreModel main = await _genreService.CreateGenre("main"); + GenreModel side = await _genreService.CreateGenre("side"); + + await _genreService.CreateGenreToSeries(main.GenreId, series.SeriesId, true); + await _genreService.CreateGenreToSeries(side.GenreId, series.SeriesId, false); + + Assert.Equal(1, series.SeriesId); + + Assert.Equal(1, main.GenreId); + Assert.Equal(2, side.GenreId); + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/ListServiceTests.cs b/server/AniStream.Tests/ListServiceTests.cs new file mode 100644 index 0000000..f80488d --- /dev/null +++ b/server/AniStream.Tests/ListServiceTests.cs @@ -0,0 +1,247 @@ +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Tests.Utils; + +namespace AniStream.Tests; + +public sealed class ListServiceTests : TestBase +{ + private readonly IListService _listService; + private readonly ISeriesService _seriesService; + + public ListServiceTests() + { + _listService = GetService(); + _seriesService = GetService(); + } + + [Fact] + public async Task GetLists() + { + await _listService.CreateList("watchlist"); + await _listService.CreateList("favorites"); + + ListModel[] lists = await _listService.GetLists(); + + Assert.Equal(2, lists.Length); + Assert.Contains(lists, l => l.Name == "watchlist"); + Assert.Contains(lists, l => l.Name == "favorites"); + } + + [Fact] + public async Task GetList() + { + ListModel created = await _listService.CreateList("watchlist"); + + ListModel? list = await _listService.GetList(created.ListId); + + Assert.NotNull(list); + Assert.Equal(created.ListId, list.ListId); + Assert.Equal("watchlist", list.Name); + } + + [Fact] + public async Task GetListsOfSeries() + { + await CreateSeries(); + ListModel list1 = await _listService.CreateList("list1"); + ListModel list2 = await _listService.CreateList("list2"); + + await _listService.AddSeriesToList(list1.ListId, 1); + await _listService.AddSeriesToList(list2.ListId, 1); + + ListModel[] lists = await _listService.GetListsOfSeries(1); + + Assert.Equal(2, lists.Length); + Assert.Contains(lists, l => l.Name == "list1"); + Assert.Contains(lists, l => l.Name == "list2"); + } + + [Fact] + public async Task CreateList() + { + ListModel list = await _listService.CreateList("watchlist"); + + Assert.Equal(1, list.ListId); + Assert.Equal("watchlist", list.Name); + Assert.Equal(MockCredentialService.MOCK_UUID, list.TenantId); + } + + [Fact] + public async Task UpdateListById() + { + ListModel created = await _listService.CreateList("watchlist"); + + ListModel updated = await _listService.UpdateList(created.ListId, "updated"); + + Assert.Equal("updated", updated.Name); + + ListModel? fetched = await _listService.GetList(created.ListId); + Assert.Equal("updated", fetched!.Name); + } + + [Fact] + public async Task UpdateListByModel() + { + ListModel created = await _listService.CreateList("watchlist"); + + ListModel updated = await _listService.UpdateList(created, "updated"); + + Assert.Equal("updated", updated.Name); + } + + [Fact] + public async Task DeleteListById() + { + ListModel created = await _listService.CreateList("watchlist"); + + await _listService.DeleteList(created.ListId); + + ListModel? list = await _listService.GetList(created.ListId); + Assert.Null(list); + } + + [Fact] + public async Task DeleteListByModel() + { + ListModel created = await _listService.CreateList("watchlist"); + + await _listService.DeleteList(created); + + ListModel? list = await _listService.GetList(created.ListId); + Assert.Null(list); + } + + [Fact] + public async Task GetSeriesById() + { + ListModel list = await _listService.CreateList("watchlist"); + await CreateSeries(); + await _listService.AddSeriesToList(list.ListId, 1); + + SeriesModel[] seriesInList = await _listService.GetSeries(list.ListId); + + Assert.Single(seriesInList); + Assert.Equal(1, seriesInList[0].SeriesId); + } + + [Fact] + public async Task GetSeriesByModel() + { + ListModel list = await _listService.CreateList("watchlist"); + await CreateSeries(); + await _listService.AddSeriesToList(list, 1); + + SeriesModel[] seriesInList = await _listService.GetSeries(list); + + Assert.Single(seriesInList); + Assert.Equal(1, seriesInList[0].SeriesId); + } + + [Fact] + public async Task AddSeriesToListById() + { + ListModel list = await _listService.CreateList("watchlist"); + await CreateSeries(); + + await _listService.AddSeriesToList(list.ListId, 1); + + SeriesModel[] seriesInList = await _listService.GetSeries(list.ListId); + Assert.Single(seriesInList); + } + + [Fact] + public async Task AddSeriesToListByModel() + { + ListModel list = await _listService.CreateList("watchlist"); + await CreateSeries(); + + await _listService.AddSeriesToList(list, 1); + + SeriesModel[] seriesInList = await _listService.GetSeries(list.ListId); + Assert.Single(seriesInList); + } + + [Fact] + public async Task RemoveSeriesFromListById() + { + ListModel list = await _listService.CreateList("watchlist"); + await CreateSeries(); + await _listService.AddSeriesToList(list.ListId, 1); + + await _listService.RemoveSeriesFromList(list.ListId, 1); + + SeriesModel[] seriesInList = await _listService.GetSeries(list.ListId); + Assert.Empty(seriesInList); + } + + [Fact] + public async Task RemoveSeriesFromListByModel() + { + ListModel list = await _listService.CreateList("watchlist"); + await CreateSeries(); + await _listService.AddSeriesToList(list.ListId, 1); + + await _listService.RemoveSeriesFromList(list, 1); + + SeriesModel[] seriesInList = await _listService.GetSeries(list.ListId); + Assert.Empty(seriesInList); + } + + [Fact] + public async Task GetPreviewImagesById() + { + ListModel list = await _listService.CreateList("watchlist"); + await CreateSeries(); + await _listService.AddSeriesToList(list.ListId, 1); + + string[] images = await _listService.GetPreviewImages(list.ListId); + + Assert.Single(images); + Assert.Equal("ABCDEFG", images[0]); + } + + [Fact] + public async Task GetPreviewImagesByModel() + { + ListModel list = await _listService.CreateList("watchlist"); + await CreateSeries(); + await _listService.AddSeriesToList(list.ListId, 1); + + string[] images = await _listService.GetPreviewImages(list); + + Assert.Single(images); + Assert.Equal("ABCDEFG", images[0]); + } + + [Fact] + public async Task GetPreviewImagesMax4() + { + ListModel list = await _listService.CreateList("watchlist"); + + SeriesModel series1 = await _seriesService.CreateSeries("series-1", "Series 1", "", "a"); + SeriesModel series2 = await _seriesService.CreateSeries("series-2", "Series 2", "", "b"); + SeriesModel series3 = await _seriesService.CreateSeries("series-3", "Series 3", "", "c"); + SeriesModel series4 = await _seriesService.CreateSeries("series-4", "Series 4", "", "d"); + SeriesModel series5 = await _seriesService.CreateSeries("series-5", "Series 5", "", "e"); + + await _listService.AddSeriesToList(list, series1.SeriesId); + await _listService.AddSeriesToList(list, series2.SeriesId); + await _listService.AddSeriesToList(list, series3.SeriesId); + await _listService.AddSeriesToList(list, series4.SeriesId); + await _listService.AddSeriesToList(list, series5.SeriesId); + + string[] previewImages = await _listService.GetPreviewImages(list); + Assert.Equal(4, previewImages.Length); + Assert.Contains(series1.PreviewImage, previewImages); + Assert.Contains(series2.PreviewImage, previewImages); + Assert.Contains(series3.PreviewImage, previewImages); + Assert.Contains(series4.PreviewImage, previewImages); + } + + private async Task CreateSeries() + { + SeriesModel series = await _seriesService.CreateSeries(Guid.NewGuid().ToString(), "Title", "Desc", "ABCDEFG"); + Assert.Equal(1, series.SeriesId); + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/ProviderServiceTests.cs b/server/AniStream.Tests/ProviderServiceTests.cs new file mode 100644 index 0000000..caf560f --- /dev/null +++ b/server/AniStream.Tests/ProviderServiceTests.cs @@ -0,0 +1,34 @@ +using AniStream.Contracts; +using AniStream.Tests.Utils; + +namespace AniStream.Tests; + +public sealed class ProviderServiceTests : TestBase +{ + private readonly IProviderService _providerService; + + public ProviderServiceTests() : base(false) + { + _providerService = GetService(); + } + + [Fact] + public void RejectsGettingNotSetProvider() + { + Assert.Throws(() => _providerService.GetActiveProvider()); + } + + [Fact] + public void SetActiveProvider() + { + _providerService.SetActiveProvider("aniworld"); + + Assert.Equal("aniworld", _providerService.GetActiveProvider()); + } + + [Fact] + public void RejectsSettingInvalidProvider() + { + Assert.Throws(() => _providerService.SetActiveProvider("unknown")); + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/SeasonServiceTests.cs b/server/AniStream.Tests/SeasonServiceTests.cs new file mode 100644 index 0000000..9c48383 --- /dev/null +++ b/server/AniStream.Tests/SeasonServiceTests.cs @@ -0,0 +1,56 @@ +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Tests.Utils; + +namespace AniStream.Tests; + +public sealed class SeasonServiceTests : TestBase +{ + private readonly ISeriesService _seriesService; + private readonly ISeasonService _seasonService; + + public SeasonServiceTests() + { + _seriesService = GetService(); + _seasonService = GetService(); + } + + [Fact] + public async Task CreateSeason() + { + SeriesModel series = await _seriesService.CreateSeries("g-ep", "Episodes", "Desc", null); + Assert.Equal(1, series.SeriesId); + + SeasonModel season = await _seasonService.CreateSeason(series.SeriesId, 1); + Assert.Equal(1, season.SeasonId); + Assert.Equal(series.SeriesId, season.SeriesId); + Assert.Equal(1, season.SeasonNumber); + } + + + [Fact] + public async Task GetSeasons() + { + SeriesModel series = await _seriesService.CreateSeries("g-ep", "Episodes", "Desc", null); + await _seasonService.CreateSeason(series.SeriesId, 1); + + SeasonModel[] bySeriesId = await _seasonService.GetSeasons(series.SeriesId); + + Assert.Single(bySeriesId); + Assert.Equal(1, bySeriesId[0].SeasonId); + Assert.Equal(1, bySeriesId[0].SeasonNumber); + } + + [Fact] + public async Task GetSeason() + { + SeriesModel series = await _seriesService.CreateSeries("g-ep", "Episodes", "Desc", null); + SeasonModel season = await _seasonService.CreateSeason(series.SeriesId, 1); + + SeasonModel? byId = await _seasonService.GetSeason(season.SeasonId); + + Assert.NotNull(byId); + Assert.Equal(season.SeasonId, byId.SeasonId); + Assert.Equal(season.SeasonNumber, byId.SeasonNumber); + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/SeriesServiceTests.cs b/server/AniStream.Tests/SeriesServiceTests.cs new file mode 100644 index 0000000..2fca165 --- /dev/null +++ b/server/AniStream.Tests/SeriesServiceTests.cs @@ -0,0 +1,156 @@ +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Tests.Utils; +using AniStream.Utils; + +namespace AniStream.Tests; + +public sealed class SeriesServiceTests : TestBase +{ + private readonly ISeriesService _seriesService; + private readonly IGenreService _genreService; + private readonly ISeasonService _seasonService; + private readonly IEpisodeService _episodeService; + private readonly DbContextFactory _dbFactory; + + public SeriesServiceTests() + { + _seriesService = GetService(); + _genreService = GetService(); + _seasonService = GetService(); + _episodeService = GetService(); + _dbFactory = GetService>(); + } + + [Fact] + public async Task CreateSeries() + { + SeriesModel series = await _seriesService.CreateSeries("a-series", "A Seris", "Desc", null); + Assert.Equal(1, series.SeriesId); + Assert.Equal("A Seris", series.Title); + Assert.Equal("Desc", series.Description); + Assert.Null(series.PreviewImage); + } + + [Fact] + public async Task GetSeriesById() + { + SeriesModel created = await _seriesService.CreateSeries("g-a", "Alpha Show", "", null); + + SeriesModel? series = await _seriesService.GetSeries(created.SeriesId); + + Assert.NotNull(series); + Assert.Equal(created.SeriesId, series.SeriesId); + } + + [Fact] + public async Task GetSeriesByGuid() + { + SeriesModel created = await _seriesService.CreateSeries("g-a", "Alpha Show", "", null); + + SeriesModel? series = await _seriesService.GetSeries(created.Guid); + + Assert.NotNull(series); + Assert.Equal(created.SeriesId, series.SeriesId); + } + + [Fact] + public async Task GetSeriesByIds() + { + SeriesModel alpha = await _seriesService.CreateSeries("g-a", "Alpha Show", "", null); + await _seriesService.CreateSeries("g-b", "Beta Show", "", null); + SeriesModel gamma = await _seriesService.CreateSeries("g-c", "Gamma", "", null); + + SeriesModel[] series = await _seriesService.GetSeriesByIds([alpha.SeriesId, gamma.SeriesId]); + + Assert.Equal(["Alpha Show", "Gamma"], series.OrderBy(s => s.Title).Select(s => s.Title).ToArray()); + } + + [Fact] + public async Task GetSeriesChunk() + { + SeriesModel alpha = await _seriesService.CreateSeries("g-a", "Alpha Show", "", null); + await _seriesService.CreateSeries("g-b", "Beta Show", "", null); + SeriesModel gamma = await _seriesService.CreateSeries("g-c", "Gamma", "", null); + GenreModel adventure = await _genreService.CreateGenre("adventure"); + await _genreService.CreateGenreToSeries(adventure.GenreId, alpha.SeriesId, true); + await _genreService.CreateGenreToSeries(adventure.GenreId, gamma.SeriesId, false); + + SeriesModel[] filtered = await _seriesService.GetSeriesChunk(0, 10, "a", [adventure.GenreId]); + SeriesModel[] paged = await _seriesService.GetSeriesChunk(1, 1, null, null); + + Assert.Equal(["Alpha Show", "Gamma"], filtered.Select(s => s.Title).ToArray()); + Assert.Single(paged); + Assert.Equal("Beta Show", paged[0].Title); + } + + [Fact] + public async Task GetStartedSeries() + { + // Series A: Started (One episode 50%, one 0%) -> Should be returned + SeriesModel seriesA = await _seriesService.CreateSeries("series-a", "Series A", "", null); + + await using (MetadataDbContext db = await _dbFactory.GetContext()) + { + SeasonModel seasonA = new SeasonModel(seriesA.SeriesId, 1); + db.Seasons.Add(seasonA); + await db.SaveChangesAsync(); + + EpisodeModel epA1 = new EpisodeModel(seasonA.SeasonId, 1, "A1", "A1", ""); + EpisodeModel epA2 = new EpisodeModel(seasonA.SeasonId, 2, "A2", "A2", ""); + db.Episodes.AddRange(epA1, epA2); + await db.SaveChangesAsync(); + + // Series B: Finished (All episodes > 80%) -> Should NOT be returned + SeriesModel seriesB = new SeriesModel("series-b", "Series B", "", null); + db.Series.Add(seriesB); + await db.SaveChangesAsync(); + SeasonModel seasonB = new SeasonModel(seriesB.SeriesId, 1); + db.Seasons.Add(seasonB); + await db.SaveChangesAsync(); + EpisodeModel epB1 = new EpisodeModel(seasonB.SeasonId, 1, "B1", "B1", ""); + EpisodeModel epB2 = new EpisodeModel(seasonB.SeasonId, 2, "B2", "B2", ""); + db.Episodes.AddRange(epB1, epB2); + await db.SaveChangesAsync(); + + // Series C: Not started (All episodes 0%) -> Should NOT be returned + SeriesModel seriesC = new SeriesModel("series-c", "Series C", "", null); + db.Series.Add(seriesC); + await db.SaveChangesAsync(); + SeasonModel seasonC = new SeasonModel(seriesC.SeriesId, 1); + db.Seasons.Add(seasonC); + await db.SaveChangesAsync(); + EpisodeModel epC1 = new EpisodeModel(seasonC.SeasonId, 1, "C1", "C1", ""); + db.Episodes.Add(epC1); + await db.SaveChangesAsync(); + + // Series D: Special case -> Should NOT be returned + SeriesModel seriesD = new SeriesModel("series-d", "Series D", "", null); + db.Series.Add(seriesD); + await db.SaveChangesAsync(); + SeasonModel seasonD = new SeasonModel(seriesD.SeriesId, 0); + db.Seasons.Add(seasonD); + await db.SaveChangesAsync(); + EpisodeModel epD1 = new EpisodeModel(seasonD.SeasonId, 1, "D1", "D1", ""); + db.Episodes.Add(epD1); + await db.SaveChangesAsync(); + + // Seed WatchTimes + db.WatchTimes.AddRange( + new WatchTimeModel(epA1.EpisodeId, 50, 100, MockCredentialService.MOCK_UUID), + new WatchTimeModel(epA2.EpisodeId, 0, 0, MockCredentialService.MOCK_UUID), + new WatchTimeModel(epB1.EpisodeId, 90, 200, MockCredentialService.MOCK_UUID), + new WatchTimeModel(epB2.EpisodeId, 85, 190, MockCredentialService.MOCK_UUID), + new WatchTimeModel(epC1.EpisodeId, 0, 0, MockCredentialService.MOCK_UUID), + new WatchTimeModel(epD1.EpisodeId, 50, 100, MockCredentialService.MOCK_UUID) + ); + await db.SaveChangesAsync(); + } + + SeriesModel[] started = await _seriesService.GetStartedSeries(); + + Assert.Single(started); + Assert.Equal(seriesA.SeriesId, started[0].SeriesId); + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/UserServiceTests.cs b/server/AniStream.Tests/UserServiceTests.cs new file mode 100644 index 0000000..2eee73a --- /dev/null +++ b/server/AniStream.Tests/UserServiceTests.cs @@ -0,0 +1,111 @@ +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Tests.Utils; + +namespace AniStream.Tests; + +public sealed class UserServiceTests : TestBase +{ + private readonly IUserService _userService; + + public UserServiceTests() + { + _userService = GetService(); + } + + [Fact] + public async Task CreateProfile() + { + Guid johnGuid = Guid.NewGuid(); + ProfileModel john = await _userService.CreateProfile(johnGuid.ToString(), "john", "", "","fff", "eye-1", "mouth-1", "dark", "en", true, false); + + Assert.Equal(1, john.ProfileId); + Assert.Equal("john", john.Name); + } + + [Fact] + public async Task GetProfiles() + { + Guid johnGuid = Guid.NewGuid(); + Guid janeGuid = Guid.NewGuid(); + await _userService.CreateProfile(johnGuid.ToString(), "john", "", "", "fff", "eye-1", "mouth-1", "dark", "en", true, false); + await _userService.CreateProfile(janeGuid.ToString(), "jane", "", "", "000", "eye-2", "mouth-2", "light", "de", true, true); + + ProfileModel[] profiles = await _userService.GetProfiles(); + + Assert.Equal(2, profiles.Length); + } + + [Fact] + public async Task GetProfileByName() + { + Guid johnGuid = Guid.NewGuid(); + Guid janeGuid = Guid.NewGuid(); + await _userService.CreateProfile(johnGuid.ToString(), "john", "", "", "fff", "eye-1", "mouth-1", "dark", "en", true, false); + await _userService.CreateProfile(janeGuid.ToString(), "jane", "", "", "000", "eye-2", "mouth-2", "light", "de", true, true); + + ProfileModel? profileByName = await _userService.GetProfileByUsername("jane"); + + Assert.NotNull(profileByName); + Assert.Equal("jane", profileByName.Name); + } + + + [Fact] + public async Task GetProfileByUuid() + { + Guid johnGuid = Guid.NewGuid(); + Guid janeGuid = Guid.NewGuid(); + await _userService.CreateProfile(johnGuid.ToString(), "john", "", "", "fff", "eye-1", "mouth-1", "dark", "en", true, false); + await _userService.CreateProfile(janeGuid.ToString(), "jane", "", "", "000", "eye-2", "mouth-2", "light", "de", true, true); + + ProfileModel? profileByUuid = await _userService.GetProfile(janeGuid.ToString()); + + Assert.NotNull(profileByUuid); + Assert.Equal(janeGuid.ToString(), profileByUuid.Uuid); + } + + [Fact] + public async Task GetProfileById() + { + Guid johnGuid = Guid.NewGuid(); + ProfileModel john = await _userService.CreateProfile(johnGuid.ToString(), "john", "", "", "fff", "eye-1", "mouth-1", "dark", "en", true, false); + + ProfileModel? profileById = await _userService.GetProfile(john.ProfileId); + + Assert.NotNull(profileById); + Assert.Equal("john", profileById.Name); + } + + [Fact] + public async Task GetActiveProfile_ThrowsNotImplementedException() + { + await Assert.ThrowsAsync(() => _userService.GetActiveProfile()); + } + + [Fact] + public async Task UpdateProfileById() + { + Guid johnGuid = Guid.NewGuid(); + ProfileModel john = await _userService.CreateProfile(johnGuid.ToString(), "john", "", "", "fff", "eye-1", "mouth-1", "dark", "en", true, false); + + ProfileModel updated = await _userService.UpdateProfile(john.ProfileId, name: "johnny", theme: "light"); + + Assert.Equal("johnny", updated.Name); + Assert.Equal("light", updated.Theme); + Assert.Equal("fff", updated.BackgroundColor); // Should remain unchanged + } + + [Fact] + public async Task UpdateProfileByModel() + { + Guid johnGuid = Guid.NewGuid(); + ProfileModel john = await _userService.CreateProfile(johnGuid.ToString(), "john", "", "", "fff", "eye-1", "mouth-1", "dark", "en", true, false); + + ProfileModel updated = await _userService.UpdateProfile(john, name: "johnny", theme: "light"); + + Assert.Equal("johnny", updated.Name); + Assert.Equal("light", updated.Theme); + Assert.Equal("fff", updated.BackgroundColor); // Should remain unchanged + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/Utils/MetadataDbContextFactory.cs b/server/AniStream.Tests/Utils/MetadataDbContextFactory.cs new file mode 100644 index 0000000..e7202c2 --- /dev/null +++ b/server/AniStream.Tests/Utils/MetadataDbContextFactory.cs @@ -0,0 +1,38 @@ +using AniStream.Contexts; +using AniStream.Utils; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Tests.Utils; + +internal class MetadataDbContextFactory : DbContextFactory +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions _options; + private bool _migrated; + + public MetadataDbContextFactory(string migrationFolder) : base("sqlite", migrationFolder, "metadata") + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + DbContextOptionsBuilder builder = new DbContextOptionsBuilder(); + builder.UseSqlite(_connection); + + _options = builder.Options; + _migrated = false; + } + + public override async Task GetContext() + { + MetadataDbContext context = new MetadataDbContext(_options); + + if (!_migrated) + { + await MigrateDatabaseIfRequired(context); + _migrated = true; + } + + return context; + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/Utils/MockCredentialService.cs b/server/AniStream.Tests/Utils/MockCredentialService.cs new file mode 100644 index 0000000..3b72a96 --- /dev/null +++ b/server/AniStream.Tests/Utils/MockCredentialService.cs @@ -0,0 +1,19 @@ +using AniStream.Contracts; +using AniStream.Models; + +namespace AniStream.Tests.Utils; + +public sealed class MockCredentialService : ICredentialsService +{ + public const string MOCK_UUID = "test-tenant-id"; + + public Task ValidateCredentials(string username, string password) + { + throw new NotImplementedException(); + } + + public Task GetCurrentUuid() + { + return Task.FromResult(MOCK_UUID); + } +} diff --git a/server/AniStream.Tests/Utils/ProfileDbContextFactory.cs b/server/AniStream.Tests/Utils/ProfileDbContextFactory.cs new file mode 100644 index 0000000..94c1a1f --- /dev/null +++ b/server/AniStream.Tests/Utils/ProfileDbContextFactory.cs @@ -0,0 +1,38 @@ +using AniStream.Contexts; +using AniStream.Utils; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Tests.Utils; + +internal class ProfileDbContextFactory : DbContextFactory +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions _options; + private bool _migrated; + + public ProfileDbContextFactory(string migrationFolder) : base("sqlite", migrationFolder, "profile") + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + DbContextOptionsBuilder builder = new DbContextOptionsBuilder(); + builder.UseSqlite(_connection); + + _options = builder.Options; + _migrated = false; + } + + public override async Task GetContext() + { + ProfileDbContext context = new ProfileDbContext(_options); + + if (!_migrated) + { + await MigrateDatabaseIfRequired(context); + _migrated = true; + } + + return context; + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/Utils/TestBase.cs b/server/AniStream.Tests/Utils/TestBase.cs new file mode 100644 index 0000000..6700389 --- /dev/null +++ b/server/AniStream.Tests/Utils/TestBase.cs @@ -0,0 +1,55 @@ +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Services; +using AniStream.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace AniStream.Tests.Utils; + +public abstract class TestBase : IDisposable +{ + private readonly ServiceCollection _services; + private readonly ServiceProvider _provider; + + private readonly IServiceScope _scope; + + public TestBase(bool setProvider = true) + { + // TODO resolve dynamically + const string MIGRATION_PATH = "./migration"; + _services = new ServiceCollection(); + + AutoLoader.LoadServices(_services, new AutoLoader.Options + { + DatabaseDriver = "sqlite", + MigrationPath = MIGRATION_PATH, + DatabaseMetadataConnectionString = "DataSource=:memory:", + DatabaseProfileConnectionString = "DataSource=:memory:" + }); + + // Overwrite DB handlers so that each Fact uses one DB-Connection + _services.AddScoped>(_ => new MetadataDbContextFactory(MIGRATION_PATH)); + _services.AddScoped>(_ => new ProfileDbContextFactory(MIGRATION_PATH)); + + _services.AddScoped(); + + _provider = _services.BuildServiceProvider(); + _scope = _provider.CreateScope(); + + if (setProvider) + { + IProviderService providerService = GetService(); + providerService.SetActiveProvider("sto"); + } + } + + protected T GetService() where T : class + { + return _scope.ServiceProvider.GetRequiredService(); + } + + public void Dispose() + { + _scope.Dispose(); + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/WatchListServiceTests.cs b/server/AniStream.Tests/WatchListServiceTests.cs new file mode 100644 index 0000000..2f998a1 --- /dev/null +++ b/server/AniStream.Tests/WatchListServiceTests.cs @@ -0,0 +1,70 @@ +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Tests.Utils; + +namespace AniStream.Tests; + +public sealed class WatchListServiceTests : TestBase +{ + private readonly IWatchListService _watchListService; + private readonly ISeriesService _seriesService; + + public WatchListServiceTests() + { + _watchListService = GetService(); + _seriesService = GetService(); + } + + [Fact] + public async Task AddSeriesToWatchList() + { + SeriesModel series = await _seriesService.CreateSeries("test-series", "Test Series", "Description", null); + + await _watchListService.AddSeries(series.SeriesId); + + bool isOnList = await _watchListService.IsSeriesOnList(series.SeriesId); + Assert.True(isOnList); + } + + [Fact] + public async Task RemoveSeriesFromWatchList() + { + SeriesModel series = await _seriesService.CreateSeries("test-series", "Test Series", "Description", null); + await _watchListService.AddSeries(series.SeriesId); + + await _watchListService.RemoveSeries(series.SeriesId); + + bool isOnList = await _watchListService.IsSeriesOnList(series.SeriesId); + Assert.False(isOnList); + } + + [Fact] + public async Task GetSeriesIds() + { + SeriesModel series1 = await _seriesService.CreateSeries("series-1", "Series 1", "", null); + SeriesModel series2 = await _seriesService.CreateSeries("series-2", "Series 2", "", null); + + await _watchListService.AddSeries(series1.SeriesId); + await _watchListService.AddSeries(series2.SeriesId); + + int[] seriesIds = await _watchListService.GetSeriesIds(); + + Assert.Contains(series1.SeriesId, seriesIds); + Assert.Contains(series2.SeriesId, seriesIds); + Assert.Equal(2, seriesIds.Length); + } + + [Fact] + public async Task IsSeriesOnList() + { + SeriesModel series = await _seriesService.CreateSeries("test-series", "Test Series", "Description", null); + + bool isOnList = await _watchListService.IsSeriesOnList(series.SeriesId); + Assert.False(isOnList); + + await _watchListService.AddSeries(series.SeriesId); + + isOnList = await _watchListService.IsSeriesOnList(series.SeriesId); + Assert.True(isOnList); + } +} \ No newline at end of file diff --git a/server/AniStream.Tests/WatchTimeServiceTests.cs b/server/AniStream.Tests/WatchTimeServiceTests.cs new file mode 100644 index 0000000..cbe7c83 --- /dev/null +++ b/server/AniStream.Tests/WatchTimeServiceTests.cs @@ -0,0 +1,114 @@ +using AniStream.Contexts; +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Tests.Utils; +using AniStream.Utils; +using Microsoft.EntityFrameworkCore; + +namespace AniStream.Tests; + +public sealed class WatchTimeServiceTests : TestBase +{ + private readonly IWatchTimeService _watchTimeService; + private readonly ISeriesService _seriesService; + private readonly ISeasonService _seasonService; + private readonly IEpisodeService _episodeService; + + public WatchTimeServiceTests() + { + _watchTimeService = GetService(); + _seriesService = GetService(); + _seasonService = GetService(); + _episodeService = GetService(); + } + + [Fact] + public async Task CreateWatchTime() + { + SeriesModel series = await _seriesService.CreateSeries("s1", "Series 1", "", null); + SeasonModel season = await _seasonService.CreateSeason(series.SeriesId, 1); + EpisodeModel episode = await _episodeService.CreateEpisode(season.SeasonId, 1, "E1", "E1", ""); + + WatchTimeModel watchTime = await _watchTimeService.CreateWatchTime(episode.EpisodeId, 50, 10.5); + + Assert.Equal(episode.EpisodeId, watchTime.EpisodeId); + Assert.Equal(50, watchTime.PercentageWatched); + Assert.Equal(10.5, watchTime.StoppedTime); + Assert.Equal(MockCredentialService.MOCK_UUID, watchTime.TenantId); + } + + [Fact] + public async Task GetWatchTimeOfEpisode() + { + SeriesModel series = await _seriesService.CreateSeries("s1", "Series 1", "", null); + SeasonModel season = await _seasonService.CreateSeason(series.SeriesId, 1); + EpisodeModel episode = await _episodeService.CreateEpisode(season.SeasonId, 1, "E1", "E1", ""); + await _watchTimeService.CreateWatchTime(episode.EpisodeId, 75, 20.0); + + WatchTimeModel? watchTime = await _watchTimeService.GetWatchTimeOfEpisode(episode.EpisodeId); + + Assert.NotNull(watchTime); + Assert.Equal(75, watchTime.PercentageWatched); + } + + [Fact] + public async Task GetWatchTimesOfSeries() + { + SeriesModel series = await _seriesService.CreateSeries("s1", "Series 1", "", null); + SeasonModel season = await _seasonService.CreateSeason(series.SeriesId, 1); + EpisodeModel ep1 = await _episodeService.CreateEpisode(season.SeasonId, 1, "E1", "E1", ""); + EpisodeModel ep2 = await _episodeService.CreateEpisode(season.SeasonId, 2, "E2", "E2", ""); + + await _watchTimeService.CreateWatchTime(ep1.EpisodeId, 100, 30.0); + await _watchTimeService.CreateWatchTime(ep2.EpisodeId, 20, 5.0); + + WatchTimeModel[] watchTimes = await _watchTimeService.GetWatchTimesOfSeries(series.SeriesId); + + // Note: The implementation of GetWatchTimesOfSeries in WatchTimeServiceImpl.cs + // seems to be missing a where clause for seriesId/seasonId. + // It currently only filters by TenantId. + // Wait, I should check the implementation again. + // Line 37: IQueryable query = from watchtime in db.WatchTimes where watchtime.TenantId == tenantId select watchtime; + // This looks like a bug in the service, but I should test the intended behavior. + + Assert.Equal(2, watchTimes.Length); + } + + [Fact] + public async Task UpdateWatchTime() + { + SeriesModel series = await _seriesService.CreateSeries("s1", "Series 1", "", null); + SeasonModel season = await _seasonService.CreateSeason(series.SeriesId, 1); + EpisodeModel episode = await _episodeService.CreateEpisode(season.SeasonId, 1, "E1", "E1", ""); + WatchTimeModel watchTime = await _watchTimeService.CreateWatchTime(episode.EpisodeId, 10, 2.0); + + await _watchTimeService.UpdateWatchTime(watchTime.WatchtimeId, 90, 25.0); + + WatchTimeModel? updated = await _watchTimeService.GetWatchTimeOfEpisode(episode.EpisodeId); + Assert.NotNull(updated); + Assert.Equal(90, updated.PercentageWatched); + Assert.Equal(25.0, updated.StoppedTime); + } + + [Fact] + public async Task GetTotalWatchProgression() + { + SeriesModel series = await _seriesService.CreateSeries("s1", "Series 1", "", null); + SeasonModel season = await _seasonService.CreateSeason(series.SeriesId, 1); + EpisodeModel ep1 = await _episodeService.CreateEpisode(season.SeasonId, 1, "E1", "E1", ""); + EpisodeModel ep2 = await _episodeService.CreateEpisode(season.SeasonId, 2, "E2", "E2", ""); + EpisodeModel ep3 = await _episodeService.CreateEpisode(season.SeasonId, 3, "E3", "E3", ""); + + // ep1: 85% (>80% -> completed) + // ep2: 50% (<=80% -> not completed) + // ep3: 0% (not started -> not completed) + // Total progression should be 1/3 = 33% + + await _watchTimeService.CreateWatchTime(ep1.EpisodeId, 85, 100); + await _watchTimeService.CreateWatchTime(ep2.EpisodeId, 50, 60); + + int progression = await _watchTimeService.GetTotalWatchProgression(series.SeriesId); + + Assert.Equal(33, progression); + } +} diff --git a/server/AniStream.Worker/AniStream.Worker.csproj b/server/AniStream.Worker/AniStream.Worker.csproj new file mode 100644 index 0000000..3a94ed0 --- /dev/null +++ b/server/AniStream.Worker/AniStream.Worker.csproj @@ -0,0 +1,20 @@ + + + + net9.0 + enable + enable + dotnet-AniStream.Worker-5a8be353-773b-48fe-bcba-c34ff039a6e5 + True + AniStream.Worker + + + + + + + + + + + diff --git a/server/AniStream.Worker/CancellationScope.cs b/server/AniStream.Worker/CancellationScope.cs new file mode 100644 index 0000000..f66496d --- /dev/null +++ b/server/AniStream.Worker/CancellationScope.cs @@ -0,0 +1,36 @@ +namespace AniStream.Worker; + +internal sealed class CancellationScope +{ + private readonly T[] _snapshot; + private readonly Func _callback; + private readonly CancellationToken _cancellationToken; + + private bool _executedCallback; + + public CancellationScope(T[] snapshot, Func callback, CancellationToken cancellationToken) + { + _snapshot = snapshot; + _callback = callback; + _cancellationToken = cancellationToken; + + _executedCallback = false; + } + + public async Task IsCancelledAsync() + { + if (!_cancellationToken.IsCancellationRequested) + { + return false; + } + + if (_executedCallback) + { + return true; + } + + _executedCallback = true; + await _callback(_snapshot); + return true; + } +} \ No newline at end of file diff --git a/server/AniStream.Worker/Jobs/ProviderSyncJob.cs b/server/AniStream.Worker/Jobs/ProviderSyncJob.cs new file mode 100644 index 0000000..e31f492 --- /dev/null +++ b/server/AniStream.Worker/Jobs/ProviderSyncJob.cs @@ -0,0 +1,63 @@ +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Shared; +using AniStream.Worker.Sidecars; + +namespace AniStream.Worker.Jobs; + +public sealed class ProviderSyncJob +{ + private readonly IProviderService _providerService; + private readonly ISeriesService _seriesService; + private readonly ISeasonService _seasonService; + private readonly IEpisodeService _episodeService; + private readonly IProviderSyncService _syncService; + + + private readonly WorkerClient _worker; + + public ProviderSyncJob( + ILoggerFactory loggerFactory, + IProviderService providerService, + ISeriesService seriesService, + ISeasonService seasonService, + IEpisodeService episodeService, + IProviderSyncService syncService + ) + { + _providerService = providerService; + _seriesService = seriesService; + _seasonService = seasonService; + _episodeService = episodeService; + _syncService = syncService; + + _worker = new WorkerClient(loggerFactory.CreateLogger(), AppConfig.CurrentConfig.SidecarPath); + } + + public async Task SyncEpisodeProviderAsync(SyncProviderJobModel job) + { + EpisodeModel? episode = await _episodeService.GetEpisode(job.EpisodeId); + if (episode is null) + { + throw new InvalidOperationException($"Episode with ID {job.EpisodeId} does not exist"); + } + + SeasonModel? season = await _seasonService.GetSeason(episode.SeasonId); + if (season is null) + { + throw new InvalidOperationException($"Season with ID {episode.SeasonId} does not exist"); + } + + SeriesModel? series = await _seriesService.GetSeries(season.SeriesId); + if (series is null) + { + throw new InvalidOperationException($"Series with ID {season.SeriesId} does not exist"); + } + + ProviderFetchModel[] providers = await _worker.ProvidersAsync(_providerService.GetActiveProvider(), series.Guid, season.SeasonNumber, episode.EpisodeNumber); + foreach (ProviderFetchModel provider in providers) + { + await _syncService.CreateSyncResult(job.SyncProviderJobId, provider.Name, provider.EmbeddedUrl, (int)provider.Language); + } + } +} \ No newline at end of file diff --git a/server/AniStream.Worker/Jobs/SeriesSyncJob.cs b/server/AniStream.Worker/Jobs/SeriesSyncJob.cs new file mode 100644 index 0000000..3567594 --- /dev/null +++ b/server/AniStream.Worker/Jobs/SeriesSyncJob.cs @@ -0,0 +1,104 @@ +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Shared; +using AniStream.Worker.Sidecars; + +namespace AniStream.Worker.Jobs; + +internal sealed class SeriesSyncJob +{ + private readonly IProviderService _providerService; + private readonly ISeriesService _seriesService; + private readonly ISeasonService _seasonService; + private readonly IEpisodeService _episodeService; + + private readonly WorkerClient _worker; + + public SeriesSyncJob( + ILoggerFactory loggerFactory, + IProviderService providerService, + ISeriesService seriesService, + ISeasonService seasonService, + IEpisodeService episodeService + ) + { + _providerService = providerService; + _seriesService = seriesService; + _seasonService = seasonService; + _episodeService = episodeService; + + _worker = new WorkerClient(loggerFactory.CreateLogger(), AppConfig.CurrentConfig.SidecarPath); + } + + public async Task SyncSeriesAsync(SyncSeriesJobModel job) + { + SeriesModel? series = await _seriesService.GetSeries(job.SeriesId); + + if (series is null) + { + throw new InvalidOperationException($"Series with id {job.SeriesId} does not exist"); + } + + SeasonModel[] existingSeasons = await _seasonService.GetSeasons(job.SeriesId); + SeasonFetchModel[] allSeasons = await _worker.SeasonsAsync(_providerService.GetActiveProvider(), series.Guid); + + foreach (SeasonFetchModel season in allSeasons) + { + SeasonModel? existingSeason = existingSeasons.FirstOrDefault(s => s.SeasonNumber == season.SeasonNumber); + EpisodeFetchModel[] episodes = await _worker.EpisodesAsync(_providerService.GetActiveProvider(), series.Guid, season.SeasonNumber); + + if (existingSeason is null) + { + await SyncNewSeasonAsync(series.SeriesId, season.SeasonNumber, episodes); + } + else + { + await SyncExistingSeasonAsync(existingSeason, episodes); + } + } + } + + private async Task SyncExistingSeasonAsync(SeasonModel season, EpisodeFetchModel[] episodes) + { + EpisodeModel[] existingEpisodes = await _episodeService.GetEpisodes(season.SeasonId); + + foreach (EpisodeFetchModel episode in episodes) + { + EpisodeModel? existingEpisode = existingEpisodes.FirstOrDefault(s => s.EpisodeNumber == episode.EpisodeNumber); + if (existingEpisode is null) + { + await _episodeService.CreateEpisode( + season.SeasonId, + episode.EpisodeNumber, + episode.GermanTitle, + episode.EnglishTitle, + episode.Description + ); + continue; + } + + await _episodeService.UpdateEpisode( + existingEpisode.EpisodeId, + germanTitle: episode.GermanTitle, + englishTitle: episode.EnglishTitle, + description: episode.Description + ); + } + } + + private async Task SyncNewSeasonAsync(int seriesId, int seasonNumber, EpisodeFetchModel[] episodes) + { + SeasonModel season = await _seasonService.CreateSeason(seriesId, seasonNumber); + + foreach (EpisodeFetchModel episode in episodes) + { + await _episodeService.CreateEpisode( + season.SeasonId, + episode.EpisodeNumber, + episode.GermanTitle, + episode.EnglishTitle, + episode.Description + ); + } + } +} \ No newline at end of file diff --git a/server/AniStream.Worker/Program.cs b/server/AniStream.Worker/Program.cs new file mode 100644 index 0000000..fd92490 --- /dev/null +++ b/server/AniStream.Worker/Program.cs @@ -0,0 +1,40 @@ +using AniStream.Contracts; +using AniStream.Services; +using AniStream.Shared; +using AniStream.Worker.Services; + +namespace AniStream.Worker; + +internal static class Program +{ + public static async Task Main(string[] args) + { + AppConfig.Initialize(); + + HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + + SetupDependencyInjection(builder); + + IHost host = builder.Build(); + await host.RunAsync(); + } + + private static void SetupDependencyInjection(HostApplicationBuilder builder) + { + // Proprietary services + builder.Services.AddScoped(); + + // BL Layer + AutoLoader.LoadServices(builder.Services, new AutoLoader.Options + { + DatabaseDriver = AppConfig.CurrentConfig.DatabaseDriver, + MigrationPath = AppConfig.CurrentConfig.MigrationPath, + DatabaseMetadataConnectionString = AppConfig.CurrentConfig.DatabaseMetadataConnectionString, + DatabaseProfileConnectionString = AppConfig.CurrentConfig.DatabaseProfileConnectionString, + AssetsPath = AppConfig.CurrentConfig.AssetsPath + }); + } +} \ No newline at end of file diff --git a/server/AniStream.Worker/ProviderSyncWorker.cs b/server/AniStream.Worker/ProviderSyncWorker.cs new file mode 100644 index 0000000..0cc9ed4 --- /dev/null +++ b/server/AniStream.Worker/ProviderSyncWorker.cs @@ -0,0 +1,122 @@ +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Worker.Jobs; + +namespace AniStream.Worker; + +public sealed class ProviderSyncWorker : ScopedBackgroundService +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(5); + + private readonly ILogger _logger; + private readonly IProviderService _providerService; + private readonly IProviderSyncService _syncService; + + private readonly ProviderSyncJob _job; + + public ProviderSyncWorker(IServiceScopeFactory scopeFactory) : base(scopeFactory) + { + _logger = GetRequiredService>(); + _providerService = GetRequiredService(); + _syncService = GetRequiredService(); + + ILoggerFactory loggerFactory = GetRequiredService(); + ISeriesService seriesService = GetRequiredService(); + ISeasonService seasonService = GetRequiredService(); + IEpisodeService episodeService = GetRequiredService(); + IProviderSyncService syncService = GetRequiredService(); + + _job = new ProviderSyncJob(loggerFactory, _providerService, seriesService, seasonService, episodeService, syncService); + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Provider sync worker started"); + + while (!cancellationToken.IsCancellationRequested) + { + _logger.LogInformation("Provider sync worker running at: {Time}", DateTimeOffset.Now); + + try + { + await ProcessPendingTasksAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during sync pickup"); + } + + _logger.LogInformation("Provider sync worker completed run at: {Time}", DateTimeOffset.Now); + await Task.Delay(PollInterval, cancellationToken); + } + } + + private async Task ProcessPendingTasksAsync(CancellationToken cancellationToken) + { + foreach (string provider in _providerService.GetProviders()) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + _providerService.SetActiveProvider(provider); + + SyncProviderJobModel[] jobs = await _syncService.GetSyncJobs(SyncJobStatus.Queued); + if (jobs.Length == 0) + { + continue; + } + + _logger.LogInformation("Found {Count} jobs for provider {Provider}", jobs.Length, provider); + + CancellationScope scope = new CancellationScope(jobs, async jobs => + { + foreach (SyncProviderJobModel job in jobs.Where(job => job.Status == SyncJobStatus.Processing)) + { + await _syncService.UpdateSyncJob(job, SyncJobStatus.Queued); + } + }, cancellationToken); + + foreach (SyncProviderJobModel job in jobs) + { + if (await scope.IsCancelledAsync()) + { + return; + } + + await _syncService.UpdateSyncJob(job, SyncJobStatus.Processing); + } + + foreach (SyncProviderJobModel job in jobs) + { + if (await scope.IsCancelledAsync()) + { + return; + } + + try + { + await _job.SyncEpisodeProviderAsync(job); + await _syncService.UpdateSyncJob(job, SyncJobStatus.Completed, DateTime.UtcNow, DateTime.UtcNow.AddDays(1)); + } + catch (Exception e) + { + List messages = new List(); + + Exception? ex = e; + + while (ex is not null) + { + messages.Add(ex.Message); + ex = ex.InnerException; + } + + string message = string.Join("\n", messages); + _logger.LogError(e, "Provider sync failed for job {Job}", job.SyncProviderJobId); + await _syncService.UpdateSyncJob(job, SyncJobStatus.Failed, DateTime.UtcNow, error: message); + } + } + } + } +} \ No newline at end of file diff --git a/server/AniStream.Worker/ScopedBackgroundService.cs b/server/AniStream.Worker/ScopedBackgroundService.cs new file mode 100644 index 0000000..a6398ed --- /dev/null +++ b/server/AniStream.Worker/ScopedBackgroundService.cs @@ -0,0 +1,23 @@ +namespace AniStream.Worker; + +public abstract class ScopedBackgroundService : BackgroundService +{ + private readonly IServiceScope _scope; + + protected ScopedBackgroundService(IServiceScopeFactory scopeFactory) + { + _scope = scopeFactory.CreateScope(); + } + + protected T GetRequiredService() where T : notnull + { + return _scope.ServiceProvider.GetRequiredService(); + } + + public override void Dispose() + { + _scope.Dispose(); + + base.Dispose(); + } +} \ No newline at end of file diff --git a/server/AniStream.Worker/SeriesSyncWorker.cs b/server/AniStream.Worker/SeriesSyncWorker.cs new file mode 100644 index 0000000..e2f57bc --- /dev/null +++ b/server/AniStream.Worker/SeriesSyncWorker.cs @@ -0,0 +1,122 @@ +using AniStream.Contracts; +using AniStream.Models; +using AniStream.Worker.Jobs; + +namespace AniStream.Worker; + +internal sealed class SeriesSyncWorker : ScopedBackgroundService +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(10); + + private readonly ILogger _logger; + private readonly IProviderService _providerService; + private readonly ISeriesSyncService _syncService; + + private readonly SeriesSyncJob _job; + + public SeriesSyncWorker(IServiceScopeFactory scopeFactory) : base(scopeFactory) + { + _logger = GetRequiredService>(); + _providerService = GetRequiredService(); + _syncService = GetRequiredService(); + + ILoggerFactory loggerFactory = GetRequiredService(); + ISeriesService seriesService = GetRequiredService(); + ISeasonService seasonService = GetRequiredService(); + IEpisodeService episodeService = GetRequiredService(); + + _job = new SeriesSyncJob(loggerFactory, _providerService, seriesService, seasonService, episodeService); + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Series sync worker started"); + + while (!cancellationToken.IsCancellationRequested) + { + _logger.LogInformation("Series sync worker running at: {Time}", DateTimeOffset.Now); + + try + { + await ProcessPendingTasksAsync(cancellationToken); + } + catch (Exception e) + { + _logger.LogError(e, "Error during sync pickup"); + } + + _logger.LogInformation("Series sync worker completed run at: {Time}", DateTimeOffset.Now); + await Task.Delay(PollInterval, cancellationToken); + } + } + + private async Task ProcessPendingTasksAsync(CancellationToken cancellationToken) + { + foreach (string provider in _providerService.GetProviders()) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + _providerService.SetActiveProvider(provider); + + SyncSeriesJobModel[] jobs = await _syncService.GetSyncJobs(SyncJobStatus.Queued); + if (jobs.Length == 0) + { + continue; + } + + _logger.LogInformation("Found {Count} queued jobs for provider {Provider}", jobs.Length, provider); + + CancellationScope scope = new CancellationScope(jobs, async jobs => + { + foreach (SyncSeriesJobModel job in jobs.Where(job => job.Status == SyncJobStatus.Processing)) + { + await _syncService.UpdateSyncJob(job, SyncJobStatus.Queued); + } + }, cancellationToken); + + foreach (SyncSeriesJobModel job in jobs) + { + if (await scope.IsCancelledAsync()) + { + return; + } + + await _syncService.UpdateSyncJob(job, SyncJobStatus.Processing); + } + + foreach (SyncSeriesJobModel job in jobs) + { + if (await scope.IsCancelledAsync()) + { + return; + } + + try + { + await _job.SyncSeriesAsync(job); + await _syncService.UpdateSyncJob(job, SyncJobStatus.Completed, DateTime.UtcNow); + } + catch (Exception e) + { + List messages = new List(); + + Exception? ex = e; + + while (ex is not null) + { + messages.Add(e.Message); + ex = ex.InnerException; + } + + string message = string.Join("\n", messages); + + _logger.LogError(e, "Series sync failed for job {Job}", job.SyncSeriesJobId); + await _syncService.UpdateSyncJob(job, SyncJobStatus.Failed, DateTime.UtcNow, message); + } + } + } + } +} \ No newline at end of file diff --git a/server/AniStream.Worker/Services/CredentialsServiceImpl.cs b/server/AniStream.Worker/Services/CredentialsServiceImpl.cs new file mode 100644 index 0000000..1ccc0f9 --- /dev/null +++ b/server/AniStream.Worker/Services/CredentialsServiceImpl.cs @@ -0,0 +1,17 @@ +using AniStream.Contracts; +using AniStream.Models; + +namespace AniStream.Worker.Services; + +internal sealed class CredentialsServiceImpl : ICredentialsService +{ + public Task ValidateCredentials(string uuid, string password) + { + throw new NotSupportedException(); + } + + public Task GetCurrentUuid() + { + throw new NotSupportedException(); + } +} \ No newline at end of file diff --git a/server/AniStream.Worker/Sidecars/FetchModels.cs b/server/AniStream.Worker/Sidecars/FetchModels.cs new file mode 100644 index 0000000..a85b2b9 --- /dev/null +++ b/server/AniStream.Worker/Sidecars/FetchModels.cs @@ -0,0 +1,84 @@ +using System.Text.Json.Serialization; + +namespace AniStream.Worker.Sidecars; + +internal sealed class SeriesFetchModel +{ + [JsonPropertyName("series")] + public SeriesModel Series { get; set; } + + [JsonPropertyName("genres")] + public GenreModel[] Genres { get; set; } + + internal sealed class SeriesModel + { + [JsonPropertyName("series_id")] + public int SeriesId { get; set; } + + [JsonPropertyName("guid")] + public string Guid { get; set; } + + [JsonPropertyName("title")] + public string Title { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("preview_image")] + public string? PreviewImage { get; set; } + } + + internal sealed class GenreModel + { + [JsonPropertyName("key")] + public string Key { get; set; } + + [JsonPropertyName("main")] + public bool Main { get; set; } + } +} + +internal sealed class SeasonFetchModel +{ + [JsonPropertyName("series_id")] + public int SeriesId { get; set; } + + [JsonPropertyName("season_number")] + public int SeasonNumber { get; set; } +} + +internal sealed class EpisodeFetchModel +{ + [JsonPropertyName("episode_number")] + public int EpisodeNumber { get; set; } + + [JsonPropertyName("german_title")] + public string GermanTitle { get; set; } + + [JsonPropertyName("english_title")] + public string EnglishTitle { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } +} + +internal sealed class ProviderFetchModel +{ + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("language")] + public LanguageCode Language { get; set; } + + [JsonPropertyName("embeddedURL")] + public string EmbeddedUrl { get; set; } + + internal enum LanguageCode + { + DE_DUB = 0, + DE_SUP = 1, + EN_DUB = 2, + EN_SUP = 3, + UNKNOWN = 4, + } +} diff --git a/server/AniStream.Worker/Sidecars/WorkerClient.cs b/server/AniStream.Worker/Sidecars/WorkerClient.cs new file mode 100644 index 0000000..25c8a2f --- /dev/null +++ b/server/AniStream.Worker/Sidecars/WorkerClient.cs @@ -0,0 +1,74 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace AniStream.Worker.Sidecars; + +internal sealed class WorkerClient +{ + private const string WorkerExecutable = "worker"; + + private static readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + private readonly ILogger _logger; + private readonly string _executable; + + public WorkerClient(ILogger logger, string sidecarFolder) + { + _logger = logger; + _executable = Path.Combine(sidecarFolder, WorkerExecutable); + if (!File.Exists(_executable)) + { + throw new InvalidOperationException($"Missing executable: '{_executable}'"); + } + } + + public Task CatalogAsync(string provider) => ExecuteAsync("catalog", "-p", provider, "-o", "json"); + + public Task SeriesAsync(string provider, string guid) => ExecuteAsync("series", guid, "-p", provider, "-o", "json"); + + public Task SeasonsAsync(string provider, string guid) => ExecuteAsync("seasons", guid, "-p", provider, "-o", "json"); + + public Task EpisodesAsync(string provider, string guid, int seasonNumber) => + ExecuteAsync("episodes", guid, seasonNumber.ToString(), "-p", provider, "-o", "json"); + + public Task ProvidersAsync(string provider, string guid, int seasonNumber, int episodeNumber) => + ExecuteAsync("providers", guid, seasonNumber.ToString(), episodeNumber.ToString(), "-p", provider, "-o", "json"); + + private async Task ExecuteAsync(params string[] args) + { + _logger.LogInformation("Invoke Sidecar '{Executable}' with '{Arguments}'", WorkerExecutable, string.Join(" ", args)); + + ProcessStartInfo info = new ProcessStartInfo(_executable, args) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using Process process = new Process + { + StartInfo = info + }; + + process.Start(); + + Task stdoutTask = process.StandardOutput.ReadToEndAsync(); + Task stderrTask = process.StandardError.ReadToEndAsync(); + + await process.WaitForExitAsync(); + + string stdout = await stdoutTask; + string stderr = await stderrTask; + + if (process.ExitCode != 0) + { + throw new InvalidOperationException($"Sidecar command failed ({process.ExitCode}): {stderr}"); + } + + return JsonSerializer.Deserialize(stdout, _jsonOptions) ?? throw new JsonException($"Failed to deserialize {typeof(T).Name}"); + } +} diff --git a/server/AniStream.Worker/appsettings.Development.json b/server/AniStream.Worker/appsettings.Development.json new file mode 100644 index 0000000..b2dcdb6 --- /dev/null +++ b/server/AniStream.Worker/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/server/AniStream.Worker/appsettings.json b/server/AniStream.Worker/appsettings.json new file mode 100644 index 0000000..b2dcdb6 --- /dev/null +++ b/server/AniStream.Worker/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/server/AniStream/AniStream.csproj b/server/AniStream/AniStream.csproj new file mode 100644 index 0000000..2ef4ca8 --- /dev/null +++ b/server/AniStream/AniStream.csproj @@ -0,0 +1,37 @@ + + + + net9.0 + enable + enable + AniStream.API + + + + + + + + + + + + + + + + + + $(DefineConstants);TESTING_ENABLED + + + + + + + + + + + + diff --git a/server/AniStream/Controllers/CredentialsController.cs b/server/AniStream/Controllers/CredentialsController.cs new file mode 100644 index 0000000..ac36fdc --- /dev/null +++ b/server/AniStream/Controllers/CredentialsController.cs @@ -0,0 +1,53 @@ +using System.Security.Claims; +using AniStream.API.DTO; +using AniStream.API.Utils; +using AniStream.Contracts; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AniStream.API.Controllers; + +[Route("api/credentials")] +[ApiController] +public sealed class CredentialsController : ApiControllerBase +{ + public const string LOGIN_ROUTE = "/api/credentials/login"; + public const string LOGOUT_ROUTE = "/api/credentials/logout"; + + private ICredentialsService _credentialsService; + + public CredentialsController(ICredentialsService credentialsService) + { + _credentialsService = credentialsService; + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginModel credentials) + { + Models.ProfileModel? profile = await _credentialsService.ValidateCredentials(credentials.Uuid, credentials.Password); + if (profile is null) + { + return Unauthorized("Credentials are wrong"); + } + + List claims = new List + { + new Claim(ClaimTypes.Name, profile.Uuid), + }; + ClaimsIdentity identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + ClaimsPrincipal principal = new ClaimsPrincipal(identity); + + await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); + return Ok("Login successful"); + } + + [HttpGet("logout")] + [Authorize] + public async Task Logout() + { + await HttpContext.SignOutAsync(); + return Ok("Logout successful"); + } +} \ No newline at end of file diff --git a/server/AniStream/Controllers/EpisodeController.cs b/server/AniStream/Controllers/EpisodeController.cs new file mode 100644 index 0000000..ce6fb6c --- /dev/null +++ b/server/AniStream/Controllers/EpisodeController.cs @@ -0,0 +1,108 @@ +using AniStream.API.DTO; +using AniStream.API.Utils; +using AniStream.Contracts; +using AniStream.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using EpisodeModel = AniStream.API.DTO.EpisodeModel; + +namespace AniStream.API.Controllers; + +[Route("api/{provider}/episodes")] +[ApiController] +[Authorize] +public sealed class EpisodeController : ApiControllerBase +{ + private readonly IEpisodeService _episodeService; + private readonly IProviderSyncService _syncService; + + public EpisodeController(IEpisodeService episodeService, IProviderSyncService syncService) + { + _episodeService = episodeService; + _syncService = syncService; + } + +#if TESTING_ENABLED + [HttpPost] + public async Task> CreateEpisode([FromBody] EpisodeCreateModel data) + { + Models.EpisodeModel episode = await _episodeService.CreateEpisode( + data.SeasonId, + data.EpisodeNumber, + data.GermanTitle, + data.EnglishTitle, + data.Description + ); + + return Ok(episode.ToDTO()); + } +#endif + + [HttpGet("{episodeId}")] + public async Task> GetEpisode(int episodeId) + { + Models.EpisodeModel? episode = await _episodeService.GetEpisode(episodeId); + if (episode is null) + { + return NotFound($"Episode with the ID '{episodeId}' not found"); + } + + return Ok(episode.ToDTO()); + } + +#if TESTING_ENABLED + [HttpPut("{episodeId}")] + public async Task> UpdateEpisode(int episodeId, [FromBody] EpisodeUpdateModel data) + { + Models.EpisodeModel? episode = await _episodeService.GetEpisode(episodeId); + if (episode is null) + { + return NotFound($"Episode with ID '{episodeId}' not found"); + } + + await _episodeService.UpdateEpisode(episode, germanTitle: data.GermanTitle, englishTitle: data.EnglishTitle, description: data.Description); + + return Ok(episode.ToDTO()); + } +#endif + + [HttpGet("season/{seasonId}")] + public async Task> GetEpisodes(int seasonId) + { + Models.EpisodeModel[] episodes = await _episodeService.GetEpisodes(seasonId); + + return episodes.Select(episode => episode.ToDTO()).ToArray(); + } + + [HttpGet("{episodeId}/providers")] + public async Task> GetProviders(int episodeId) + { + Models.EpisodeModel? episode = await _episodeService.GetEpisode(episodeId); + if (episode is null) + { + return NotFound($"Episode with the ID '{episodeId}' not found"); + } + + SyncProviderJobResultModel[] results = await _syncService.GetSyncResults(episodeId); + SyncProviderJobModel? job = await _syncService.GetSyncJobByEpisode(episode); + + SyncJobStatus? status = job?.Status ?? null; + + if (results.Length == 0 && status != SyncJobStatus.Queued) + { + await _syncService.RequestSync(episode); + status = SyncJobStatus.Queued; + } + + return Ok(new EpisodeSyncModel + { + Status = status, + Providers = results.Select(result => new EpisodeProviderModel + { + Name = result.Provider, + Url = result.Url, + LanguageCode = result.LanguageCode + }).ToArray() + }); + } +} \ No newline at end of file diff --git a/server/AniStream/Controllers/GenreController.cs b/server/AniStream/Controllers/GenreController.cs new file mode 100644 index 0000000..6e7e171 --- /dev/null +++ b/server/AniStream/Controllers/GenreController.cs @@ -0,0 +1,92 @@ +using AniStream.API.DTO; +using AniStream.API.Utils; +using AniStream.Contracts; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore.Metadata.Internal; + +namespace AniStream.API.Controllers; + +[Route("api/{provider}/genres")] +[ApiController] +[Authorize] +public sealed class GenreController : ApiControllerBase +{ + private readonly IGenreService _genreService; + + public GenreController(IGenreService genreService) + { + _genreService = genreService; + } + + [HttpGet] + public async Task GetGenres() + { + Models.GenreModel[] genres = await _genreService.GetGenres(); + + return genres.Select(genre => genre.ToDTO()).ToArray(); + } + +#if TESTING_ENABLED + [HttpPost] + public async Task> CreateGenre([FromBody] GenreCreateModel data) + { + Models.GenreModel genre = await _genreService.CreateGenre(data.Key); + + return genre.ToDTO(); + } +#endif + + [HttpGet("{genreId}")] + public async Task> GetGenre(int genreId) + { + Models.GenreModel? genre = await _genreService.GetGenre(genreId); + if (genre is null) + { + return NotFound($"Genre with the ID '{genreId}' not found"); + } + + return Ok(genre.ToDTO()); + } + + [HttpGet("{genreKey}/key")] + public async Task> GetGenre(string genreKey) + { + Models.GenreModel? genre = await _genreService.GetGenre(genreKey); + if (genre is null) + { + return NotFound($"Genre with the key '{genreKey}' not found"); + } + + return Ok(genre.ToDTO()); + } + +#if TESTING_ENABLED + [HttpPost("series")] + public async Task CreateGenreToSeries([FromBody] GenreToSeriesCreateModel data) + { + await _genreService.CreateGenreToSeries(data.GenreId, data.SeriesId, data.MainGenre); + return Ok("Genre added to series"); + } +#endif + + [HttpGet("series/{seriesId}")] + public async Task GetGenresOfSeries(int seriesId) + { + Models.GenreModel[] genres = await _genreService.GetNonMainGenresOfSeries(seriesId); + + return genres.Select(genre => genre.ToDTO()).ToArray(); + } + + [HttpGet("series/{seriesId}/main")] + public async Task> GetMainGenreOfSeries(int seriesId) + { + Models.GenreModel? genre = await _genreService.GetMainGenreOfSeries(seriesId); + if (genre is null) + { + return NotFound("Series dont have a main genre"); + } + + return Ok(genre); + } +} \ No newline at end of file diff --git a/server/AniStream/Controllers/ListController.cs b/server/AniStream/Controllers/ListController.cs new file mode 100644 index 0000000..0decb35 --- /dev/null +++ b/server/AniStream/Controllers/ListController.cs @@ -0,0 +1,146 @@ +using AniStream.API.DTO; +using AniStream.API.Utils; +using AniStream.Contracts; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AniStream.API.Controllers; + +[Route("api/{provider}/lists")] +[ApiController] +[Authorize] +public sealed class ListController : ApiControllerBase +{ + private readonly IListService _listService; + private readonly ICredentialsService _credentialsService; + + public ListController(IListService listService, ICredentialsService credentialsService) + { + _listService = listService; + _credentialsService = credentialsService; + } + + [HttpGet] + public async Task GetLists() + { + string tenantId = await _credentialsService.GetCurrentUuid(); + + Models.ListModel[] lists = await _listService.GetLists(); + + return lists.Select(list => list.ToDTO(tenantId)).ToArray(); + } + + [HttpPost] + public async Task CreateList([FromBody] ListCreateModel data) + { + string tenantId = await _credentialsService.GetCurrentUuid(); + + Models.ListModel list = await _listService.CreateList(data.Name); + + return list.ToDTO(tenantId); + } + + [HttpGet("{listId}")] + public async Task> GetList(int listId) + { + string tenantId = await _credentialsService.GetCurrentUuid(); + + Models.ListModel? list = await _listService.GetList(listId); + if (list is null) + { + return NotFound($"List with ID '{listId}' not found"); + } + + return Ok(list.ToDTO(tenantId)); + } + + [HttpPut("{listId}")] + public async Task> UpdateList(int listId, [FromBody] ListUpdateModel data) + { + string tenantId = await _credentialsService.GetCurrentUuid(); + + Models.ListModel? list = await _listService.GetList(listId); + if (list is null) + { + return NotFound($"List with ID '{listId}' not found"); + } + + await _listService.UpdateList(list, data.Name); + + return Ok(list.ToDTO(tenantId)); + } + + [HttpDelete("{listId}")] + public async Task DeleteList(int listId) + { + Models.ListModel? list = await _listService.GetList(listId); + if (list is null) + { + return NotFound($"List with ID '{listId}' not found"); + } + + await _listService.DeleteList(list); + return Ok("List deleted"); + } + + [HttpGet("series/{seriesId}")] + public async Task GetListsOfSeries(int seriesId) + { + string tenantId = await _credentialsService.GetCurrentUuid(); + Models.ListModel[] lists = await _listService.GetListsOfSeries(seriesId); + + return lists.Select(list => list.ToDTO(tenantId)).ToArray(); + } + + [HttpGet("{listId}/series")] + public async Task> GetSeries(int listId) + { + Models.ListModel? list = await _listService.GetList(listId); + if (list is null) + { + return NotFound($"List with ID '{listId}' not found"); + } + + Models.SeriesModel[] series = await _listService.GetSeries(list); + + return Ok(series.Select(series => series.ToDTO()).ToArray()); + } + + [HttpPost("{listId}/series/{seriesId}")] + public async Task AddSeriesToList(int listId, int seriesId) + { + Models.ListModel? list = await _listService.GetList(listId); + if (list is null) + { + return NotFound($"List with ID '{listId}' not found"); + } + + await _listService.AddSeriesToList(list, seriesId); + return Ok("Series added to list"); + } + + [HttpDelete("{listId}/series/{seriesId}")] + public async Task RemoveSeriesFromList(int listId, int seriesId) + { + Models.ListModel? list = await _listService.GetList(listId); + if (list is null) + { + return NotFound($"List with ID '{listId}' not found"); + } + + await _listService.RemoveSeriesFromList(list, seriesId); + return Ok("Series removed from list"); + } + + [HttpGet("{listId}/preview")] + public async Task> GetPreviewImages(int listId) + { + Models.ListModel? list = await _listService.GetList(listId); + if (list is null) + { + return NotFound($"List with ID '{listId}' not found"); + } + + return Ok(await _listService.GetPreviewImages(list)); + } +} \ No newline at end of file diff --git a/server/AniStream/Controllers/ProfileController.cs b/server/AniStream/Controllers/ProfileController.cs new file mode 100644 index 0000000..a5a040b --- /dev/null +++ b/server/AniStream/Controllers/ProfileController.cs @@ -0,0 +1,124 @@ +using AniStream.API.DTO; +using AniStream.API.Utils; +using AniStream.Contracts; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AniStream.API.Controllers; + +[Route("/api/profiles")] +[ApiController] +[Authorize] +public sealed class ProfileController : ApiControllerBase +{ + private readonly IUserService _userService; + + public ProfileController(IUserService userService) + { + _userService = userService; + } + + [HttpGet] + [AllowAnonymous] + public async Task> GetProfiles() + { + // TODO implement GetProfilesPublic with Public DTO for stripped information + Models.ProfileModel[] profiles = await _userService.GetProfiles(); + + return profiles.Select(profile => profile.ToDTO()).ToArray(); + } + + [HttpGet("{profileId}")] + public async Task> GetProfile(int profileId) + { + Models.ProfileModel? profile = await _userService.GetProfile(profileId); + if (profile is null) + { + return NotFound($"Profile with ID '{profileId}' not found"); + } + + return Ok(profile.ToDTO()); + } + + [HttpPut("{profileId}")] + public async Task> UpdateProfile(int profileId, [FromBody] ProfileUpdateModel data) + { + Models.ProfileModel? profile = await _userService.GetProfile(profileId); + if (profile is null) + { + return NotFound($"Profile with ID '{profileId}' not found"); + } + + await _userService.UpdateProfile( + profile, + data.Name, + data.BackgroundColor, + data.Eye, + data.Mouth, + data.Theme, + data.Lang, + data.TosAccepted + ); + + return Ok(profile.ToDTO()); + } + + [HttpGet("{uuid}/uuid")] + public async Task> GetProfile(string uuid) + { + Models.ProfileModel? profile = await _userService.GetProfile(uuid); + if (profile is null) + { + return NotFound($"Profile with UUID '{uuid}' not found"); + } + + return Ok(profile.ToDTO()); + } + + [HttpPut("{uuid}/uuid")] + public async Task> UpdateProfile(string uuid, [FromBody] ProfileUpdateModel data) + { + Models.ProfileModel? profile = await _userService.GetProfile(uuid); + if (profile is null) + { + return NotFound($"Profile with UUID '{uuid}' not found"); + } + + await _userService.UpdateProfile( + profile, + data.Name, + data.BackgroundColor, + data.Eye, + data.Mouth, + data.Theme, + data.Lang, + data.TosAccepted + ); + + return Ok(profile.ToDTO()); + } + +#if TESTING_ENABLED + [HttpPost] + public async Task> CreateProfile([FromBody] ProfileCreateModel data) + { + string uuid = Guid.NewGuid().ToString(); + + Models.ProfileModel profileModel = await _userService.CreateProfile( + uuid, + data.Name, + data.Password, + data.PasswordSalt, + data.BackgroundColor, + data.Eye, + data.Mouth, + data.Theme, + data.Lang, + false, + false + ); + + return profileModel.ToDTO(); + } +#endif +} \ No newline at end of file diff --git a/server/AniStream/Controllers/ResourceController.cs b/server/AniStream/Controllers/ResourceController.cs new file mode 100644 index 0000000..ab8af82 --- /dev/null +++ b/server/AniStream/Controllers/ResourceController.cs @@ -0,0 +1,31 @@ +using AniStream.API.Utils; +using AniStream.Contracts; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AniStream.API.Controllers; + +[Route("api/{provider}/resources")] +[ApiController] +[Authorize] +public sealed class ResourceController : ApiControllerBase +{ + private readonly IResourceService _resourceService; + + public ResourceController(IResourceService resourceService) + { + _resourceService = resourceService; + } + + [HttpGet("{hash}")] + public ActionResult GetResource(string hash) + { + Stream? resource = _resourceService.GetResource(hash); + if (resource is null) + { + return NotFound($"Resource with hash '{hash}' not found"); + } + + return File(resource, "application/octet-stream"); + } +} \ No newline at end of file diff --git a/server/AniStream/Controllers/SeasonController.cs b/server/AniStream/Controllers/SeasonController.cs new file mode 100644 index 0000000..a680b3f --- /dev/null +++ b/server/AniStream/Controllers/SeasonController.cs @@ -0,0 +1,49 @@ +using AniStream.API.DTO; +using AniStream.API.Utils; +using AniStream.Contracts; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AniStream.API.Controllers; + +[Route("api/{provider}/seasons")] +[ApiController] +[Authorize] +public class SeasonController : ApiControllerBase +{ + private readonly ISeasonService _seasonService; + + public SeasonController(ISeasonService seasonService) + { + _seasonService = seasonService; + } + +#if TESTING_ENABLED + [HttpPost] + public async Task> CreateSeason([FromBody] SeasonCreateModel data) + { + Models.SeasonModel season = await _seasonService.CreateSeason(data.SeriesId, data.SeasonNumber); + + return Ok(season.ToDTO()); + } +#endif + + [HttpGet("{seasonId}")] + public async Task> GetSeason(int seasonId) + { + Models.SeasonModel? season = await _seasonService.GetSeason(seasonId); + if (season is null) + { + return NotFound($"Season with ID '{seasonId}' not found"); + } + + return Ok(season.ToDTO()); + } + + [HttpGet("series/{seriesId}")] + public async Task GetSeasonsOfSeries(int seriesId) + { + Models.SeasonModel[] seasons = await _seasonService.GetSeasons(seriesId); + return seasons.Select(season => season.ToDTO()).ToArray(); + } +} \ No newline at end of file diff --git a/server/AniStream/Controllers/SeriesController.cs b/server/AniStream/Controllers/SeriesController.cs new file mode 100644 index 0000000..ba97e30 --- /dev/null +++ b/server/AniStream/Controllers/SeriesController.cs @@ -0,0 +1,116 @@ +using AniStream.API.DTO; +using AniStream.API.Utils; +using AniStream.Contracts; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AniStream.API.Controllers; + +[Route("api/{provider}/series")] +[ApiController] +[Authorize] +public class SeriesController : ApiControllerBase +{ + private readonly ISeriesService _seriesService; + private readonly ISeriesSyncService _syncService; + + public SeriesController(ISeriesService seriesService, ISeriesSyncService syncService) + { + _seriesService = seriesService; + _syncService = syncService; + } + +#if TESTING_ENABLED + [HttpPost] + public async Task CreateSeries([FromBody] SeriesCreateModel data) + { + Models.SeriesModel series = await _seriesService.CreateSeries( + data.Guid, + data.Title, + data.Description, + data.PreviewImage + ); + + return series.ToDTO(); + } +#endif + + [HttpGet("{seriesId}")] + public async Task> GetSeries(int seriesId) + { + Models.SeriesModel? series = await _seriesService.GetSeries(seriesId); + if (series is null) + { + return NotFound($"Series with ID '{seriesId}' not found"); + } + + return series.ToDTO(); + } + + [HttpGet("{seriesId}/sync")] + public async Task GetSeriesSync(int seriesId) + { + Models.SeriesModel? series = await _seriesService.GetSeries(seriesId); + if (series is null) + { + return NotFound($"Series with ID '{seriesId}' not found"); + } + + bool requiresSync = await _seriesService.RequiresSync(seriesId); + Models.SyncSeriesJobModel? job = await _syncService.GetSyncJobBySeries(series); + + return Ok(new SeriesSyncModel + { + RequiresSync = requiresSync, + Status = job?.Status ?? null + }); + } + + [HttpPost("{seriesId}/sync")] + public async Task> SyncSeries(int seriesId) + { + Models.SeriesModel? series = await _seriesService.GetSeries(seriesId); + if (series is null) + { + return NotFound($"Series with ID '{seriesId}' not found"); + } + + await _syncService.RequestSync(series); + return Ok("Sync successfully queued"); + } + + [HttpPost("chunk")] + public async Task GetSeriesChunk([FromBody] SeriesFilterModel options) + { + Models.SeriesModel[] series = await _seriesService.GetSeriesChunk(options.Offset, options.Limit, options.SearchText, options.GenreIds); + return series.Select(series => series.ToDTO()).ToArray(); + } + + [HttpGet] + public async Task> GetSeriesByIds([FromQuery] int[] seriesIds) + { + Models.SeriesModel[] series = await _seriesService.GetSeriesByIds(seriesIds); + + return series.Select(series => series.ToDTO()).ToArray(); + } + + [HttpGet("guid/{guid}")] + public async Task> GetSeries(string guid) + { + Models.SeriesModel? series = await _seriesService.GetSeries(guid); + if (series is null) + { + return NotFound($"Series with GUID '{guid}' not found"); + } + + return series.ToDTO(); + } + + [HttpGet("started")] + public async Task> GetStartedSeries() + { + Models.SeriesModel[] series = await _seriesService.GetStartedSeries(); + + return series.Select(series => series.ToDTO()).ToArray(); + } +} \ No newline at end of file diff --git a/server/AniStream/Controllers/WatchListController.cs b/server/AniStream/Controllers/WatchListController.cs new file mode 100644 index 0000000..6ec658d --- /dev/null +++ b/server/AniStream/Controllers/WatchListController.cs @@ -0,0 +1,50 @@ +using AniStream.API.Utils; +using AniStream.Contracts; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AniStream.API.Controllers; + +[Route("api/{provider}/watchlist")] +[ApiController] +[Authorize] +public sealed class WatchListController : ApiControllerBase +{ + private readonly IWatchListService _watchListService; + + public WatchListController(IWatchListService watchListService) + { + _watchListService = watchListService; + } + + [HttpGet("series")] + public async Task> GetSeries() + { + return await _watchListService.GetSeriesIds(); + } + + [HttpGet("series/{seriesId}")] + public async Task SeriesOnList(int seriesId) + { + if (await _watchListService.IsSeriesOnList(seriesId)) + { + return Ok($"Series '{seriesId}' is on the watchlist"); + } + + return NotFound($"Series '{seriesId}' is not on the watchlist"); + } + + [HttpPost("series/{seriesId}")] + public async Task AddSeries(int seriesId) + { + await _watchListService.AddSeries(seriesId); + return Ok($"Series '{seriesId}' added to the watchlist"); + } + + [HttpDelete("series/{seriesId}")] + public async Task RemoveSeries(int seriesId) + { + await _watchListService.RemoveSeries(seriesId); + return Ok($"Series '{seriesId}' removed from the watchlist"); + } +} \ No newline at end of file diff --git a/server/AniStream/Controllers/WatchTimeController.cs b/server/AniStream/Controllers/WatchTimeController.cs new file mode 100644 index 0000000..e0b7bc4 --- /dev/null +++ b/server/AniStream/Controllers/WatchTimeController.cs @@ -0,0 +1,112 @@ +using AniStream.API.DTO; +using AniStream.API.Utils; +using AniStream.Contracts; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AniStream.API.Controllers; + +[Route("api/{provider}/watchtime")] +[ApiController] +[Authorize] +public sealed class WatchTimeController : ApiControllerBase +{ + private readonly IWatchTimeService _watchTimeService; + + public WatchTimeController(IWatchTimeService watchTimeService) + { + _watchTimeService = watchTimeService; + } + + [HttpPost] + public async Task> CreateWatchTime(WatchTimeCreateModel data) + { + Models.WatchTimeModel watchtime = await _watchTimeService.CreateWatchTime(data.EpisodeId, data.PercentageWatched, data.StoppedTime); + + return Ok(watchtime.ToDTO()); + } + + [HttpGet("{watchtimeId}")] + public async Task> GetWatchTime(int watchtimeId) + { + Models.WatchTimeModel? watchtime = await _watchTimeService.GetWatchTime(watchtimeId); + if (watchtime is null) + { + return NotFound($"Watchtime with ID '{watchtimeId}' not found"); + } + + return Ok(watchtime.ToDTO()); + } + + [HttpPut("{watchtimeId}")] + public async Task> UpdateWatchTime(int watchtimeId, WatchTimeUpdateModel data) + { + Models.WatchTimeModel? watchtime = await _watchTimeService.GetWatchTime(watchtimeId); + if (watchtime is null) + { + return NotFound($"Watchtime with ID '{watchtimeId}' not found"); + } + + await _watchTimeService.UpdateWatchTime(watchtime, data.PercentageWatched, data.StoppedTime); + + return Ok(watchtime.ToDTO()); + } + + [HttpGet("{episodeId}/episode")] + public async Task> GetWatchTimeOfEpisode(int episodeId) + { + Models.WatchTimeModel? watchtime = await _watchTimeService.GetWatchTimeOfEpisode(episodeId); + if (watchtime is null) + { + return NotFound($"WatchTime of Episode with ID '{episodeId}' dont exist"); + } + + return Ok(watchtime.ToDTO()); + } + + [HttpPut("{episodeId}/episode")] + public async Task> UpdateWatchTimeOfEpisode(int episodeId, WatchTimeUpdateModel data) + { + Models.WatchTimeModel? watchtime = await _watchTimeService.GetWatchTimeOfEpisode(episodeId); + if (watchtime is null) + { + return NotFound($"WatchTime of Episode with ID '{episodeId}' dont exist"); + } + + await _watchTimeService.UpdateWatchTime(watchtime, data.PercentageWatched, data.StoppedTime); + return Ok(watchtime.ToDTO()); + } + + [HttpPut("{seasonId}/season")] + public async Task UpdateWatchTimeOfSeason(int seasonId, WatchTimeUpdateModel data) + { + await _watchTimeService.UpdateWatchTimeOfSeason(seasonId, data.PercentageWatched, data.StoppedTime); + return Ok("Watchtime of season updated successfully"); + } + + [HttpGet("{seriesId}/total")] + public async Task> GetTotalWatchTimeOfSeries(int seriesId) + { + int totalProgression = await _watchTimeService.GetTotalWatchProgression(seriesId); + + return Ok(new WatchTimeTotalModel + { + TotalProgression = totalProgression + }); + } + + [HttpGet("{seriesId}/series")] + public async Task> GetWatchTimeOfSeries(int seriesId) + { + Models.WatchTimeModel[] watchTimes = await _watchTimeService.GetWatchTimesOfSeries(seriesId); + + return Ok(watchTimes.Select(watchtime => watchtime.ToDTO())); + } + + [HttpPut("{seriesId}/series")] + public async Task UpdateWatchTimeOfSeries(int seriesId, WatchTimeUpdateModel data) + { + await _watchTimeService.UpdateWatchTimeOfSeries(seriesId, data.PercentageWatched, data.StoppedTime); + return Ok("Watchtime of series updated successfully"); + } +} \ No newline at end of file diff --git a/server/AniStream/DTO/EpisodeModel.cs b/server/AniStream/DTO/EpisodeModel.cs new file mode 100644 index 0000000..b3cfb89 --- /dev/null +++ b/server/AniStream/DTO/EpisodeModel.cs @@ -0,0 +1,72 @@ +using AniStream.Models; + +namespace AniStream.API.DTO; + +public sealed class EpisodeModel +{ + public required int EpisodeId { get; set; } + + public required int SeasonId { get; set; } + + public required int EpisodeNumber { get; set; } + + public required string GermanTitle { get; set; } + + public required string EnglishTitle { get; set; } + + public required string Description { get; set; } +} + +public sealed class EpisodeSyncModel +{ + public required SyncJobStatus? Status { get; set; } + + public required EpisodeProviderModel[] Providers { get; set; } +} + +public sealed class EpisodeProviderModel +{ + public required string Name { get; set; } + + public required string Url { get; set; } + + public required int LanguageCode { get; set; } +} + +public sealed class EpisodeCreateModel +{ + public required int SeasonId { get; set; } + + public required int EpisodeNumber { get; set; } + + public required string GermanTitle { get; set; } + + public required string EnglishTitle { get; set; } + + public required string Description { get; set; } +} + +public sealed class EpisodeUpdateModel +{ + public required string? GermanTitle { get; set; } + + public required string? EnglishTitle { get; set; } + + public required string? Description { get; set; } +} + +internal static class EpisodeModelHelper +{ + public static EpisodeModel ToDTO(this Models.EpisodeModel model) + { + return new EpisodeModel + { + EpisodeId = model.EpisodeId, + SeasonId = model.SeasonId, + EpisodeNumber = model.EpisodeNumber, + GermanTitle = model.GermanTitle, + EnglishTitle = model.EnglishTitle, + Description = model.Description + }; + } +} \ No newline at end of file diff --git a/server/AniStream/DTO/GenreModel.cs b/server/AniStream/DTO/GenreModel.cs new file mode 100644 index 0000000..05446fa --- /dev/null +++ b/server/AniStream/DTO/GenreModel.cs @@ -0,0 +1,32 @@ +namespace AniStream.API.DTO; + +public sealed class GenreModel +{ + public required int GenreId { get; set; } + + public required string Key { get; set; } +} + +public sealed class GenreCreateModel +{ + public required string Key { get; set; } +} + +public sealed class GenreToSeriesCreateModel +{ + public required int GenreId { get; set; } + public required int SeriesId { get; set; } + public required bool MainGenre { get; set; } +} + +internal static class GenreModelHelper +{ + public static GenreModel ToDTO(this Models.GenreModel model) + { + return new GenreModel + { + GenreId = model.GenreId, + Key = model.Key + }; + } +} \ No newline at end of file diff --git a/server/AniStream/DTO/ListModel.cs b/server/AniStream/DTO/ListModel.cs new file mode 100644 index 0000000..617fadd --- /dev/null +++ b/server/AniStream/DTO/ListModel.cs @@ -0,0 +1,30 @@ +namespace AniStream.API.DTO; + +public sealed class ListModel +{ + public required int ListId { get; set; } + + public required string Name { get; set; } +} + +public sealed class ListCreateModel +{ + public required string Name { get; set; } +} + +public sealed class ListUpdateModel +{ + public required string Name { get; set; } +} + +internal static class ListModelHelper +{ + public static ListModel ToDTO(this Models.ListModel model, string tenantId) + { + return new ListModel + { + ListId = model.ListId, + Name = model.Name + }; + } +} \ No newline at end of file diff --git a/server/AniStream/DTO/LoginModel.cs b/server/AniStream/DTO/LoginModel.cs new file mode 100644 index 0000000..f5c16c4 --- /dev/null +++ b/server/AniStream/DTO/LoginModel.cs @@ -0,0 +1,8 @@ +namespace AniStream.API.DTO; + +public sealed class LoginModel +{ + public required string Uuid { get; set; } + + public required string Password { get; set; } +} \ No newline at end of file diff --git a/server/AniStream/DTO/ProfileModel.cs b/server/AniStream/DTO/ProfileModel.cs new file mode 100644 index 0000000..c88a30d --- /dev/null +++ b/server/AniStream/DTO/ProfileModel.cs @@ -0,0 +1,81 @@ +namespace AniStream.API.DTO; + +public sealed class ProfileModel +{ + public required int ProfileId { get; set; } + + public required string Uuid { get; set; } + + public required string Name { get; set; } + + public required string BackgroundColor { get; set; } + + public required string Eye { get; set; } + + public required string Mouth { get; set; } + + public required string Theme { get; set; } + + public required string Lang { get; set; } + + public required bool TosAccepted { get; set; } + + public required bool SyncCatalog { get; set; } +} + +public sealed class ProfileCreateModel +{ + public required string Name { get; set; } + + public required string Password { get; set; } + + public required string PasswordSalt { get; set; } + + public required string BackgroundColor { get; set; } + + public required string Eye { get; set; } + + public required string Mouth { get; set; } + + public required string Theme { get; set; } + + public required string Lang { get; set; } +} + +public sealed class ProfileUpdateModel +{ + public required string? Name { get; set; } + + public required string? BackgroundColor { get; set; } + + public required string? Eye { get; set; } + + public required string? Mouth { get; set; } + + public required string? Theme { get; set; } + + public required string? Lang { get; set; } + + public required bool? TosAccepted { get; set; } +} + +internal static class ProfileModelHelper +{ + public static ProfileModel ToDTO(this Models.ProfileModel model) + { + return new ProfileModel + { + ProfileId = model.ProfileId, + Uuid = model.Uuid, + Name = model.Name, + BackgroundColor = model.BackgroundColor, + Eye = model.Eye, + Mouth = model.Mouth, + Theme = model.Theme, + Lang = model.Lang, + TosAccepted = model.TosAccepted, + SyncCatalog = model.SyncCatalog + }; + } +} + diff --git a/server/AniStream/DTO/SeasonModel.cs b/server/AniStream/DTO/SeasonModel.cs new file mode 100644 index 0000000..a1422d9 --- /dev/null +++ b/server/AniStream/DTO/SeasonModel.cs @@ -0,0 +1,30 @@ +namespace AniStream.API.DTO; + +public sealed class SeasonModel +{ + public required int SeasonId { get; set; } + + public required int SeriesId { get; set; } + + public required int SeasonNumber { get; set; } +} + +public sealed class SeasonCreateModel +{ + public required int SeriesId { get; set; } + + public required int SeasonNumber { get; set; } +} + +internal static class SeasonModelHelper +{ + public static SeasonModel ToDTO(this Models.SeasonModel model) + { + return new SeasonModel() + { + SeasonId = model.SeasonId, + SeriesId = model.SeriesId, + SeasonNumber = model.SeasonNumber + }; + } +} \ No newline at end of file diff --git a/server/AniStream/DTO/SeriesModel.cs b/server/AniStream/DTO/SeriesModel.cs new file mode 100644 index 0000000..7232193 --- /dev/null +++ b/server/AniStream/DTO/SeriesModel.cs @@ -0,0 +1,59 @@ +using AniStream.Models; + +namespace AniStream.API.DTO; + +public sealed class SeriesModel +{ + public required int SeriesId { get; set; } + + public required string Guid { get; set; } + + public required string Title { get; set; } + + public required string Description { get; set; } + + public required string? PreviewImage { get; set; } +} + +public sealed class SeriesFilterModel +{ + public required int Offset { get; set; } + + public required int Limit { get; set; } + + public required string? SearchText { get; set; } + + public required int[]? GenreIds { get; set; } +} + +public sealed class SeriesCreateModel +{ + public required string Guid { get; set; } + + public required string Title { get; set; } + + public required string Description { get; set; } + + public required string? PreviewImage { get; set; } +} + +public sealed class SeriesSyncModel +{ + public required bool RequiresSync { get; set; } + public required SyncJobStatus? Status { get; set; } +} + +internal static class SeriesModelHelper +{ + public static SeriesModel ToDTO(this Models.SeriesModel model) + { + return new SeriesModel + { + SeriesId = model.SeriesId, + Guid = model.Guid, + Title = model.Title, + Description = model.Description, + PreviewImage = model.PreviewImage + }; + } +} \ No newline at end of file diff --git a/server/AniStream/DTO/WatchTimeModel.cs b/server/AniStream/DTO/WatchTimeModel.cs new file mode 100644 index 0000000..266b4d4 --- /dev/null +++ b/server/AniStream/DTO/WatchTimeModel.cs @@ -0,0 +1,51 @@ +namespace AniStream.API.DTO; + +public sealed class WatchTimeModel +{ + public required int WatchtimeId { get; set; } + + public required int EpisodeId { get; set; } + + public required int PercentageWatched { get; set; } + + public required double StoppedTime { get; set; } + + public required string TenantId { get; set; } +} + +public sealed class WatchTimeCreateModel +{ + public required int EpisodeId { get; set; } + + public required int PercentageWatched { get; set; } + + public required double StoppedTime { get; set; } +} + +public sealed class WatchTimeUpdateModel +{ + public required int? PercentageWatched { get; set; } + + public required double? StoppedTime { get; set; } +} + + +public sealed class WatchTimeTotalModel +{ + public required int TotalProgression { get; set; } +} + +internal static class WatchTimeModelHelper +{ + public static WatchTimeModel ToDTO(this Models.WatchTimeModel model) + { + return new WatchTimeModel + { + WatchtimeId = model.WatchtimeId, + EpisodeId = model.EpisodeId, + PercentageWatched = model.PercentageWatched, + StoppedTime = model.StoppedTime, + TenantId = model.TenantId + }; + } +} \ No newline at end of file diff --git a/server/AniStream/Middelware/ProviderMiddelware.cs b/server/AniStream/Middelware/ProviderMiddelware.cs new file mode 100644 index 0000000..05d9324 --- /dev/null +++ b/server/AniStream/Middelware/ProviderMiddelware.cs @@ -0,0 +1,26 @@ +using AniStream.Contracts; + +namespace AniStream.API.Middelware; + +public class ProviderMiddelware +{ + private readonly RequestDelegate _next; + + public ProviderMiddelware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context, IProviderService providerService) + { + if (context.Request.RouteValues.TryGetValue("provider", out object? value)) + { + if (value is string provider) + { + providerService.SetActiveProvider(provider); + } + } + + await _next(context); + } +} \ No newline at end of file diff --git a/server/AniStream/Program.cs b/server/AniStream/Program.cs new file mode 100644 index 0000000..557fbee --- /dev/null +++ b/server/AniStream/Program.cs @@ -0,0 +1,155 @@ +using AniStream.API.Middelware; +using AniStream.API.Serivces; +using AniStream.API.Utils; +using AniStream.Contracts; +using AniStream.Services; +using AniStream.Shared; +using AniStream.Utils; +using Microsoft.AspNetCore.HttpLogging; +using Microsoft.AspNetCore.Mvc; +using Microsoft.OpenApi.Models; +using Scalar.AspNetCore; + +#if TESTING_ENABLED +using AniStream.Integration; +using Microsoft.AspNetCore.Authentication; +#else +using Microsoft.AspNetCore.Authentication.Cookies; +using AniStream.API.Controllers; +#endif + +namespace AniStream.API; + +public static class Program +{ + public static void Main(string[] args) + { + AppConfig.Initialize(); + + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + + builder.Services.AddHttpLogging(options => + { + options.LoggingFields = HttpLoggingFields.RequestMethod | + HttpLoggingFields.RequestPath | + HttpLoggingFields.ResponseStatusCode | + HttpLoggingFields.Duration; + }); + builder.Services.AddControllers() + .AddJsonOptions(options => options.JsonSerializerOptions.PropertyNamingPolicy = new SnakeCasePolicy()); + builder.Services.Configure(options => options.ModelMetadataDetailsProviders.Add(new EmptyStringEnabledDisplayMetadataProvider())); + +#if TESTING_ENABLED + builder.Services.AddAuthentication("Test") + .AddScheme("Test", _ => { }); +#else + builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.LoginPath = CredentialsController.LOGIN_ROUTE; + options.LogoutPath = CredentialsController.LOGOUT_ROUTE; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + + options.Events.OnRedirectToLogin = context => + { + if (context.Request.Path.StartsWithSegments("/api")) + { + context.Response.Clear(); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + } + + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; + }; + }); +#endif + + builder.Services.AddAuthorization(); + builder.Services.AddOpenApi(options => + { + options.AddDocumentTransformer((document, context, cancellationToken) => + { + document.Components ??= new(); + document.Components.SecuritySchemes ??= new Dictionary(); + + document.Components.SecuritySchemes["cookieAuth"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Cookie, + Name = ".AniStream.Cookies" + }; + + return Task.CompletedTask; + }); + options.AddSchemaTransformer((schema, context, cancellationToken) => + { + if (schema.Properties is null) + { + return Task.CompletedTask; + } + + Dictionary updated = new Dictionary(); + + foreach ((string? key, OpenApiSchema? propSchema) in schema.Properties) + { + string snake = SnakeCaseConvention.ToSnakeCase(key); + updated[snake] = propSchema; + } + + schema.Properties = updated; + return Task.CompletedTask; + }); + }); + builder.Services.AddHttpContextAccessor(); + builder.Services.AddEndpointsApiExplorer(); + SetupDependencyInjection(builder); + +#if TESTING_ENABLED + builder.AddTestingMode(new AutoLoader.Options + { + MigrationPath = AppConfig.CurrentConfig.MigrationPath + }); +#endif + + WebApplication app = builder.Build(); + + app.UseHttpLogging(); + +#if TESTING_ENABLED + app.UseMiddleware(); +#endif + app.UseMiddleware(); + + if (app.Environment.IsProduction()) + { + app.UseHttpsRedirection(); + } + + app.UseAuthentication(); + app.UseAuthorization(); + + app.MapControllers(); + app.MapOpenApi(); + app.MapScalarApiReference(options => { options.Title = "AniStream API"; }); + + app.Run(); + } + + private static void SetupDependencyInjection(WebApplicationBuilder builder) + { + // Proprietary services + builder.Services.AddScoped(); + + // BL Layer + AutoLoader.LoadServices(builder.Services, new AutoLoader.Options + { + DatabaseDriver = AppConfig.CurrentConfig.DatabaseDriver, + MigrationPath = AppConfig.CurrentConfig.MigrationPath, + DatabaseMetadataConnectionString = AppConfig.CurrentConfig.DatabaseMetadataConnectionString, + DatabaseProfileConnectionString = AppConfig.CurrentConfig.DatabaseProfileConnectionString, + AssetsPath = AppConfig.CurrentConfig.AssetsPath + }); + } +} \ No newline at end of file diff --git a/server/AniStream/Properties/disabled_launchSettings.json b/server/AniStream/Properties/disabled_launchSettings.json new file mode 100644 index 0000000..39364b9 --- /dev/null +++ b/server/AniStream/Properties/disabled_launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5281", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7102;http://localhost:5281", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/server/AniStream/Services/CredentialsService.cs b/server/AniStream/Services/CredentialsService.cs new file mode 100644 index 0000000..af406ca --- /dev/null +++ b/server/AniStream/Services/CredentialsService.cs @@ -0,0 +1,69 @@ +using System.Security.Cryptography; +using System.Text; +using AniStream.Contracts; +using AniStream.Models; +using Konscious.Security.Cryptography; + +namespace AniStream.API.Serivces; + +public sealed class CredentialsService : ICredentialsService +{ + private readonly IUserService _userService; + private readonly IHttpContextAccessor _context; + + public CredentialsService(IUserService userService, IHttpContextAccessor context) + { + _userService = userService; + _context = context; + } + + public async Task ValidateCredentials(string uuid, string password) + { + ProfileModel? profile = await _userService.GetProfile(uuid); + if (profile is null) + { + return null; + } + + if (!VerifyPassword(password, profile.Password, profile.PasswordSalt)) + { + return null; + } + + return profile; + } + + public Task GetCurrentUuid() + { + string? guid = _context.HttpContext?.User.Identity?.Name ?? null; + if (guid is null) + { + return Task.FromException(new Exception("Trying to access user in a non-authorized context")); + } + + return Task.FromResult(guid); + } + + private bool VerifyPassword(string input, string passwordB64, string saltB64) + { + if (input.Length == 0) + { + return false; + } + + byte[] salt = Convert.FromBase64String(saltB64); + byte[] password = Convert.FromBase64String(passwordB64); + + Argon2id argon2 = new Argon2id(Encoding.UTF8.GetBytes(input)) + { + Salt = salt, + DegreeOfParallelism = 4, + MemorySize = 1024 * 1024, + Iterations = 3 + }; + + byte[] computedHash = argon2.GetBytes(32); + + return CryptographicOperations.FixedTimeEquals(computedHash, password); + } +} \ No newline at end of file diff --git a/server/AniStream/Utils/ApiControllerBase.cs b/server/AniStream/Utils/ApiControllerBase.cs new file mode 100644 index 0000000..1a4d901 --- /dev/null +++ b/server/AniStream/Utils/ApiControllerBase.cs @@ -0,0 +1,35 @@ + +using Microsoft.AspNetCore.Mvc; + +namespace AniStream.API.Utils; + +public abstract class ApiControllerBase : ControllerBase +{ + protected ObjectResult Ok(string message) + { + return Ok(new + { + message + }); + } + + protected ObjectResult Unauthorized(string message) + { + return Problem( + title: "Unauthorized", + detail: message, + statusCode: StatusCodes.Status401Unauthorized + ); + } + + protected ObjectResult NotFound(string message) + { + return Problem( + title: "Not found", + detail: message, + statusCode: StatusCodes.Status404NotFound + ); + } + + +} \ No newline at end of file diff --git a/server/AniStream/Utils/EmptyStringEnabledDisplayMetadataProvider.cs b/server/AniStream/Utils/EmptyStringEnabledDisplayMetadataProvider.cs new file mode 100644 index 0000000..28b8a06 --- /dev/null +++ b/server/AniStream/Utils/EmptyStringEnabledDisplayMetadataProvider.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; + +namespace AniStream.API.Utils; + +public class EmptyStringEnabledDisplayMetadataProvider : IDisplayMetadataProvider +{ + public void CreateDisplayMetadata(DisplayMetadataProviderContext context) + { + if (context.Key.ModelType == typeof(string)) + { + context.DisplayMetadata.ConvertEmptyStringToNull = false; + } + } +} \ No newline at end of file diff --git a/server/AniStream/Utils/SnakeCasePolicy.cs b/server/AniStream/Utils/SnakeCasePolicy.cs new file mode 100644 index 0000000..a780d7c --- /dev/null +++ b/server/AniStream/Utils/SnakeCasePolicy.cs @@ -0,0 +1,17 @@ +using System.Text.Json; +using AniStream.Utils; + +namespace AniStream.API.Utils; + +public class SnakeCasePolicy : JsonNamingPolicy +{ + public override string ConvertName(string name) + { + if (string.IsNullOrEmpty(name)) + { + return name; + } + + return SnakeCaseConvention.ToSnakeCase(name); + } +} \ No newline at end of file diff --git a/server/AniStream/appsettings.Development.json b/server/AniStream/appsettings.Development.json new file mode 100644 index 0000000..fd513fc --- /dev/null +++ b/server/AniStream/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.HttpLogging": "Information" + } + } +} diff --git a/server/AniStream/appsettings.json b/server/AniStream/appsettings.json new file mode 100644 index 0000000..01ae915 --- /dev/null +++ b/server/AniStream/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.HttpLogging": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..49f5fb4 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,22 @@ +# Stage 1: Build +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /app + +COPY ./AniStream/AniStream.csproj ./AniStream/AniStream.csproj +COPY ./AniStream.Models/AniStream.Models.csproj ./AniStream.Models/AniStream.Models.csproj +COPY ./AniStream.Services/AniStream.Services.csproj ./AniStream.Services/AniStream.Services.csproj + +RUN dotnet restore "AniStream/AniStream.csproj" + +COPY . . + +WORKDIR /app/AniStream +RUN dotnet publish "AniStream.csproj" -c Release -o /app/publish /p:UseAppHost=false + +# Stage 2: Final Runtime +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final +WORKDIR /app + +COPY --from=build /app/publish . + +ENTRYPOINT ["dotnet", "AniStream.dll"] \ No newline at end of file diff --git a/src/utils/http.ts b/src/utils/http.ts deleted file mode 100644 index 45fb525..0000000 --- a/src/utils/http.ts +++ /dev/null @@ -1,64 +0,0 @@ -import {fetch} from "@tauri-apps/plugin-http"; - -export async function get(url: string, headers: [string, string][] = []): Promise { - const response: Response = await fetch(url, { - method: "GET", - headers: headers - }); - - if (!response.ok) { - throw `Request failed: HTTP response status ${response.status}`; - } - - return await response.text(); -} - -export async function getBinary(url: string, headers: [string, string][] = []): Promise { - const response: Response = await fetch(url, { - method: "GET", - headers: headers - }); - - if (!response.ok) { - throw `Request failed: HTTP response status ${response.status}`; - } - - const buffer: ArrayBuffer = await response.arrayBuffer(); - return new Uint8Array(buffer); -} - -export async function head(url: string, headers: [string, string][], followRedirect: boolean = true): Promise { - const response: Response = await fetch(url, { - method: "HEAD", - headers: headers, - redirect: followRedirect ? "follow" : undefined - }); - - if (!response.ok) { - throw `Request failed: HTTP response status ${response.status}`; - } - - return await response; -} - -export async function post(url: string, headers: [string, string][]): Promise { - const response: Response = await fetch(url, { - method: "POST", - headers: headers - }); - - if (!response.ok) { - throw `Request failed: HTTP response status ${response.status}`; - } - - return await response.text(); -} - -export async function runHealthz(healthzUrl: string): Promise { - try { - await get(healthzUrl); - return true; - } catch { - return false; - } -} \ No newline at end of file