Merge branch 'frontend-domain-model' into ci-docs-publish-fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 8m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 5m18s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m38s
Build & Release / Build Linux (push) Successful in 17m22s
Build & Release / Build Android (push) Successful in 22m44s
Build & Release / Create Release (push) Successful in 14s

This commit is contained in:
2026-07-23 22:18:59 +02:00
67 changed files with 998 additions and 589 deletions
+60 -19
View File
@@ -18,6 +18,7 @@
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@testing-library/svelte": "^5.3.1", "@testing-library/svelte": "^5.3.1",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "^4.0.16", "@vitest/ui": "^4.0.16",
"@wdio/cli": "^9.5.0", "@wdio/cli": "^9.5.0",
"@wdio/local-runner": "^9.5.0", "@wdio/local-runner": "^9.5.0",
@@ -30,7 +31,7 @@
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"vite": "^6.0.3", "vite": "^6.0.3",
"vitest": "^4.0.16", "vitest": ">=1.0.0 <5.0.0",
"webdriverio": "^9.5.0", "webdriverio": "^9.5.0",
}, },
}, },
@@ -46,10 +47,18 @@
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
"@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
"@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="],
@@ -348,6 +357,8 @@
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.10", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.10", "vitest": "4.1.10" }, "optionalPeers": ["@vitest/browser"] }, "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g=="],
"@vitest/expect": ["@vitest/expect@4.0.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA=="], "@vitest/expect": ["@vitest/expect@4.0.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA=="],
"@vitest/mocker": ["@vitest/mocker@4.0.16", "", { "dependencies": { "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg=="], "@vitest/mocker": ["@vitest/mocker@4.0.16", "", { "dependencies": { "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg=="],
@@ -362,7 +373,7 @@
"@vitest/ui": ["@vitest/ui@4.0.16", "", { "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", "flatted": "^3.3.3", "pathe": "^2.0.3", "sirv": "^3.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "vitest": "4.0.16" } }, "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg=="], "@vitest/ui": ["@vitest/ui@4.0.16", "", { "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", "flatted": "^3.3.3", "pathe": "^2.0.3", "sirv": "^3.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "vitest": "4.0.16" } }, "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg=="],
"@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="], "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
"@wdio/cli": ["@wdio/cli@9.23.2", "", { "dependencies": { "@vitest/snapshot": "^2.1.1", "@wdio/config": "9.23.2", "@wdio/globals": "9.23.0", "@wdio/logger": "9.18.0", "@wdio/protocols": "9.23.2", "@wdio/types": "9.23.2", "@wdio/utils": "9.23.2", "async-exit-hook": "^2.0.1", "chalk": "^5.4.1", "chokidar": "^4.0.0", "create-wdio": "9.21.0", "dotenv": "^17.2.0", "import-meta-resolve": "^4.0.0", "lodash.flattendeep": "^4.4.0", "lodash.pickby": "^4.6.0", "lodash.union": "^4.6.0", "read-pkg-up": "^10.0.0", "tsx": "^4.7.2", "webdriverio": "9.23.2", "yargs": "^17.7.2" }, "bin": { "wdio": "bin/wdio.js" } }, "sha512-D6KZGomfNmjFhSWYdfR7Ojik5qWEpPoR4g5LQPzbFwiii/RkTudLcMFcCO6s7HTMLDQDWryOStV2KK6KqrIF8A=="], "@wdio/cli": ["@wdio/cli@9.23.2", "", { "dependencies": { "@vitest/snapshot": "^2.1.1", "@wdio/config": "9.23.2", "@wdio/globals": "9.23.0", "@wdio/logger": "9.18.0", "@wdio/protocols": "9.23.2", "@wdio/types": "9.23.2", "@wdio/utils": "9.23.2", "async-exit-hook": "^2.0.1", "chalk": "^5.4.1", "chokidar": "^4.0.0", "create-wdio": "9.21.0", "dotenv": "^17.2.0", "import-meta-resolve": "^4.0.0", "lodash.flattendeep": "^4.4.0", "lodash.pickby": "^4.6.0", "lodash.union": "^4.6.0", "read-pkg-up": "^10.0.0", "tsx": "^4.7.2", "webdriverio": "9.23.2", "yargs": "^17.7.2" }, "bin": { "wdio": "bin/wdio.js" } }, "sha512-D6KZGomfNmjFhSWYdfR7Ojik5qWEpPoR4g5LQPzbFwiii/RkTudLcMFcCO6s7HTMLDQDWryOStV2KK6KqrIF8A=="],
@@ -422,6 +433,8 @@
"ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="], "async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="],
@@ -498,6 +511,8 @@
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
@@ -686,6 +701,8 @@
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
"htmlfy": ["htmlfy@0.8.1", "", {}, "sha512-xWROBw9+MEGwxpotll0h672KCaLrKKiCYzsyN8ZgL9cQbVumFnyvsk2JqiB9ELAV1GLj1GG/jxZUjV9OZZi/yQ=="], "htmlfy": ["htmlfy@0.8.1", "", {}, "sha512-xWROBw9+MEGwxpotll0h672KCaLrKKiCYzsyN8ZgL9cQbVumFnyvsk2JqiB9ELAV1GLj1GG/jxZUjV9OZZi/yQ=="],
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
@@ -738,6 +755,12 @@
"isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="], "isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="],
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
@@ -756,7 +779,7 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
@@ -828,6 +851,10 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
"mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="], "mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
@@ -1020,7 +1047,7 @@
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
"stream-buffers": ["stream-buffers@3.0.3", "", {}, "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw=="], "stream-buffers": ["stream-buffers@3.0.3", "", {}, "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw=="],
@@ -1042,7 +1069,7 @@
"strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="], "strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"svelte": ["svelte@5.48.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ=="], "svelte": ["svelte@5.48.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ=="],
@@ -1068,7 +1095,7 @@
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"tldts": ["tldts@7.0.19", "", { "dependencies": { "tldts-core": "^7.0.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA=="], "tldts": ["tldts@7.0.19", "", { "dependencies": { "tldts-core": "^7.0.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA=="],
@@ -1166,6 +1193,10 @@
"zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="], "zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="],
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
@@ -1190,10 +1221,24 @@
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@vitest/expect/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"@vitest/expect/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"@vitest/pretty-format/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"@vitest/runner/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"@vitest/snapshot/@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="], "@vitest/snapshot/@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="],
"@vitest/snapshot/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "@vitest/snapshot/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"@vitest/ui/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"@vitest/ui/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="],
"@wdio/reporter/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "@wdio/reporter/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -1260,6 +1305,8 @@
"mocha/glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="], "mocha/glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="],
"mocha/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"mocha/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], "mocha/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
"mocha/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], "mocha/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
@@ -1298,6 +1345,12 @@
"vitest/@vitest/snapshot": ["@vitest/snapshot@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA=="], "vitest/@vitest/snapshot": ["@vitest/snapshot@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA=="],
"vitest/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"vitest/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"wait-port/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "wait-port/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"wait-port/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], "wait-port/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
@@ -1328,7 +1381,7 @@
"@jest/types/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@jest/types/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@jest/types/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "@vitest/runner/@vitest/utils/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"@vitest/snapshot/@vitest/pretty-format/tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="], "@vitest/snapshot/@vitest/pretty-format/tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="],
@@ -1338,34 +1391,24 @@
"jest-diff/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "jest-diff/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"jest-diff/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"jest-diff/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "jest-diff/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"jest-matcher-utils/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "jest-matcher-utils/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"jest-matcher-utils/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"jest-matcher-utils/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "jest-matcher-utils/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"jest-message-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "jest-message-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"jest-message-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"jest-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "jest-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"jest-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"log-symbols/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "log-symbols/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"log-symbols/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"mocha/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "mocha/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"mocha/find-up/locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "mocha/find-up/locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
@@ -1432,8 +1475,6 @@
"wait-port/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "wait-port/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"wait-port/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"mocha/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "mocha/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"mocha/find-up/locate-path/p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], "mocha/find-up/locate-path/p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
+13 -4
View File
@@ -106,6 +106,8 @@ pub struct MergedMediaItem {
pub album_id: Option<String>, pub album_id: Option<String>,
pub duration: Option<f64>, pub duration: Option<f64>,
pub primary_image_tag: Option<String>, pub primary_image_tag: Option<String>,
/// Neutral image identifier — replaces `primary_image_tag` (same value).
pub image_id: Option<String>,
pub media_type: String, pub media_type: String,
} }
@@ -120,6 +122,7 @@ impl From<&crate::player::MediaItem> for MergedMediaItem {
album_id: item.album_id.clone(), album_id: item.album_id.clone(),
duration: item.duration, duration: item.duration,
primary_image_tag: item.primary_image_tag.clone(), primary_image_tag: item.primary_image_tag.clone(),
image_id: item.primary_image_tag.clone(),
media_type: match item.media_type { media_type: match item.media_type {
crate::player::MediaType::Audio => "audio".to_string(), crate::player::MediaType::Audio => "audio".to_string(),
crate::player::MediaType::Video => "video".to_string(), crate::player::MediaType::Video => "video".to_string(),
@@ -142,6 +145,7 @@ impl From<&crate::jellyfin::client::NowPlayingItem> for MergedMediaItem {
album_id: item.album_id.clone(), album_id: item.album_id.clone(),
duration: item.run_time_ticks.map(|ticks| ticks as f64 / 10_000_000.0), duration: item.run_time_ticks.map(|ticks| ticks as f64 / 10_000_000.0),
primary_image_tag: item.primary_image_tag.clone(), primary_image_tag: item.primary_image_tag.clone(),
image_id: item.primary_image_tag.clone(),
media_type: item media_type: item
.item_type .item_type
.clone() .clone()
@@ -361,10 +365,11 @@ pub(super) async fn create_media_item(
artist_items: None, // Not available from video-only request artist_items: None, // Not available from video-only request
artists: None, // Not available from video-only request artists: None, // Not available from video-only request
primary_image_tag: None, // Not available from video-only request primary_image_tag: None, // Not available from video-only request
item_type: None, // Not available from video-only request image_id: None,
playlist_id: None, // Not available from video-only request item_type: None, // Not available from video-only request
duration: None, // Not available from video-only request playlist_id: None, // Not available from video-only request
artwork_url: None, // Not available from video-only request duration: None, // Not available from video-only request
artwork_url: None, // Not available from video-only request
media_type: crate::player::MediaType::Video, // Video-only request media_type: crate::player::MediaType::Video, // Video-only request
source, source,
video_codec: Some(req.video_codec), video_codec: Some(req.video_codec),
@@ -595,6 +600,7 @@ pub async fn player_enter_background_audio(
artist_items: None, artist_items: None,
artists: None, artists: None,
primary_image_tag: item.primary_image_tag.clone(), primary_image_tag: item.primary_image_tag.clone(),
image_id: item.primary_image_tag.clone(),
item_type: None, item_type: None,
playlist_id: None, playlist_id: None,
// Carry the real duration so the lockscreen MediaSession can draw a scrubber. // Carry the real duration so the lockscreen MediaSession can draw a scrubber.
@@ -1771,6 +1777,7 @@ pub async fn player_play_album_track(
artist_items: track.artist_items.clone(), // For clickable artist links artist_items: track.artist_items.clone(), // For clickable artist links
artists: track.artists.clone(), // Fallback artist info artists: track.artists.clone(), // Fallback artist info
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
image_id: track.primary_image_tag.clone(),
item_type: Some(track.item_type.clone()), // Frontend compatibility item_type: Some(track.item_type.clone()), // Frontend compatibility
playlist_id: None, playlist_id: None,
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0), duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
@@ -1966,6 +1973,7 @@ pub async fn player_play_tracks(
artist_items: track.artist_items.clone(), // For clickable artist links artist_items: track.artist_items.clone(), // For clickable artist links
artists: track.artists.clone(), // Fallback artist info artists: track.artists.clone(), // Fallback artist info
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
image_id: track.primary_image_tag.clone(),
item_type: Some(track.item_type.clone()), // Frontend compatibility item_type: Some(track.item_type.clone()), // Frontend compatibility
playlist_id: None, // Set based on context below playlist_id: None, // Set based on context below
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0), duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
@@ -2428,6 +2436,7 @@ mod tests {
artist_items: None, artist_items: None,
artists: None, artists: None,
primary_image_tag: None, primary_image_tag: None,
image_id: None,
item_type: None, item_type: None,
playlist_id: None, playlist_id: None,
duration: None, duration: None,
+2
View File
@@ -211,6 +211,7 @@ pub async fn player_add_track_by_id(
artist_items: track.artist_items.clone(), // For clickable artist links artist_items: track.artist_items.clone(), // For clickable artist links
artists: track.artists.clone(), // Fallback artist info artists: track.artists.clone(), // Fallback artist info
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
image_id: track.primary_image_tag.clone(),
item_type: Some(track.item_type.clone()), // Frontend compatibility item_type: Some(track.item_type.clone()), // Frontend compatibility
playlist_id: None, playlist_id: None,
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0), duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
@@ -329,6 +330,7 @@ pub async fn player_add_tracks_by_ids(
artist_items: track.artist_items.clone(), // For clickable artist links artist_items: track.artist_items.clone(), // For clickable artist links
artists: track.artists.clone(), // Fallback artist info artists: track.artists.clone(), // Fallback artist info
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
image_id: track.primary_image_tag.clone(),
item_type: Some(track.item_type.clone()), // Frontend compatibility item_type: Some(track.item_type.clone()), // Frontend compatibility
playlist_id: None, playlist_id: None,
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0), duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
+7 -3
View File
@@ -579,8 +579,9 @@ pub async fn repository_report_playback_start(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
item_id: String, item_id: String,
position_ticks: i64, position_ms: i64,
) -> Result<(), String> { ) -> Result<(), String> {
let position_ticks = position_ms * 10_000;
let repo = manager.0.get(&handle).ok_or("Repository not found")?; let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref() repo.as_ref()
.report_playback_start(&item_id, position_ticks) .report_playback_start(&item_id, position_ticks)
@@ -595,8 +596,9 @@ pub async fn repository_report_playback_progress(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
item_id: String, item_id: String,
position_ticks: i64, position_ms: i64,
) -> Result<(), String> { ) -> Result<(), String> {
let position_ticks = position_ms * 10_000;
let repo = manager.0.get(&handle).ok_or("Repository not found")?; let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref() repo.as_ref()
.report_playback_progress(&item_id, position_ticks) .report_playback_progress(&item_id, position_ticks)
@@ -611,8 +613,10 @@ pub async fn repository_report_playback_stopped(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
item_id: String, item_id: String,
position_ticks: i64, position_ms: i64,
) -> Result<(), String> { ) -> Result<(), String> {
// Milliseconds across the boundary; the Jellyfin API wants ticks.
let position_ticks = position_ms * 10_000;
let repo = manager.0.get(&handle).ok_or("Repository not found")?; let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref() repo.as_ref()
.report_playback_stopped(&item_id, position_ticks) .report_playback_stopped(&item_id, position_ticks)
+17 -9
View File
@@ -583,7 +583,9 @@ pub async fn storage_delete_user(
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PlaybackProgress { pub struct PlaybackProgress {
pub item_id: String, pub item_id: String,
pub position_ticks: i64, /// Resume position in milliseconds. Stored as Jellyfin ticks in the DB;
/// converted here so the frontend never sees ticks.
pub position_ms: i64,
pub is_played: bool, pub is_played: bool,
pub is_favorite: bool, pub is_favorite: bool,
pub play_count: i32, pub play_count: i32,
@@ -597,8 +599,11 @@ pub async fn storage_update_playback_progress(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
user_id: String, user_id: String,
item_id: String, item_id: String,
position_ticks: i64, position_ms: i64,
) -> Result<(), String> { ) -> Result<(), String> {
// The frontend speaks milliseconds; ticks are a Jellyfin storage detail that
// stays on this side of the boundary. 10_000 ticks = 1 ms.
let position_ticks = position_ms * 10_000;
let db_service = { let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?; let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service()) Arc::new(database.service())
@@ -649,12 +654,14 @@ pub async fn storage_update_playback_context(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
user_id: String, user_id: String,
item_id: String, item_id: String,
position_ticks: i64, position_ms: i64,
context_type: Option<String>, context_type: Option<String>,
context_id: Option<String>, context_id: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
use crate::storage::db_service::{Query, QueryParam}; use crate::storage::db_service::{Query, QueryParam};
// Milliseconds in, Jellyfin ticks stored. 10_000 ticks = 1 ms.
let position_ticks = position_ms * 10_000;
let db_service = { let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?; let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service()) Arc::new(database.service())
@@ -857,9 +864,10 @@ pub async fn storage_get_playback_progress(
db_service db_service
.query_optional(query, |row| { .query_optional(query, |row| {
let position_ticks: i64 = row.get(1)?;
Ok(PlaybackProgress { Ok(PlaybackProgress {
item_id: row.get(0)?, item_id: row.get(0)?,
position_ticks: row.get(1)?, position_ms: position_ticks / 10_000,
is_played: row.get::<_, i32>(2)? != 0, is_played: row.get::<_, i32>(2)? != 0,
is_favorite: row.get::<_, i32>(3)? != 0, is_favorite: row.get::<_, i32>(3)? != 0,
play_count: row.get(4)?, play_count: row.get(4)?,
@@ -1495,7 +1503,7 @@ mod tests {
fn test_playback_progress_serialization() { fn test_playback_progress_serialization() {
let progress = PlaybackProgress { let progress = PlaybackProgress {
item_id: "item-123".to_string(), item_id: "item-123".to_string(),
position_ticks: 150_000_000, position_ms: 150_000_000,
is_played: true, is_played: true,
is_favorite: false, is_favorite: false,
play_count: 3, play_count: 3,
@@ -1512,7 +1520,7 @@ mod tests {
fn test_playback_progress_played_status() { fn test_playback_progress_played_status() {
let progress = PlaybackProgress { let progress = PlaybackProgress {
item_id: "item-456".to_string(), item_id: "item-456".to_string(),
position_ticks: 0, position_ms: 0,
is_played: true, is_played: true,
is_favorite: true, is_favorite: true,
play_count: 1, play_count: 1,
@@ -1530,7 +1538,7 @@ mod tests {
fn test_playback_progress_not_played() { fn test_playback_progress_not_played() {
let progress = PlaybackProgress { let progress = PlaybackProgress {
item_id: "item-789".to_string(), item_id: "item-789".to_string(),
position_ticks: 30_000_000, position_ms: 30_000_000,
is_played: false, is_played: false,
is_favorite: false, is_favorite: false,
play_count: 0, play_count: 0,
@@ -1600,7 +1608,7 @@ mod tests {
fn test_playback_progress_camel_case() { fn test_playback_progress_camel_case() {
let progress = PlaybackProgress { let progress = PlaybackProgress {
item_id: "i1".to_string(), item_id: "i1".to_string(),
position_ticks: 100, position_ms: 100,
is_played: true, is_played: true,
is_favorite: false, is_favorite: false,
play_count: 1, play_count: 1,
@@ -1609,7 +1617,7 @@ mod tests {
let json = serde_json::to_string(&progress).unwrap(); let json = serde_json::to_string(&progress).unwrap();
// Verify camelCase serialization // Verify camelCase serialization
assert!(json.contains("itemId")); assert!(json.contains("itemId"));
assert!(json.contains("positionTicks")); assert!(json.contains("positionMs"));
assert!(json.contains("isPlayed")); assert!(json.contains("isPlayed"));
assert!(json.contains("isFavorite")); assert!(json.contains("isFavorite"));
assert!(json.contains("playCount")); assert!(json.contains("playCount"));
+180
View File
@@ -0,0 +1,180 @@
//! Jellyfin → domain translation.
//!
//! The ONLY place Jellyfin's vocabulary touches the domain model. Adding a
//! second provider later means a sibling `from_<provider>.rs`; the domain types
//! and every consumer stay untouched.
//!
//! Spec: docs/specs/frontend-domain-model.md
use super::media::{MediaKind, StreamKind};
/// Classify a Jellyfin media-stream `Type` string into a neutral [`StreamKind`].
/// Total and panic-free.
pub fn stream_kind_from_jellyfin(stream_type: &str) -> StreamKind {
match stream_type {
"Audio" => StreamKind::Audio,
"Video" => StreamKind::Video,
"Subtitle" => StreamKind::Subtitle,
_ => StreamKind::Other,
}
}
/// Jellyfin ticks per second (10 million). A tick is 100 ns.
/// The frontend must never see ticks — this is where they die.
const TICKS_PER_MILLISECOND: i64 = 10_000;
/// Convert a Jellyfin `RunTimeTicks` value to milliseconds.
///
/// Domain durations are milliseconds; ticks are a Jellyfin unit and stop here.
pub fn ticks_to_ms(ticks: i64) -> i64 {
ticks / TICKS_PER_MILLISECOND
}
/// Classify a Jellyfin `Type` string into a neutral [`MediaKind`].
///
/// **Total and panic-free**: any unrecognised string maps to [`MediaKind::Other`]
/// rather than failing. `is_folder` disambiguates the one Jellyfin type
/// (`ChannelFolderItem`) whose kind depends on whether it is a container.
///
/// The recognised set is every `item_type` the frontend audit found in use
/// (docs/specs/frontend-domain-model.md), plus the common cast/crew person
/// subtypes Jellyfin returns in `People[].Type`.
pub fn kind_from_jellyfin(item_type: &str, is_folder: bool) -> MediaKind {
match item_type {
// Music
"Audio" | "MusicVideo" => MediaKind::Track,
"MusicAlbum" => MediaKind::Album,
"MusicArtist" | "AlbumArtist" => MediaKind::Artist,
"Playlist" => MediaKind::Playlist,
// Video
"Movie" => MediaKind::Movie,
"Series" => MediaKind::Series,
"Season" => MediaKind::Season,
"Episode" => MediaKind::Episode,
// A bare video leaf with no richer classification.
"Video" => MediaKind::Movie,
// Cast / crew — Jellyfin uses both a "Person" item type and role-typed
// people (Actor/Director/Writer/Composer/…) in People[].Type.
"Person" | "Actor" | "Director" | "Writer" | "Composer" | "GuestStar" | "Producer" => {
MediaKind::Person
}
// A live TV channel: playable, but a non-seekable live stream.
"TvChannel" | "LiveTvChannel" => MediaKind::LiveChannel,
// A bare channel is a container the user drills into.
"Channel" => MediaKind::Channel,
// Containers
"Folder" | "CollectionFolder" | "UserView" | "BoxSet" => MediaKind::Folder,
// ChannelFolderItem is a container when it is a folder, else a playable
// channel leaf (distinct kind so the UI can route it to playback).
"ChannelFolderItem" => {
if is_folder {
MediaKind::Folder
} else {
MediaKind::ChannelItem
}
}
// Unknown → safe sink. Never panics.
_ => {
if is_folder {
MediaKind::Folder
} else {
MediaKind::Other
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ticks_convert_to_milliseconds() {
// 1 second = 10,000,000 ticks = 1000 ms
assert_eq!(ticks_to_ms(10_000_000), 1000);
// 90.5 s
assert_eq!(ticks_to_ms(905_000_000), 90_500);
assert_eq!(ticks_to_ms(0), 0);
// Sub-millisecond truncates toward zero, not panics.
assert_eq!(ticks_to_ms(9_999), 0);
}
#[test]
fn music_types_map() {
assert_eq!(kind_from_jellyfin("Audio", false), MediaKind::Track);
assert_eq!(kind_from_jellyfin("MusicAlbum", true), MediaKind::Album);
assert_eq!(kind_from_jellyfin("MusicArtist", true), MediaKind::Artist);
assert_eq!(kind_from_jellyfin("Playlist", true), MediaKind::Playlist);
}
#[test]
fn video_types_map() {
assert_eq!(kind_from_jellyfin("Movie", false), MediaKind::Movie);
assert_eq!(kind_from_jellyfin("Series", true), MediaKind::Series);
assert_eq!(kind_from_jellyfin("Season", true), MediaKind::Season);
assert_eq!(kind_from_jellyfin("Episode", false), MediaKind::Episode);
assert_eq!(kind_from_jellyfin("Video", false), MediaKind::Movie);
}
#[test]
fn person_and_role_types_map_to_person() {
for t in ["Person", "Actor", "Director", "Writer", "Composer"] {
assert_eq!(kind_from_jellyfin(t, false), MediaKind::Person, "{t}");
}
}
#[test]
fn channel_and_container_types_map() {
assert_eq!(
kind_from_jellyfin("TvChannel", false),
MediaKind::LiveChannel
);
assert_eq!(kind_from_jellyfin("Channel", false), MediaKind::Channel);
assert_eq!(
kind_from_jellyfin("CollectionFolder", true),
MediaKind::Folder
);
assert_eq!(kind_from_jellyfin("BoxSet", true), MediaKind::Folder);
}
#[test]
fn channel_folder_item_disambiguates_on_is_folder() {
assert_eq!(
kind_from_jellyfin("ChannelFolderItem", true),
MediaKind::Folder
);
assert_eq!(
kind_from_jellyfin("ChannelFolderItem", false),
MediaKind::ChannelItem
);
}
#[test]
fn stream_kinds_map() {
assert_eq!(stream_kind_from_jellyfin("Audio"), StreamKind::Audio);
assert_eq!(stream_kind_from_jellyfin("Video"), StreamKind::Video);
assert_eq!(stream_kind_from_jellyfin("Subtitle"), StreamKind::Subtitle);
assert_eq!(
stream_kind_from_jellyfin("EmbeddedImage"),
StreamKind::Other
);
assert_eq!(stream_kind_from_jellyfin(""), StreamKind::Other);
}
#[test]
fn unknown_type_never_panics_and_falls_back() {
// The whole point: garbage in, safe kind out, no panic.
assert_eq!(kind_from_jellyfin("Epis0de", false), MediaKind::Other);
assert_eq!(kind_from_jellyfin("", false), MediaKind::Other);
assert_eq!(
kind_from_jellyfin("SomeFutureType", true),
MediaKind::Folder
);
assert_eq!(kind_from_jellyfin("🎵unicode", false), MediaKind::Other);
}
}
+91
View File
@@ -0,0 +1,91 @@
//! Canonical, provider-neutral media domain model.
//!
//! This is the *single source of truth* for what a media item is across the
//! whole app. Rust (repositories, player, downloads) uses these types directly;
//! the frontend consumes the tauri-specta-generated projection in
//! `src/lib/api/bindings.ts`. There is no second hand-written copy in either
//! language, so the model cannot drift.
//!
//! No provider (Jellyfin) vocabulary belongs in this file. Translation from a
//! provider's wire shape lives beside it in `from_jellyfin.rs` and is the only
//! place provider terms touch the domain type.
//!
//! Spec: docs/specs/frontend-domain-model.md
use serde::{Deserialize, Serialize};
/// The kind of a media item — provider-neutral classification.
///
/// Replaces the stringly-typed `item_type` that carried Jellyfin's vocabulary
/// (`"Audio"`, `"MusicAlbum"`, …) across the boundary. A closed enum means a
/// typo or an unhandled kind is a compile error on the frontend, not a silent
/// runtime miss across ~127 comparison sites.
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum MediaKind {
// Music
Track,
Album,
Artist,
Playlist,
// Video
Movie,
Series,
Season,
Episode,
// Cast/crew
Person,
// Containers / live TV
/// A channel *container* the user drills into (Jellyfin `Channel`).
Channel,
Folder,
/// A live TV channel — playable, but a live stream with no seekable
/// timeline (no resume/seek). Jellyfin `TvChannel`/`LiveTvChannel`.
LiveChannel,
/// A playable leaf inside a channel (Jellyfin `ChannelFolderItem` that is
/// not itself a folder) — e.g. a plugin-channel VOD item that has no
/// dedicated item type but carries its own media streams. Playable and
/// seekable, unlike `LiveChannel`. Distinct from `Channel` (the container)
/// and from `Other` so the UI can route it to playback.
ChannelItem,
/// A kind we do not model explicitly. Reached only for provider item types
/// that map to nothing meaningful; consumers treat it like an opaque
/// container. The mapping must be *total* — it never panics — so this is the
/// safe sink for unknown strings. Also the `Default`, so a defaulted
/// `MediaItem` (see the dual-carry migration) is inert rather than a lie.
#[default]
Other,
}
/// The kind of a media stream within an item (audio track, video track,
/// subtitle, …) — provider-neutral, replacing the stringly Jellyfin stream type.
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum StreamKind {
Audio,
Video,
Subtitle,
/// Any stream kind we do not model explicitly (e.g. embedded image, data).
#[default]
Other,
}
impl MediaKind {
/// True for kinds that are containers/collections rather than playable leaves.
/// Presentation-neutral helper the backend can use for e.g. drill-vs-play.
// Consumed by later migration phases (drill-vs-play routing); kept now so the
// domain surface is complete alongside the type it describes.
#[allow(dead_code)]
pub fn is_container(self) -> bool {
matches!(
self,
MediaKind::Album
| MediaKind::Artist
| MediaKind::Series
| MediaKind::Season
| MediaKind::Playlist
| MediaKind::Channel
| MediaKind::Folder
)
}
}
+10
View File
@@ -0,0 +1,10 @@
//! Canonical, provider-neutral domain model — the single source of truth for
//! the app's core data shapes, shared with the frontend via generated bindings.
//!
//! Spec: docs/specs/frontend-domain-model.md
pub mod from_jellyfin;
pub mod media;
pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
pub use media::{MediaKind, StreamKind};
+1
View File
@@ -2,6 +2,7 @@ mod auth;
mod commands; mod commands;
mod connectivity; mod connectivity;
mod credentials; mod credentials;
mod domain;
mod download; mod download;
mod jellyfin; mod jellyfin;
mod playback_mode; mod playback_mode;
+2
View File
@@ -948,6 +948,7 @@ mod tests {
artist_items: None, artist_items: None,
artists: Some(vec!["Test Artist".to_string()]), artists: Some(vec!["Test Artist".to_string()]),
primary_image_tag: None, primary_image_tag: None,
image_id: None,
item_type: Some("Audio".to_string()), item_type: Some("Audio".to_string()),
playlist_id: None, playlist_id: None,
duration: Some(180.0), duration: Some(180.0),
@@ -979,6 +980,7 @@ mod tests {
artist_items: None, artist_items: None,
artists: Some(vec!["Test Artist".to_string()]), artists: Some(vec!["Test Artist".to_string()]),
primary_image_tag: None, primary_image_tag: None,
image_id: None,
item_type: Some("Audio".to_string()), item_type: Some("Audio".to_string()),
playlist_id: None, playlist_id: None,
duration: Some(180.0), duration: Some(180.0),
+3
View File
@@ -380,6 +380,7 @@ mod tests {
artist_items: None, artist_items: None,
artists: Some(vec!["Test Artist".to_string()]), artists: Some(vec!["Test Artist".to_string()]),
primary_image_tag: None, primary_image_tag: None,
image_id: None,
item_type: Some("Audio".to_string()), item_type: Some("Audio".to_string()),
playlist_id: None, playlist_id: None,
duration: Some(180.0), duration: Some(180.0),
@@ -438,6 +439,7 @@ mod tests {
artist_items: None, artist_items: None,
artists: Some(vec!["Test Artist".to_string()]), artists: Some(vec!["Test Artist".to_string()]),
primary_image_tag: None, primary_image_tag: None,
image_id: None,
item_type: Some("Audio".to_string()), item_type: Some("Audio".to_string()),
playlist_id: None, playlist_id: None,
duration: Some(180.0), duration: Some(180.0),
@@ -490,6 +492,7 @@ mod tests {
artist_items: None, artist_items: None,
artists: Some(vec!["Test Artist".to_string()]), artists: Some(vec!["Test Artist".to_string()]),
primary_image_tag: None, primary_image_tag: None,
image_id: None,
item_type: Some("Audio".to_string()), item_type: Some("Audio".to_string()),
playlist_id: None, playlist_id: None,
duration: Some(180.0), duration: Some(180.0),
+14 -1
View File
@@ -67,9 +67,16 @@ pub struct MediaItem {
/// Artists as array of strings (fallback when artist_items not available) /// Artists as array of strings (fallback when artist_items not available)
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub artists: Option<Vec<String>>, pub artists: Option<Vec<String>>,
/// Primary image tag for artwork /// Primary image tag for artwork.
///
/// Legacy Jellyfin name; being replaced by `image_id` (same value). Dual-carried
/// while the frontend migrates (docs/specs/frontend-domain-model.md).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_image_tag: Option<String>, pub primary_image_tag: Option<String>,
/// Neutral image identifier the frontend resolves to a URL — replaces
/// `primary_image_tag`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_id: Option<String>,
/// Item type (Audio, Movie, Episode, etc.) /// Item type (Audio, Movie, Episode, etc.)
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
pub item_type: Option<String>, pub item_type: Option<String>,
@@ -345,6 +352,7 @@ mod tests {
artist_items: None, artist_items: None,
artists: None, artists: None,
primary_image_tag: None, primary_image_tag: None,
image_id: None,
item_type: None, item_type: None,
playlist_id: None, playlist_id: None,
duration: None, duration: None,
@@ -380,6 +388,7 @@ mod tests {
artist_items: None, artist_items: None,
artists: None, artists: None,
primary_image_tag: None, primary_image_tag: None,
image_id: None,
item_type: None, item_type: None,
playlist_id: None, playlist_id: None,
duration: None, duration: None,
@@ -414,6 +423,7 @@ mod tests {
artist_items: None, artist_items: None,
artists: None, artists: None,
primary_image_tag: None, primary_image_tag: None,
image_id: None,
item_type: None, item_type: None,
playlist_id: None, playlist_id: None,
duration: None, duration: None,
@@ -448,6 +458,7 @@ mod tests {
artist_items: None, artist_items: None,
artists: None, artists: None,
primary_image_tag: None, primary_image_tag: None,
image_id: None,
item_type: None, item_type: None,
playlist_id: None, playlist_id: None,
duration: None, duration: None,
@@ -489,6 +500,7 @@ mod tests {
artist_items: None, artist_items: None,
artists: None, artists: None,
primary_image_tag: None, primary_image_tag: None,
image_id: None,
item_type: None, item_type: None,
playlist_id: None, playlist_id: None,
duration: None, duration: None,
@@ -523,6 +535,7 @@ mod tests {
artist_items: None, artist_items: None,
artists: None, artists: None,
primary_image_tag: None, primary_image_tag: None,
image_id: None,
item_type: Some("Movie".to_string()), item_type: Some("Movie".to_string()),
playlist_id: None, playlist_id: None,
duration: Some(120.0), duration: Some(120.0),
+4
View File
@@ -1397,6 +1397,7 @@ mod tests {
artist_items: None, artist_items: None,
artists: Some(vec!["Test Artist".to_string()]), artists: Some(vec!["Test Artist".to_string()]),
primary_image_tag: None, primary_image_tag: None,
image_id: None,
item_type: Some("Audio".to_string()), item_type: Some("Audio".to_string()),
playlist_id: None, playlist_id: None,
duration: Some(180.0), duration: Some(180.0),
@@ -2177,6 +2178,7 @@ mod tests {
id: id.to_string(), id: id.to_string(),
name: format!("Episode {}", index), name: format!("Episode {}", index),
item_type: "Episode".to_string(), item_type: "Episode".to_string(),
kind: crate::domain::MediaKind::Episode,
is_folder: false, is_folder: false,
server_id: "server".to_string(), server_id: "server".to_string(),
parent_id: Some("season1".to_string()), parent_id: Some("season1".to_string()),
@@ -2184,11 +2186,13 @@ mod tests {
overview: None, overview: None,
genres: None, genres: None,
runtime_ticks: None, runtime_ticks: None,
duration_ms: None,
production_year: None, production_year: None,
premiere_date: None, premiere_date: None,
community_rating: None, community_rating: None,
official_rating: None, official_rating: None,
primary_image_tag: None, primary_image_tag: None,
image_id: None,
backdrop_image_tags: None, backdrop_image_tags: None,
parent_backdrop_image_tags: None, parent_backdrop_image_tags: None,
album_id: None, album_id: None,
+1
View File
@@ -551,6 +551,7 @@ mod tests {
artist_items: None, artist_items: None,
artists: Some(vec!["Artist".to_string()]), artists: Some(vec!["Artist".to_string()]),
primary_image_tag: None, primary_image_tag: None,
image_id: None,
item_type: Some("Audio".to_string()), item_type: Some("Audio".to_string()),
playlist_id: None, playlist_id: None,
duration: Some(180.0), duration: Some(180.0),
+2
View File
@@ -242,6 +242,7 @@ mod tests {
artist_items: None, artist_items: None,
artists: Some(vec!["Test Artist".to_string()]), artists: Some(vec!["Test Artist".to_string()]),
primary_image_tag: None, primary_image_tag: None,
image_id: None,
item_type: Some("Audio".to_string()), item_type: Some("Audio".to_string()),
playlist_id: None, playlist_id: None,
duration: Some(180.0), duration: Some(180.0),
@@ -272,6 +273,7 @@ mod tests {
artist_items: None, artist_items: None,
artists: None, artists: None,
primary_image_tag: None, primary_image_tag: None,
image_id: None,
item_type: Some("Movie".to_string()), item_type: Some("Movie".to_string()),
playlist_id: None, playlist_id: None,
duration: Some(7200.0), duration: Some(7200.0),
+1
View File
@@ -323,6 +323,7 @@ mod tests {
artist_items: None, artist_items: None,
artists: None, artists: None,
primary_image_tag: None, primary_image_tag: None,
image_id: None,
item_type: Some("Video".to_string()), item_type: Some("Video".to_string()),
playlist_id: None, playlist_id: None,
duration: Some(100.0), duration: Some(100.0),
+3
View File
@@ -1422,6 +1422,7 @@ mod tests {
id: id.to_string(), id: id.to_string(),
name: name.to_string(), name: name.to_string(),
item_type: "Movie".to_string(), item_type: "Movie".to_string(),
kind: crate::domain::MediaKind::Movie,
is_folder: false, is_folder: false,
server_id: "test-server".to_string(), server_id: "test-server".to_string(),
parent_id: Some("parent-123".to_string()), parent_id: Some("parent-123".to_string()),
@@ -1429,11 +1430,13 @@ mod tests {
overview: Some("Test overview".to_string()), overview: Some("Test overview".to_string()),
genres: Some(vec!["Action".to_string(), "Adventure".to_string()]), genres: Some(vec!["Action".to_string(), "Adventure".to_string()]),
runtime_ticks: Some(7200000000), runtime_ticks: Some(7200000000),
duration_ms: Some(720000),
production_year: Some(2024), production_year: Some(2024),
premiere_date: None, premiere_date: None,
community_rating: Some(8.5), community_rating: Some(8.5),
official_rating: Some("PG-13".to_string()), official_rating: Some("PG-13".to_string()),
primary_image_tag: Some("image-tag-123".to_string()), primary_image_tag: Some("image-tag-123".to_string()),
image_id: Some("image-tag-123".to_string()),
backdrop_image_tags: Some(vec!["backdrop-1".to_string()]), backdrop_image_tags: Some(vec!["backdrop-1".to_string()]),
parent_backdrop_image_tags: None, parent_backdrop_image_tags: None,
album_id: None, album_id: None,
+16 -3
View File
@@ -68,10 +68,13 @@ impl OfflineRepository {
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok()) .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.unwrap_or_default(); .unwrap_or_default();
let kind = crate::domain::kind_from_jellyfin(&item.item_type, item.is_folder);
MediaItem { MediaItem {
id: item.id.clone(), id: item.id.clone(),
name: item.name, name: item.name,
item_type: item.item_type, item_type: item.item_type,
kind,
is_folder: item.is_folder, is_folder: item.is_folder,
server_id: item.server_id, server_id: item.server_id,
parent_id: item.parent_id, parent_id: item.parent_id,
@@ -82,11 +85,13 @@ impl OfflineRepository {
.as_ref() .as_ref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok()), .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok()),
runtime_ticks: item.runtime_ticks, runtime_ticks: item.runtime_ticks,
duration_ms: item.runtime_ticks.map(crate::domain::ticks_to_ms),
production_year: item.production_year, production_year: item.production_year,
premiere_date: item.premiere_date, premiere_date: item.premiere_date,
community_rating: item.community_rating, community_rating: item.community_rating,
official_rating: item.official_rating, official_rating: item.official_rating,
primary_image_tag: item.primary_image_tag, primary_image_tag: item.primary_image_tag.clone(),
image_id: item.primary_image_tag,
backdrop_image_tags: item.backdrop_image_tags, backdrop_image_tags: item.backdrop_image_tags,
parent_backdrop_image_tags: item.parent_backdrop_image_tags, parent_backdrop_image_tags: item.parent_backdrop_image_tags,
album_id: item.album_id, album_id: item.album_id,
@@ -120,8 +125,10 @@ impl OfflineRepository {
self.db_service self.db_service
.query_optional(query, |row| { .query_optional(query, |row| {
let playback_position_ticks: Option<i64> = row.get(0).ok();
Ok(UserData { Ok(UserData {
playback_position_ticks: row.get(0).ok(), playback_position_ticks,
playback_position_ms: playback_position_ticks.map(crate::domain::ticks_to_ms),
is_played: row.get::<_, Option<i32>>(1).ok().flatten().map(|v| v != 0), is_played: row.get::<_, Option<i32>>(1).ok().flatten().map(|v| v != 0),
is_favorite: row.get::<_, Option<i32>>(2).ok().flatten().map(|v| v != 0), is_favorite: row.get::<_, Option<i32>>(2).ok().flatten().map(|v| v != 0),
play_count: row.get(3).ok(), play_count: row.get(3).ok(),
@@ -1599,6 +1606,7 @@ impl MediaRepository for OfflineRepository {
id: person_data.0, id: person_data.0,
name: person_data.1, name: person_data.1,
item_type: "Person".to_string(), item_type: "Person".to_string(),
kind: crate::domain::MediaKind::Person,
is_folder: false, is_folder: false,
server_id: self.server_id.clone(), server_id: self.server_id.clone(),
parent_id: None, parent_id: None,
@@ -1606,11 +1614,13 @@ impl MediaRepository for OfflineRepository {
overview: person_data.2, overview: person_data.2,
genres: None, genres: None,
runtime_ticks: None, runtime_ticks: None,
duration_ms: None,
production_year: None, production_year: None,
premiere_date: None, premiere_date: None,
community_rating: None, community_rating: None,
official_rating: None, official_rating: None,
primary_image_tag: person_data.3, primary_image_tag: person_data.3.clone(),
image_id: person_data.3,
backdrop_image_tags: None, backdrop_image_tags: None,
parent_backdrop_image_tags: None, parent_backdrop_image_tags: None,
album_id: None, album_id: None,
@@ -2100,6 +2110,7 @@ mod tests {
id: id.to_string(), id: id.to_string(),
name: name.to_string(), name: name.to_string(),
item_type: "Audio".to_string(), item_type: "Audio".to_string(),
kind: crate::domain::MediaKind::Track,
is_folder: false, is_folder: false,
server_id: "test-server".to_string(), server_id: "test-server".to_string(),
parent_id: parent_id.map(|s| s.to_string()), parent_id: parent_id.map(|s| s.to_string()),
@@ -2107,11 +2118,13 @@ mod tests {
overview: None, overview: None,
genres: None, genres: None,
runtime_ticks: None, runtime_ticks: None,
duration_ms: None,
production_year: None, production_year: None,
premiere_date: None, premiere_date: None,
community_rating: None, community_rating: None,
official_rating: None, official_rating: None,
primary_image_tag: None, primary_image_tag: None,
image_id: None,
backdrop_image_tags: None, backdrop_image_tags: None,
parent_backdrop_image_tags: None, parent_backdrop_image_tags: None,
album_id: None, album_id: None,
+10 -1
View File
@@ -619,10 +619,13 @@ impl JellyfinItem {
let primary_tag = self.image_tags.as_ref().and_then(|tags| tags.primary()); let primary_tag = self.image_tags.as_ref().and_then(|tags| tags.primary());
let backdrop_tags = self.backdrop_image_tags; let backdrop_tags = self.backdrop_image_tags;
let kind = crate::domain::kind_from_jellyfin(&self.item_type, self.is_folder);
MediaItem { MediaItem {
id: self.id, id: self.id,
name: self.name, name: self.name,
item_type: self.item_type, item_type: self.item_type,
kind,
is_folder: self.is_folder, is_folder: self.is_folder,
server_id, server_id,
parent_id: self.parent_id, parent_id: self.parent_id,
@@ -634,7 +637,9 @@ impl JellyfinItem {
community_rating: self.community_rating, community_rating: self.community_rating,
official_rating: self.official_rating, official_rating: self.official_rating,
runtime_ticks: self.run_time_ticks, runtime_ticks: self.run_time_ticks,
primary_image_tag: primary_tag, duration_ms: self.run_time_ticks.map(crate::domain::ticks_to_ms),
primary_image_tag: primary_tag.clone(),
image_id: primary_tag,
backdrop_image_tags: backdrop_tags, backdrop_image_tags: backdrop_tags,
parent_backdrop_image_tags: self.parent_backdrop_image_tags, parent_backdrop_image_tags: self.parent_backdrop_image_tags,
album_id: self.album_id, album_id: self.album_id,
@@ -653,6 +658,7 @@ impl JellyfinItem {
streams streams
.into_iter() .into_iter()
.map(|s| crate::repository::types::MediaStream { .map(|s| crate::repository::types::MediaStream {
kind: crate::domain::stream_kind_from_jellyfin(&s.stream_type),
stream_type: s.stream_type, stream_type: s.stream_type,
codec: s.codec, codec: s.codec,
language: s.language, language: s.language,
@@ -923,6 +929,7 @@ impl MediaRepository for OnlineRepository {
.clone() .clone()
.unwrap_or_else(|| "Unknown Album".to_string()), .unwrap_or_else(|| "Unknown Album".to_string()),
item_type: "MusicAlbum".to_string(), item_type: "MusicAlbum".to_string(),
kind: crate::domain::MediaKind::Album,
is_folder: true, is_folder: true,
server_id: first_track.server_id.clone(), server_id: first_track.server_id.clone(),
parent_id: None, parent_id: None,
@@ -934,7 +941,9 @@ impl MediaRepository for OnlineRepository {
community_rating: None, community_rating: None,
official_rating: None, official_rating: None,
runtime_ticks: None, runtime_ticks: None,
duration_ms: None,
primary_image_tag: first_track.primary_image_tag.clone(), primary_image_tag: first_track.primary_image_tag.clone(),
image_id: first_track.primary_image_tag.clone(),
backdrop_image_tags: None, backdrop_image_tags: None,
parent_backdrop_image_tags: None, parent_backdrop_image_tags: None,
album_id: None, album_id: None,
+48 -1
View File
@@ -42,8 +42,16 @@ pub struct Library {
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct UserData { pub struct UserData {
/// Legacy Jellyfin resume position in ticks. Being replaced by
/// `playback_position_ms`; dual-carried while the frontend migrates
/// (docs/specs/frontend-domain-model.md). New code should read the ms field.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub playback_position_ticks: Option<i64>, pub playback_position_ticks: Option<i64>,
/// Resume position in milliseconds — the neutral replacement for
/// `playback_position_ticks`. Populated from ticks by the mapping; the
/// frontend never divides ticks itself.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub playback_position_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub is_played: Option<bool>, pub is_played: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -97,13 +105,24 @@ pub struct Person {
} }
/// Media item /// Media item
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct MediaItem { pub struct MediaItem {
pub id: String, pub id: String,
pub name: String, pub name: String,
/// Legacy Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, …).
///
/// Dual-carry migration (docs/specs/frontend-domain-model.md): `kind` below
/// is the neutral replacement. This field stays while the frontend migrates
/// off it, then is removed in a later phase. New Rust code should read
/// `kind`, not this.
#[serde(rename = "type")] #[serde(rename = "type")]
pub item_type: String, pub item_type: String,
/// Provider-neutral classification — the replacement for `item_type`.
/// Populated by the Jellyfin mapping; defaults to `Other` for the handful of
/// construction sites that have not been migrated yet.
#[serde(default)]
pub kind: crate::domain::MediaKind,
/// Whether this item is a folder/container (vs a playable leaf). Used to /// Whether this item is a folder/container (vs a playable leaf). Used to
/// decide whether a channel item drills into a list or plays directly. /// decide whether a channel item drills into a list or plays directly.
#[serde(default)] #[serde(default)]
@@ -127,11 +146,25 @@ pub struct MediaItem {
pub community_rating: Option<f64>, pub community_rating: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub official_rating: Option<String>, pub official_rating: Option<String>,
/// Legacy Jellyfin duration in ticks (100 ns units). Being replaced by
/// `duration_ms`; dual-carried while the frontend migrates
/// (docs/specs/frontend-domain-model.md). New code should read `duration_ms`.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "runTimeTicks")] #[serde(rename = "runTimeTicks")]
pub runtime_ticks: Option<i64>, pub runtime_ticks: Option<i64>,
/// Duration in milliseconds — the neutral replacement for `runtime_ticks`.
/// Ticks never reach the frontend; this does.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<i64>,
/// Legacy Jellyfin primary image tag. Being replaced by `image_id`;
/// dual-carried while the frontend migrates. New code should read `image_id`.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub primary_image_tag: Option<String>, pub primary_image_tag: Option<String>,
/// Neutral image identifier the frontend resolves to a URL via the image
/// command — the replacement for `primary_image_tag`. Same value today
/// (Jellyfin's tag is the id); the rename removes the provider term.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub backdrop_image_tags: Option<Vec<String>>, pub backdrop_image_tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -172,8 +205,13 @@ pub struct MediaItem {
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct MediaStream { pub struct MediaStream {
/// Legacy Jellyfin stream type string ("Audio"/"Video"/"Subtitle"). Being
/// replaced by `kind`; dual-carried while the frontend migrates.
#[serde(rename = "type")] #[serde(rename = "type")]
pub stream_type: String, pub stream_type: String,
/// Provider-neutral stream classification — replaces `stream_type`.
#[serde(default)]
pub kind: crate::domain::StreamKind,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub codec: Option<String>, pub codec: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -525,6 +563,7 @@ mod tests {
id: "1".to_string(), id: "1".to_string(),
name: "Test".to_string(), name: "Test".to_string(),
item_type: "Audio".to_string(), item_type: "Audio".to_string(),
kind: crate::domain::MediaKind::Track,
is_folder: false, is_folder: false,
server_id: "server1".to_string(), server_id: "server1".to_string(),
parent_id: None, parent_id: None,
@@ -536,7 +575,9 @@ mod tests {
community_rating: None, community_rating: None,
official_rating: None, official_rating: None,
runtime_ticks: None, runtime_ticks: None,
duration_ms: None,
primary_image_tag: None, primary_image_tag: None,
image_id: None,
backdrop_image_tags: None, backdrop_image_tags: None,
parent_backdrop_image_tags: None, parent_backdrop_image_tags: None,
album_id: None, album_id: None,
@@ -675,6 +716,7 @@ mod tests {
id: "track1".to_string(), id: "track1".to_string(),
name: "Test Track".to_string(), name: "Test Track".to_string(),
item_type: "Audio".to_string(), item_type: "Audio".to_string(),
kind: crate::domain::MediaKind::Track,
is_folder: false, is_folder: false,
server_id: "server1".to_string(), server_id: "server1".to_string(),
parent_id: None, parent_id: None,
@@ -686,7 +728,9 @@ mod tests {
community_rating: None, community_rating: None,
official_rating: None, official_rating: None,
runtime_ticks: None, runtime_ticks: None,
duration_ms: None,
primary_image_tag: None, primary_image_tag: None,
image_id: None,
backdrop_image_tags: None, backdrop_image_tags: None,
parent_backdrop_image_tags: None, parent_backdrop_image_tags: None,
album_id: None, album_id: None,
@@ -736,6 +780,7 @@ mod tests {
id: "1".to_string(), id: "1".to_string(),
name: "Track".to_string(), name: "Track".to_string(),
item_type: "Audio".to_string(), item_type: "Audio".to_string(),
kind: crate::domain::MediaKind::Track,
is_folder: false, is_folder: false,
server_id: "s1".to_string(), server_id: "s1".to_string(),
parent_id: None, parent_id: None,
@@ -747,7 +792,9 @@ mod tests {
community_rating: None, community_rating: None,
official_rating: None, official_rating: None,
runtime_ticks: None, runtime_ticks: None,
duration_ms: None,
primary_image_tag: None, primary_image_tag: None,
image_id: None,
backdrop_image_tags: None, backdrop_image_tags: None,
parent_backdrop_image_tags: None, parent_backdrop_image_tags: None,
album_id: None, album_id: None,
+174 -19
View File
@@ -670,15 +670,15 @@ async storageDeleteUser(userId: string) : Promise<null> {
* Update playback progress in local database * Update playback progress in local database
* This stores the progress locally for offline access and "continue watching" * This stores the progress locally for offline access and "continue watching"
*/ */
async storageUpdatePlaybackProgress(userId: string, itemId: string, positionTicks: number) : Promise<null> { async storageUpdatePlaybackProgress(userId: string, itemId: string, positionMs: number) : Promise<null> {
return await TAURI_INVOKE("storage_update_playback_progress", { userId, itemId, positionTicks }); return await TAURI_INVOKE("storage_update_playback_progress", { userId, itemId, positionMs });
}, },
/** /**
* Update playback progress with context in local database * Update playback progress with context in local database
* This stores the progress along with playback context (container vs single) * This stores the progress along with playback context (container vs single)
*/ */
async storageUpdatePlaybackContext(userId: string, itemId: string, positionTicks: number, contextType: string | null, contextId: string | null) : Promise<null> { async storageUpdatePlaybackContext(userId: string, itemId: string, positionMs: number, contextType: string | null, contextId: string | null) : Promise<null> {
return await TAURI_INVOKE("storage_update_playback_context", { userId, itemId, positionTicks, contextType, contextId }); return await TAURI_INVOKE("storage_update_playback_context", { userId, itemId, positionMs, contextType, contextId });
}, },
/** /**
* Mark item as played in local database * Mark item as played in local database
@@ -1332,20 +1332,20 @@ async repositoryOpenLiveStream(handle: string, itemId: string) : Promise<LiveStr
/** /**
* Report playback start * Report playback start
*/ */
async repositoryReportPlaybackStart(handle: string, itemId: string, positionTicks: number) : Promise<null> { async repositoryReportPlaybackStart(handle: string, itemId: string, positionMs: number) : Promise<null> {
return await TAURI_INVOKE("repository_report_playback_start", { handle, itemId, positionTicks }); return await TAURI_INVOKE("repository_report_playback_start", { handle, itemId, positionMs });
}, },
/** /**
* Report playback progress * Report playback progress
*/ */
async repositoryReportPlaybackProgress(handle: string, itemId: string, positionTicks: number) : Promise<null> { async repositoryReportPlaybackProgress(handle: string, itemId: string, positionMs: number) : Promise<null> {
return await TAURI_INVOKE("repository_report_playback_progress", { handle, itemId, positionTicks }); return await TAURI_INVOKE("repository_report_playback_progress", { handle, itemId, positionMs });
}, },
/** /**
* Report playback stopped * Report playback stopped
*/ */
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionTicks: number) : Promise<null> { async repositoryReportPlaybackStopped(handle: string, itemId: string, positionMs: number) : Promise<null> {
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionTicks }); return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionMs });
}, },
/** /**
* Get image URL for an item * Get image URL for an item
@@ -1802,7 +1802,22 @@ export type LmsSyncGroup = { masterMac: string; masterName?: string; slaveMacs?:
/** /**
* Media item * Media item
*/ */
export type MediaItem = { id: string; name: string; type: string; export type MediaItem = { id: string; name: string;
/**
* Legacy Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, ).
*
* Dual-carry migration (docs/specs/frontend-domain-model.md): `kind` below
* is the neutral replacement. This field stays while the frontend migrates
* off it, then is removed in a later phase. New Rust code should read
* `kind`, not this.
*/
type: string;
/**
* Provider-neutral classification the replacement for `item_type`.
* Populated by the Jellyfin mapping; defaults to `Other` for the handful of
* construction sites that have not been migrated yet.
*/
kind?: MediaKind;
/** /**
* Whether this item is a folder/container (vs a playable leaf). Used to * Whether this item is a folder/container (vs a playable leaf). Used to
* decide whether a channel item drills into a list or plays directly. * decide whether a channel item drills into a list or plays directly.
@@ -1812,7 +1827,63 @@ isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: stri
* ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort * ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort
* podcast episodes by release date. * podcast episodes by release date.
*/ */
premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null } premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null;
/**
* Legacy Jellyfin duration in ticks (100 ns units). Being replaced by
* `duration_ms`; dual-carried while the frontend migrates
* (docs/specs/frontend-domain-model.md). New code should read `duration_ms`.
*/
runTimeTicks?: number | null;
/**
* Duration in milliseconds the neutral replacement for `runtime_ticks`.
* Ticks never reach the frontend; this does.
*/
durationMs?: number | null;
/**
* Legacy Jellyfin primary image tag. Being replaced by `image_id`;
* dual-carried while the frontend migrates. New code should read `image_id`.
*/
primaryImageTag?: string | null;
/**
* Neutral image identifier the frontend resolves to a URL via the image
* command the replacement for `primary_image_tag`. Same value today
* (Jellyfin's tag is the id); the rename removes the provider term.
*/
imageId?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }
/**
* The kind of a media item provider-neutral classification.
*
* Replaces the stringly-typed `item_type` that carried Jellyfin's vocabulary
* (`"Audio"`, `"MusicAlbum"`, ) across the boundary. A closed enum means a
* typo or an unhandled kind is a compile error on the frontend, not a silent
* runtime miss across ~127 comparison sites.
*/
export type MediaKind = "track" | "album" | "artist" | "playlist" | "movie" | "series" | "season" | "episode" | "person" |
/**
* A channel *container* the user drills into (Jellyfin `Channel`).
*/
"channel" | "folder" |
/**
* A live TV channel playable, but a live stream with no seekable
* timeline (no resume/seek). Jellyfin `TvChannel`/`LiveTvChannel`.
*/
"liveChannel" |
/**
* A playable leaf inside a channel (Jellyfin `ChannelFolderItem` that is
* not itself a folder) e.g. a plugin-channel VOD item that has no
* dedicated item type but carries its own media streams. Playable and
* seekable, unlike `LiveChannel`. Distinct from `Channel` (the container)
* and from `Other` so the UI can route it to playback.
*/
"channelItem" |
/**
* A kind we do not model explicitly. Reached only for provider item types
* that map to nothing meaningful; consumers treat it like an opaque
* container. The mapping must be *total* it never panics so this is the
* safe sink for unknown strings. Also the `Default`, so a defaulted
* `MediaItem` (see the dual-carry migration) is inert rather than a lie.
*/
"other"
/** /**
* Media session type tracking the high-level playback context * Media session type tracking the high-level playback context
*/ */
@@ -1841,13 +1912,26 @@ export type MediaSource = { id: string; name: string; container?: string | null;
/** /**
* Media stream information (audio, video, subtitle tracks) * Media stream information (audio, video, subtitle tracks)
*/ */
export type MediaStream = { type: string; codec?: string | null; language?: string | null; displayTitle?: string | null; index: number; isDefault: boolean; isForced: boolean } export type MediaStream = {
/**
* Legacy Jellyfin stream type string ("Audio"/"Video"/"Subtitle"). Being
* replaced by `kind`; dual-carried while the frontend migrates.
*/
type: string;
/**
* Provider-neutral stream classification replaces `stream_type`.
*/
kind?: StreamKind; codec?: string | null; language?: string | null; displayTitle?: string | null; index: number; isDefault: boolean; isForced: boolean }
export type MediaType = "audio" | "video" export type MediaType = "audio" | "video"
/** /**
* Lightweight media item for merged playback state * Lightweight media item for merged playback state
* Converts from both local MediaItem and remote NowPlayingItem * Converts from both local MediaItem and remote NowPlayingItem
*/ */
export type MergedMediaItem = { id: string; title: string; artist: string | null; album: string | null; albumId: string | null; duration: number | null; primaryImageTag: string | null; mediaType: string } export type MergedMediaItem = { id: string; title: string; artist: string | null; album: string | null; albumId: string | null; duration: number | null; primaryImageTag: string | null;
/**
* Neutral image identifier replaces `primary_image_tag` (same value).
*/
imageId: string | null; mediaType: string }
/** /**
* Argument struct for [`set_network_state`]. * Argument struct for [`set_network_state`].
* *
@@ -1995,7 +2079,12 @@ export type PlaybackMode = { type: "local" } | { type: "remote"; session_id: str
/** /**
* Playback progress info * Playback progress info
*/ */
export type PlaybackProgress = { itemId: string; positionTicks: number; isPlayed: boolean; isFavorite: boolean; playCount: number } export type PlaybackProgress = { itemId: string;
/**
* Resume position in milliseconds. Stored as Jellyfin ticks in the DB;
* converted here so the frontend never sees ticks.
*/
positionMs: number; isPlayed: boolean; isFavorite: boolean; playCount: number }
/** /**
* Represents a media item that can be played * Represents a media item that can be played
* *
@@ -2039,9 +2128,17 @@ artistItems?: ArtistItem[] | null;
*/ */
artists?: string[] | null; artists?: string[] | null;
/** /**
* Primary image tag for artwork * Primary image tag for artwork.
*
* Legacy Jellyfin name; being replaced by `image_id` (same value). Dual-carried
* while the frontend migrates (docs/specs/frontend-domain-model.md).
*/ */
primaryImageTag?: string | null; primaryImageTag?: string | null;
/**
* Neutral image identifier the frontend resolves to a URL replaces
* `primary_image_tag`.
*/
imageId?: string | null;
/** /**
* Item type (Audio, Movie, Episode, etc.) * Item type (Audio, Movie, Episode, etc.)
*/ */
@@ -2276,7 +2373,22 @@ export type PlaylistEntry =
/** /**
* The underlying media item * The underlying media item
*/ */
({ id: string; name: string; type: string; ({ id: string; name: string;
/**
* Legacy Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, ).
*
* Dual-carry migration (docs/specs/frontend-domain-model.md): `kind` below
* is the neutral replacement. This field stays while the frontend migrates
* off it, then is removed in a later phase. New Rust code should read
* `kind`, not this.
*/
type: string;
/**
* Provider-neutral classification the replacement for `item_type`.
* Populated by the Jellyfin mapping; defaults to `Other` for the handful of
* construction sites that have not been migrated yet.
*/
kind?: MediaKind;
/** /**
* Whether this item is a folder/container (vs a playable leaf). Used to * Whether this item is a folder/container (vs a playable leaf). Used to
* decide whether a channel item drills into a list or plays directly. * decide whether a channel item drills into a list or plays directly.
@@ -2286,7 +2398,29 @@ isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: stri
* ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort * ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort
* podcast episodes by release date. * podcast episodes by release date.
*/ */
premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & { premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null;
/**
* Legacy Jellyfin duration in ticks (100 ns units). Being replaced by
* `duration_ms`; dual-carried while the frontend migrates
* (docs/specs/frontend-domain-model.md). New code should read `duration_ms`.
*/
runTimeTicks?: number | null;
/**
* Duration in milliseconds the neutral replacement for `runtime_ticks`.
* Ticks never reach the frontend; this does.
*/
durationMs?: number | null;
/**
* Legacy Jellyfin primary image tag. Being replaced by `image_id`;
* dual-carried while the frontend migrates. New code should read `image_id`.
*/
primaryImageTag?: string | null;
/**
* Neutral image identifier the frontend resolves to a URL via the image
* command the replacement for `primary_image_tag`. Same value today
* (Jellyfin's tag is the id); the rename removes the provider term.
*/
imageId?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & {
/** /**
* The playlist-scoped entry ID (Jellyfin's PlaylistItemId) * The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
*/ */
@@ -2391,6 +2525,15 @@ export type SmartCacheStats = { total_size: number; storage_limit: number; avail
* Storage statistics for downloads * Storage statistics for downloads
*/ */
export type StorageStats = { total_bytes: number; total_items: number; albums: AlbumStorageInfo[] } export type StorageStats = { total_bytes: number; total_items: number; albums: AlbumStorageInfo[] }
/**
* The kind of a media stream within an item (audio track, video track,
* subtitle, ) provider-neutral, replacing the stringly Jellyfin stream type.
*/
export type StreamKind = "audio" | "video" | "subtitle" |
/**
* Any stream kind we do not model explicitly (e.g. embedded image, data).
*/
"other"
/** /**
* Represents a subtitle track * Represents a subtitle track
*/ */
@@ -2430,7 +2573,19 @@ export type User = { id: string; name: string; serverId: string; primaryImageTag
/** /**
* User-specific data for an item (playback state, favorites, etc.) * User-specific data for an item (playback state, favorites, etc.)
*/ */
export type UserData = { playbackPositionTicks?: number | null; isPlayed?: boolean | null; isFavorite?: boolean | null; playCount?: number | null; lastPlayedDate?: string | null; playbackContextType?: string | null; playbackContextId?: string | null } export type UserData = {
/**
* Legacy Jellyfin resume position in ticks. Being replaced by
* `playback_position_ms`; dual-carried while the frontend migrates
* (docs/specs/frontend-domain-model.md). New code should read the ms field.
*/
playbackPositionTicks?: number | null;
/**
* Resume position in milliseconds the neutral replacement for
* `playback_position_ticks`. Populated from ticks by the mapping; the
* frontend never divides ticks itself.
*/
playbackPositionMs?: number | null; isPlayed?: boolean | null; isFavorite?: boolean | null; playCount?: number | null; lastPlayedDate?: string | null; playbackContextType?: string | null; playbackContextId?: string | null }
/** /**
* User info returned to frontend * User info returned to frontend
*/ */
+1 -1
View File
@@ -470,7 +470,7 @@ describe("RepositoryClient", () => {
expect(invoke).toHaveBeenCalledWith("repository_report_playback_progress", { expect(invoke).toHaveBeenCalledWith("repository_report_playback_progress", {
handle: "test-handle-123", handle: "test-handle-123",
itemId: "item123", itemId: "item123",
positionTicks: 5000000, positionMs: 5000000,
}); });
}); });
}); });
+6 -6
View File
@@ -164,16 +164,16 @@ export class RepositoryClient {
return commands.repositoryGetPlaybackInfo(this.ensureHandle(), itemId); return commands.repositoryGetPlaybackInfo(this.ensureHandle(), itemId);
} }
async reportPlaybackStart(itemId: string, positionTicks: number): Promise<void> { async reportPlaybackStart(itemId: string, positionMs: number): Promise<void> {
await commands.repositoryReportPlaybackStart(this.ensureHandle(), itemId, positionTicks); await commands.repositoryReportPlaybackStart(this.ensureHandle(), itemId, positionMs);
} }
async reportPlaybackProgress(itemId: string, positionTicks: number): Promise<void> { async reportPlaybackProgress(itemId: string, positionMs: number): Promise<void> {
await commands.repositoryReportPlaybackProgress(this.ensureHandle(), itemId, positionTicks); await commands.repositoryReportPlaybackProgress(this.ensureHandle(), itemId, positionMs);
} }
async reportPlaybackStopped(itemId: string, positionTicks: number): Promise<void> { async reportPlaybackStopped(itemId: string, positionMs: number): Promise<void> {
await commands.repositoryReportPlaybackStopped(this.ensureHandle(), itemId, positionTicks); await commands.repositoryReportPlaybackStopped(this.ensureHandle(), itemId, positionMs);
} }
// ===== Stream URL Methods (via Rust) ===== // ===== Stream URL Methods (via Rust) =====
+1
View File
@@ -14,6 +14,7 @@ export type {
Library, Library,
LiveStreamInfo, LiveStreamInfo,
MediaItem, MediaItem,
MediaKind,
MediaSource, MediaSource,
MediaStream, MediaStream,
Person, Person,
+6 -6
View File
@@ -32,7 +32,7 @@
} }
// 2. For episodes, try series/season backdrops // 2. For episodes, try series/season backdrops
if (currentItem.type === "Episode") { if (currentItem.kind === "episode") {
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) { if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: currentItem.parentBackdropImageTags[0] }; return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: currentItem.parentBackdropImageTags[0] };
} }
@@ -45,17 +45,17 @@
} }
// 3. For music tracks, try album backdrop // 3. For music tracks, try album backdrop
if (currentItem.type === "Audio" && currentItem.albumId) { if (currentItem.kind === "track" && currentItem.albumId) {
return { itemId: currentItem.albumId, imageType: "Backdrop" as const, tag: undefined }; return { itemId: currentItem.albumId, imageType: "Backdrop" as const, tag: undefined };
} }
// 4. Fall back to primary image // 4. Fall back to primary image
if (currentItem.primaryImageTag) { if (currentItem.imageId) {
return { itemId: currentItem.id, imageType: "Primary" as const, tag: currentItem.primaryImageTag }; return { itemId: currentItem.id, imageType: "Primary" as const, tag: currentItem.imageId };
} }
// 5. Last resort for audio: album primary // 5. Last resort for audio: album primary
if (currentItem.type === "Audio" && currentItem.albumId) { if (currentItem.kind === "track" && currentItem.albumId) {
return { itemId: currentItem.albumId, imageType: "Primary" as const, tag: undefined }; return { itemId: currentItem.albumId, imageType: "Primary" as const, tag: undefined };
} }
@@ -190,7 +190,7 @@
onclick={() => { onclick={() => {
// Navigate to full series detail page with cast/crew/related content // Navigate to full series detail page with cast/crew/related content
// (even for episodes, show the series page so users see cast and related items) // (even for episodes, show the series page so users see cast and related items)
if (currentItem.type === "Episode" && currentItem.seriesId) { if (currentItem.kind === "episode" && currentItem.seriesId) {
goto(`/library/${currentItem.seriesId}`); goto(`/library/${currentItem.seriesId}`);
} else { } else {
goto(`/library/${currentItem.id}`); goto(`/library/${currentItem.id}`);
@@ -43,7 +43,7 @@
sortBy: "DateCreated", sortBy: "DateCreated",
sortOrder: "Descending" sortOrder: "Descending"
}); });
albums = albumsResult.items.filter(item => item.type === "MusicAlbum"); albums = albumsResult.items.filter(item => item.kind === "album");
} catch (e) { } catch (e) {
console.warn("Failed to load albums:", e); console.warn("Failed to load albums:", e);
} finally { } finally {
@@ -58,7 +58,7 @@
sortBy: "CommunityRating", sortBy: "CommunityRating",
sortOrder: "Descending" sortOrder: "Descending"
}); });
topTracks = tracksResult.items.filter(item => item.type === "Audio"); topTracks = tracksResult.items.filter(item => item.kind === "track");
} catch (e) { } catch (e) {
console.warn("Failed to load tracks:", e); console.warn("Failed to load tracks:", e);
} finally { } finally {
@@ -76,7 +76,7 @@
sortOrder: "Descending" sortOrder: "Descending"
}); });
relatedArtists = relatedResult.items relatedArtists = relatedResult.items
.filter(item => item.id !== artist.id && item.type === "MusicArtist") .filter(item => item.id !== artist.id && item.kind === "artist")
.slice(0, 6); .slice(0, 6);
} }
} catch (e) { } catch (e) {
@@ -112,12 +112,12 @@
<!-- Artist Info --> <!-- Artist Info -->
<div class="flex flex-col items-center text-center py-12"> <div class="flex flex-col items-center text-center py-12">
<!-- Artist Image --> <!-- Artist Image -->
{#if artist.primaryImageTag} {#if artist.imageId}
<div class="mb-6 rounded-full overflow-hidden w-40 h-40 shadow-lg"> <div class="mb-6 rounded-full overflow-hidden w-40 h-40 shadow-lg">
<CachedImage <CachedImage
itemId={artist.id} itemId={artist.id}
imageType="Primary" imageType="Primary"
tag={artist.primaryImageTag} tag={artist.imageId}
maxWidth={400} maxWidth={400}
alt={artist.name} alt={artist.name}
class="w-full h-full object-cover" class="w-full h-full object-cover"
@@ -160,11 +160,11 @@
class="group cursor-pointer" class="group cursor-pointer"
> >
<div class="aspect-square bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2 group-hover:opacity-80 transition-opacity"> <div class="aspect-square bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2 group-hover:opacity-80 transition-opacity">
{#if album.primaryImageTag} {#if album.imageId}
<CachedImage <CachedImage
itemId={album.id} itemId={album.id}
imageType="Primary" imageType="Primary"
tag={album.primaryImageTag} tag={album.imageId}
maxWidth={200} maxWidth={200}
alt={album.name} alt={album.name}
class="w-full h-full object-cover" class="w-full h-full object-cover"
@@ -218,11 +218,11 @@
class="group text-center" class="group text-center"
> >
<div class="w-32 h-32 bg-[var(--color-surface)] rounded-full overflow-hidden mb-2 mx-auto group-hover:opacity-80 transition-opacity"> <div class="w-32 h-32 bg-[var(--color-surface)] rounded-full overflow-hidden mb-2 mx-auto group-hover:opacity-80 transition-opacity">
{#if relatedArtist.primaryImageTag} {#if relatedArtist.imageId}
<CachedImage <CachedImage
itemId={relatedArtist.id} itemId={relatedArtist.id}
imageType="Primary" imageType="Primary"
tag={relatedArtist.primaryImageTag} tag={relatedArtist.imageId}
maxWidth={200} maxWidth={200}
alt={relatedArtist.name} alt={relatedArtist.name}
class="w-full h-full object-cover" class="w-full h-full object-cover"
@@ -77,8 +77,8 @@
if (episode.backdropImageTags?.[0]) { if (episode.backdropImageTags?.[0]) {
return { itemId: episode.id, imageType: "Backdrop" as const, tag: episode.backdropImageTags[0] }; return { itemId: episode.id, imageType: "Backdrop" as const, tag: episode.backdropImageTags[0] };
} }
if (episode.primaryImageTag) { if (episode.imageId) {
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.primaryImageTag }; return { itemId: episode.id, imageType: "Primary" as const, tag: episode.imageId };
} }
if (series.backdropImageTags?.[0]) { if (series.backdropImageTags?.[0]) {
return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] }; return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
@@ -86,9 +86,9 @@
return null; return null;
}); });
function formatDuration(ticks?: number | null): string { function formatDuration(ms?: number | null): string {
if (!ticks) return ""; if (!ms) return "";
const seconds = Math.floor(ticks / 10000000); const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
@@ -99,10 +99,10 @@
} }
function getProgress(ep: MediaItem): number { function getProgress(ep: MediaItem): number {
if (!ep.userData || !ep.runTimeTicks) { if (!ep.userData || !ep.durationMs) {
return 0; return 0;
} }
return ((ep.userData.playbackPositionTicks ?? 0) / ep.runTimeTicks) * 100; return ((ep.userData.playbackPositionMs ?? 0) / ep.durationMs) * 100;
} }
function handlePlay() { function handlePlay() {
@@ -116,7 +116,7 @@
const episodeLabel = $derived( const episodeLabel = $derived(
`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}` `S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`
); );
const duration = $derived(formatDuration(episode.runTimeTicks)); const duration = $derived(formatDuration(episode.durationMs));
const progress = $derived(getProgress(episode)); const progress = $derived(getProgress(episode));
</script> </script>
@@ -246,7 +246,7 @@
<CachedImage <CachedImage
itemId={ep.id} itemId={ep.id}
imageType="Primary" imageType="Primary"
tag={ep.primaryImageTag} tag={ep.imageId}
maxWidth={400} maxWidth={400}
alt={ep.name} alt={ep.name}
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}" class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
+4 -4
View File
@@ -38,13 +38,13 @@
const downloadProgress = $derived(downloadInfo?.progress || 0); const downloadProgress = $derived(downloadInfo?.progress || 0);
const progress = $derived(() => { const progress = $derived(() => {
if (!episode.userData || !episode.runTimeTicks) { if (!episode.userData || !episode.durationMs) {
return 0; return 0;
} }
return ((episode.userData.playbackPositionTicks ?? 0) / episode.runTimeTicks) * 100; return ((episode.userData.playbackPositionMs ?? 0) / episode.durationMs) * 100;
}); });
const duration = $derived(formatDuration(episode.runTimeTicks)); const duration = $derived(formatDuration(episode.durationMs));
const episodeNumber = $derived(episode.indexNumber || 0); const episodeNumber = $derived(episode.indexNumber || 0);
</script> </script>
@@ -59,7 +59,7 @@
<CachedImage <CachedImage
itemId={episode.id} itemId={episode.id}
imageType="Primary" imageType="Primary"
tag={episode.primaryImageTag} tag={episode.imageId}
maxWidth={320} maxWidth={320}
alt={episode.name} alt={episode.name}
class="w-full h-full object-cover transition-transform group-hover/row:scale-105" class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
@@ -234,7 +234,7 @@
<CachedImage <CachedImage
itemId={item.id} itemId={item.id}
imageType="Primary" imageType="Primary"
tag={item.primaryImageTag} tag={item.imageId}
maxWidth={300} maxWidth={300}
alt={item.name} alt={item.name}
class="w-full h-full object-cover group-hover:scale-105 transition-transform" class="w-full h-full object-cover group-hover:scale-105 transition-transform"
+16 -13
View File
@@ -1,32 +1,35 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { MediaKind } from "$lib/api/types";
interface Props { interface Props {
genres: string[]; genres: string[];
maxShow?: number; // Default: unlimited maxShow?: number; // Default: unlimited
clickable?: boolean; // Default: true clickable?: boolean; // Default: true
itemType?: string; // Determines which genre browse page to open itemKind?: MediaKind; // Determines which genre browse page to open
} }
let { let {
genres, genres,
maxShow, maxShow,
clickable = true, clickable = true,
itemType itemKind
}: Props = $props(); }: Props = $props();
// Map the item type to its genre-browse route // Map the item kind to its genre-browse route
function genreBasePath(type: string | undefined): string { function genreBasePath(kind: MediaKind | undefined): string {
switch (type) { switch (kind) {
case "MusicAlbum": case "album":
case "MusicArtist": case "artist":
case "Audio": case "track":
case "playlist":
return "/library/music/genres"; return "/library/music/genres";
case "Series": case "series":
case "Season": case "season":
case "Episode": case "episode":
return "/library/shows/genres"; return "/library/shows/genres";
case "Movie": case "movie":
return "/library/movies/genres"; return "/library/movies/genres";
default: default:
return "/library/movies/genres"; return "/library/movies/genres";
@@ -43,7 +46,7 @@
function handleGenreClick(genre: string) { function handleGenreClick(genre: string) {
if (clickable) { if (clickable) {
goto(`${genreBasePath(itemType)}?genre=${encodeURIComponent(genre)}`); goto(`${genreBasePath(itemKind)}?genre=${encodeURIComponent(genre)}`);
} }
} }
</script> </script>
@@ -19,7 +19,7 @@
} }
function getImageTag(item: MediaItem | Library): string | undefined { function getImageTag(item: MediaItem | Library): string | undefined {
return "primaryImageTag" in item ? (item.primaryImageTag ?? undefined) : ("imageTag" in item ? (item.imageTag ?? undefined) : undefined); return "imageId" in item ? (item.imageId ?? undefined) : ("imageTag" in item ? (item.imageTag ?? undefined) : undefined);
} }
function getSubtitle(item: MediaItem | Library): string { function getSubtitle(item: MediaItem | Library): string {
@@ -42,10 +42,10 @@
function getProgress(item: MediaItem | Library): number { function getProgress(item: MediaItem | Library): number {
if (!showProgress || !("userData" in item) || !item.userData || !("runTimeTicks" in item) || !item.runTimeTicks) { if (!showProgress || !("userData" in item) || !item.userData || !("durationMs" in item) || !item.durationMs) {
return 0; return 0;
} }
return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100; return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100;
} }
function getTrackNumber(item: MediaItem | Library): string { function getTrackNumber(item: MediaItem | Library): string {
@@ -59,7 +59,7 @@
<div class="space-y-1"> <div class="space-y-1">
{#each items as item, index (item.id)} {#each items as item, index (item.id)}
{@const subtitle = getSubtitle(item)} {@const subtitle = getSubtitle(item)}
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""} {@const duration = "durationMs" in item ? formatDuration(item.durationMs) : ""}
{@const progress = getProgress(item)} {@const progress = getProgress(item)}
{@const trackNum = getTrackNumber(item)} {@const trackNum = getTrackNumber(item)}
{@const isPlayed = "userData" in item && item.userData?.isPlayed} {@const isPlayed = "userData" in item && item.userData?.isPlayed}
+5 -5
View File
@@ -101,11 +101,11 @@
}; };
const isMusicType = $derived( const isMusicType = $derived(
"type" in item && (item.type === "Audio" || item.type === "MusicAlbum" || item.type === "MusicArtist" || item.type === "Playlist") "kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist")
); );
const aspectRatio = $derived(() => { const aspectRatio = $derived(() => {
if ("type" in item) { if ("kind" in item) {
return isMusicType ? "aspect-square" : "aspect-[2/3]"; return isMusicType ? "aspect-square" : "aspect-[2/3]";
} }
// Library // Library
@@ -113,16 +113,16 @@
}); });
const imageTag = $derived( const imageTag = $derived(
"primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined) "imageId" in item ? item.imageId : ("imageTag" in item ? item.imageTag : undefined)
); );
const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200); const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200);
const progress = $derived(() => { const progress = $derived(() => {
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) { if (!showProgress || !("userData" in item) || !item.userData || !item.durationMs) {
return 0; return 0;
} }
return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100; return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100;
}); });
const subtitle = $derived(() => { const subtitle = $derived(() => {
@@ -31,8 +31,8 @@
}); });
// Separate movies and series // Separate movies and series
movies = result.items.filter(item => item.type === "Movie"); movies = result.items.filter(item => item.kind === "movie");
series = result.items.filter(item => item.type === "Series"); series = result.items.filter(item => item.kind === "series");
} catch (e) { } catch (e) {
console.error("Failed to load filmography:", e); console.error("Failed to load filmography:", e);
} finally { } finally {
@@ -53,7 +53,7 @@
<CachedImage <CachedImage
itemId={person.id} itemId={person.id}
imageType="Primary" imageType="Primary"
tag={person.primaryImageTag} tag={person.imageId}
maxWidth={400} maxWidth={400}
alt={person.name} alt={person.name}
class="w-full rounded-lg shadow-lg" class="w-full rounded-lg shadow-lg"
@@ -25,7 +25,7 @@
const tracks = $derived(entries.map(e => ({ ...e } as MediaItem))); const tracks = $derived(entries.map(e => ({ ...e } as MediaItem)));
const totalDuration = $derived( const totalDuration = $derived(
entries.reduce((sum, e) => sum + (e.runTimeTicks ?? 0), 0) entries.reduce((sum, e) => sum + (e.durationMs ?? 0), 0)
); );
onMount(() => { onMount(() => {
@@ -146,11 +146,11 @@
<div class="flex gap-6 pt-4"> <div class="flex gap-6 pt-4">
<!-- Playlist artwork --> <!-- Playlist artwork -->
<div class="flex-shrink-0 w-48"> <div class="flex-shrink-0 w-48">
{#if playlist.primaryImageTag} {#if playlist.imageId}
<CachedImage <CachedImage
itemId={playlist.id} itemId={playlist.id}
imageType="Primary" imageType="Primary"
tag={playlist.primaryImageTag} tag={playlist.imageId}
maxWidth={400} maxWidth={400}
alt={playlist.name} alt={playlist.name}
class="w-full rounded-lg shadow-lg" class="w-full rounded-lg shadow-lg"
@@ -2,12 +2,12 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import type { MediaItem, Person } from "$lib/api/types"; import type { MediaItem, MediaKind, Person } from "$lib/api/types";
import MediaCard from "./MediaCard.svelte"; import MediaCard from "./MediaCard.svelte";
interface Props { interface Props {
currentItemId: string; currentItemId: string;
itemType: "Movie" | "Series" | "MusicAlbum" | "Audio"; itemKind: MediaKind;
genres?: string[]; genres?: string[];
people?: Person[]; people?: Person[];
artistIds?: string[]; artistIds?: string[];
@@ -16,7 +16,7 @@
let { let {
currentItemId, currentItemId,
itemType, itemKind,
genres = [], genres = [],
people = [], people = [],
artistIds = [], artistIds = [],
@@ -45,9 +45,9 @@
let items: MediaItem[] = []; let items: MediaItem[] = [];
// First, try to use the Jellyfin Similar Items API (preferred method) // First, try to use the Similar Items API (preferred method)
// This works for Movies and Series (most common cases) // This works for Movies and Series (most common cases)
if (["Movie", "Series"].includes(itemType)) { if (itemKind === "movie" || itemKind === "series") {
try { try {
const result = await repo.getSimilarItems(currentItemId, limit); const result = await repo.getSimilarItems(currentItemId, limit);
items = result.items.filter(item => item.id !== currentItemId); items = result.items.filter(item => item.id !== currentItemId);
@@ -65,10 +65,14 @@
// Fallback: Load by genres using search (works for all item types) // Fallback: Load by genres using search (works for all item types)
if (genres && genres.length > 0) { if (genres && genres.length > 0) {
try { try {
// Search by first genre to find related items // Search by first genre to find related items. This single-kind query
// maps the neutral kind to the concrete Jellyfin item type it needs.
const searchTerm = genres[0]; const searchTerm = genres[0];
const itemTypeForKind: Record<string, string> = {
movie: "Movie", series: "Series", album: "MusicAlbum", track: "Audio", artist: "MusicArtist",
};
const result = await repo.search(searchTerm, { const result = await repo.search(searchTerm, {
includeItemTypes: itemType === "MusicAlbum" ? ["MusicAlbum"] : itemType === "Audio" ? ["Audio"] : [itemType], includeItemTypes: [itemTypeForKind[itemKind] ?? "Movie"],
limit: limit * 2 limit: limit * 2
}); });
@@ -79,7 +83,7 @@
} }
// For music albums, also try to load by artist (if we don't have enough from similar API) // For music albums, also try to load by artist (if we don't have enough from similar API)
if (itemType === "MusicAlbum" && artistIds && artistIds.length > 0 && items.length === 0) { if (itemKind === "album" && artistIds && artistIds.length > 0 && items.length === 0) {
try { try {
// Search for other albums by artist name from first artist // Search for other albums by artist name from first artist
const result = await repo.search(artistIds[0], { const result = await repo.search(artistIds[0], {
@@ -109,14 +113,14 @@
} }
function getTitle(): string { function getTitle(): string {
switch (itemType) { switch (itemKind) {
case "Movie": case "movie":
return "Related Movies"; return "Related Movies";
case "Series": case "series":
return "Related Shows"; return "Related Shows";
case "MusicAlbum": case "album":
return "Related Albums"; return "Related Albums";
case "Audio": case "track":
return "Related Tracks"; return "Related Tracks";
default: default:
return "Related Items"; return "Related Items";
@@ -133,7 +137,7 @@
{#if loading} {#if loading}
<!-- Skeleton loading state --> <!-- Skeleton loading state -->
{@const isMusicContent = itemType === "MusicAlbum" || itemType === "Audio"} {@const isMusicContent = itemKind === "album" || itemKind === "track"}
<div class="grid grid-cols-2 md:grid-cols-6 gap-4"> <div class="grid grid-cols-2 md:grid-cols-6 gap-4">
{#each Array(6) as _} {#each Array(6) as _}
<div class="animate-pulse"> <div class="animate-pulse">
@@ -28,7 +28,7 @@
<CachedImage <CachedImage
itemId={season.id} itemId={season.id}
imageType="Primary" imageType="Primary"
tag={season.primaryImageTag} tag={season.imageId}
maxWidth={200} maxWidth={200}
alt={seasonName} alt={seasonName}
class="w-full h-full object-cover" class="w-full h-full object-cover"
+2 -2
View File
@@ -283,7 +283,7 @@
<!-- Duration --> <!-- Duration -->
<div class="text-gray-400 text-right"> <div class="text-gray-400 text-right">
{formatDuration(track.runTimeTicks)} {formatDuration(track.durationMs)}
</div> </div>
<!-- Download Button Placeholder --> <!-- Download Button Placeholder -->
@@ -421,7 +421,7 @@
</p> </p>
</div> </div>
<div class="text-gray-400 text-sm {showDownload ? 'mr-20' : 'mr-12'}"> <div class="text-gray-400 text-sm {showDownload ? 'mr-20' : 'mr-12'}">
{formatDuration(track.runTimeTicks)} {formatDuration(track.durationMs)}
</div> </div>
</button> </button>
+4 -4
View File
@@ -72,7 +72,7 @@ describe("TrackList", () => {
artists: ["Artist 1"], artists: ["Artist 1"],
albumName: "Album 1", albumName: "Album 1",
albumId: "album-1", albumId: "album-1",
runTimeTicks: 1800000000, // 3 minutes durationMs: 180000, // 3 minutes
primaryImageTag: "tag1", primaryImageTag: "tag1",
indexNumber: 1, indexNumber: 1,
}, },
@@ -84,7 +84,7 @@ describe("TrackList", () => {
artists: ["Artist 2"], artists: ["Artist 2"],
albumName: "Album 2", albumName: "Album 2",
albumId: "album-2", albumId: "album-2",
runTimeTicks: 2400000000, // 4 minutes durationMs: 240000, // 4 minutes
primaryImageTag: "tag2", primaryImageTag: "tag2",
indexNumber: 2, indexNumber: 2,
}, },
@@ -96,7 +96,7 @@ describe("TrackList", () => {
artists: ["Artist 3", "Artist 4"], artists: ["Artist 3", "Artist 4"],
albumName: "Album 3", albumName: "Album 3",
albumId: "album-3", albumId: "album-3",
runTimeTicks: 3000000000, // 5 minutes durationMs: 300000, // 5 minutes
indexNumber: 3, indexNumber: 3,
}, },
]; ];
@@ -187,7 +187,7 @@ describe("TrackList", () => {
const tracksWithoutDuration: MediaItem[] = [ const tracksWithoutDuration: MediaItem[] = [
{ {
...mockTracks[0], ...mockTracks[0],
runTimeTicks: undefined, durationMs: undefined,
}, },
]; ];
+2 -2
View File
@@ -143,7 +143,7 @@
<CachedImage <CachedImage
itemId={artworkItemId} itemId={artworkItemId}
imageType="Primary" imageType="Primary"
tag={displayMedia?.primaryImageTag} tag={displayMedia?.imageId}
maxWidth={800} maxWidth={800}
alt="" alt=""
class="w-full h-full object-cover blur-3xl opacity-30" class="w-full h-full object-cover blur-3xl opacity-30"
@@ -223,7 +223,7 @@
<CachedImage <CachedImage
itemId={artworkItemId} itemId={artworkItemId}
imageType="Primary" imageType="Primary"
tag={displayMedia?.primaryImageTag} tag={displayMedia?.imageId}
maxWidth={500} maxWidth={500}
alt={displayMedia?.name} alt={displayMedia?.name}
class="w-full h-full object-cover" class="w-full h-full object-cover"
+1 -1
View File
@@ -326,7 +326,7 @@
<CachedImage <CachedImage
itemId={displayMedia.albumId || displayMedia.id} itemId={displayMedia.albumId || displayMedia.id}
imageType="Primary" imageType="Primary"
tag={displayMedia.primaryImageTag} tag={displayMedia.imageId}
maxWidth={100} maxWidth={100}
alt={displayMedia?.name} alt={displayMedia?.name}
class="w-full h-full object-cover" class="w-full h-full object-cover"
@@ -78,11 +78,11 @@
<div <div
class="relative flex-shrink-0 w-28 h-16 rounded-lg overflow-hidden bg-gray-800" class="relative flex-shrink-0 w-28 h-16 rounded-lg overflow-hidden bg-gray-800"
> >
{#if imageId && $nextEpisodeItem.primaryImageTag} {#if imageId && $nextEpisodeItem.imageId}
<CachedImage <CachedImage
itemId={imageId} itemId={imageId}
imageType="Primary" imageType="Primary"
tag={$nextEpisodeItem.primaryImageTag} tag={$nextEpisodeItem.imageId}
maxHeight={200} maxHeight={200}
alt={$nextEpisodeItem.name} alt={$nextEpisodeItem.name}
class="w-full h-full object-cover" class="w-full h-full object-cover"
+6 -6
View File
@@ -36,9 +36,9 @@
let dragDisabled = $state(true); let dragDisabled = $state(true);
const flipDurationMs = 200; const flipDurationMs = 200;
function formatDuration(ticks?: number | null): string { function formatDuration(ms?: number | null): string {
if (!ticks) return ""; if (!ms) return "";
const seconds = Math.floor(ticks / 10000000); const seconds = Math.floor(ms / 1000);
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
const secs = seconds % 60; const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`; return `${mins}:${secs.toString().padStart(2, "0")}`;
@@ -184,11 +184,11 @@
<!-- Artwork --> <!-- Artwork -->
<div class="w-10 h-10 rounded bg-gray-800 flex-shrink-0 overflow-hidden"> <div class="w-10 h-10 rounded bg-gray-800 flex-shrink-0 overflow-hidden">
{#if item.primaryImageTag} {#if item.imageId}
<CachedImage <CachedImage
itemId={item.id} itemId={item.id}
imageType="Primary" imageType="Primary"
tag={item.primaryImageTag} tag={item.imageId}
maxWidth={80} maxWidth={80}
alt={item.name} alt={item.name}
class="w-full h-full object-cover" class="w-full h-full object-cover"
@@ -210,7 +210,7 @@
<!-- Duration --> <!-- Duration -->
<span class="text-xs text-gray-500 flex-shrink-0"> <span class="text-xs text-gray-500 flex-shrink-0">
{formatDuration(item.runTimeTicks)} {formatDuration(item.durationMs)}
</span> </span>
</button> </button>
@@ -97,8 +97,8 @@ function makeEpisode(): MediaItem {
return { return {
id: "ep1", id: "ep1",
name: "Episode 1", name: "Episode 1",
type: "Episode", kind: "episode",
runTimeTicks: 24 * 60 * 10_000_000, // 24 min durationMs: 24 * 60 * 1000, // 24 min
} as MediaItem; } as MediaItem;
} }
+10 -10
View File
@@ -165,9 +165,9 @@
// Use known duration from media item (runTimeTicks is in 10M ticks/second) // Use known duration from media item (runTimeTicks is in 10M ticks/second)
// Fallback to video element duration for direct streams // Fallback to video element duration for direct streams
const duration = $derived.by(() => { const duration = $derived.by(() => {
// Explicitly check if runTimeTicks exists and is a valid number // Explicitly check if durationMs exists and is a valid number
if (media && media.runTimeTicks && media.runTimeTicks > 0) { if (media && media.durationMs && media.durationMs > 0) {
return media.runTimeTicks / 10_000_000; return media.durationMs / 1000;
} }
// Otherwise use the video element's duration // Otherwise use the video element's duration
return videoDuration; return videoDuration;
@@ -180,7 +180,7 @@
console.log("[VideoPlayer] No media or mediaStreams available"); console.log("[VideoPlayer] No media or mediaStreams available");
return []; return [];
} }
const tracks = media.mediaStreams.filter(stream => stream.type === "Audio"); const tracks = media.mediaStreams.filter(stream => stream.kind === "audio");
console.log("[VideoPlayer] Found audio tracks:", tracks.length, tracks); console.log("[VideoPlayer] Found audio tracks:", tracks.length, tracks);
return tracks; return tracks;
}); });
@@ -243,7 +243,7 @@
console.log("[VideoPlayer] No media or mediaStreams available for subtitles"); console.log("[VideoPlayer] No media or mediaStreams available for subtitles");
return []; return [];
} }
const tracks = media.mediaStreams.filter(stream => stream.type === "Subtitle"); const tracks = media.mediaStreams.filter(stream => stream.kind === "subtitle");
console.log("[VideoPlayer] Found subtitle tracks:", tracks.length, tracks); console.log("[VideoPlayer] Found subtitle tracks:", tracks.length, tracks);
return tracks; return tracks;
}); });
@@ -386,7 +386,7 @@
// Check if we're near the end of the video - if so, this is likely // Check if we're near the end of the video - if so, this is likely
// end-of-stream rather than a real error. Jellyfin transcoded HLS // end-of-stream rather than a real error. Jellyfin transcoded HLS
// streams may not always terminate cleanly with #EXT-X-ENDLIST. // streams may not always terminate cleanly with #EXT-X-ENDLIST.
const knownDuration = media?.runTimeTicks ? media.runTimeTicks / 10_000_000 : videoDuration; const knownDuration = media?.durationMs ? media.durationMs / 1000 : videoDuration;
const effectiveTime = currentTime + seekOffset; const effectiveTime = currentTime + seekOffset;
const isNearEnd = knownDuration > 0 && effectiveTime > 0 && effectiveTime / knownDuration > 0.9; const isNearEnd = knownDuration > 0 && effectiveTime > 0 && effectiveTime / knownDuration > 0.9;
@@ -520,7 +520,7 @@
// Build subtitle tracks for native player // Build subtitle tracks for native player
const subtitleTracks = []; const subtitleTracks = [];
if (media.mediaStreams && mediaSourceId) { if (media.mediaStreams && mediaSourceId) {
const subtitles = media.mediaStreams.filter(s => s.type === "Subtitle"); const subtitles = media.mediaStreams.filter(s => s.kind === "subtitle");
for (const sub of subtitles) { for (const sub of subtitles) {
try { try {
const url = await getSubtitleUrl(sub.index); const url = await getSubtitleUrl(sub.index);
@@ -1223,7 +1223,7 @@
needsTranscoding: false, needsTranscoding: false,
// Now-playing metadata so the lockscreen/miniplayer show the item. // Now-playing metadata so the lockscreen/miniplayer show the item.
artist: media.seriesName ?? null, artist: media.seriesName ?? null,
primaryImageTag: media.primaryImageTag ?? null, primaryImageTag: media.imageId ?? null,
serverId: media.serverId ?? null, serverId: media.serverId ?? null,
// Real duration so the lockscreen scrubber has a range to draw. // Real duration so the lockscreen scrubber has a range to draw.
durationSeconds: duration > 0 ? duration : null, durationSeconds: duration > 0 ? duration : null,
@@ -1626,11 +1626,11 @@
{#if !isMediaReady} {#if !isMediaReady}
<div class="absolute inset-0 flex items-center justify-center bg-black"> <div class="absolute inset-0 flex items-center justify-center bg-black">
<!-- Poster/Title Card --> <!-- Poster/Title Card -->
{#if media?.primaryImageTag} {#if media?.imageId}
<CachedImage <CachedImage
itemId={media.id} itemId={media.id}
imageType="Primary" imageType="Primary"
tag={media.primaryImageTag} tag={media.imageId}
maxHeight={1080} maxHeight={1080}
alt={media?.name || "Video"} alt={media?.name || "Video"}
class="max-w-full max-h-full object-contain" class="max-w-full max-h-full object-contain"
@@ -121,11 +121,11 @@
class="w-full flex items-center gap-3 p-3 hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors disabled:opacity-50" class="w-full flex items-center gap-3 p-3 hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors disabled:opacity-50"
> >
<div class="w-10 h-10 flex-shrink-0 rounded overflow-hidden"> <div class="w-10 h-10 flex-shrink-0 rounded overflow-hidden">
{#if playlist.primaryImageTag} {#if playlist.imageId}
<CachedImage <CachedImage
itemId={playlist.id} itemId={playlist.id}
imageType="Primary" imageType="Primary"
tag={playlist.primaryImageTag} tag={playlist.imageId}
maxWidth={80} maxWidth={80}
alt={playlist.name} alt={playlist.name}
class="w-full h-full object-cover" class="w-full h-full object-cover"
+9 -9
View File
@@ -29,7 +29,7 @@ vi.mock("$lib/stores/auth", () => ({
getItem: vi.fn(async (id: string) => ({ getItem: vi.fn(async (id: string) => ({
id, id,
name: "Test Item", name: "Test Item",
runTimeTicks: 100000000, durationMs: 10000,
})), })),
})), })),
}, },
@@ -61,7 +61,7 @@ describe("playback reporting service", () => {
(c) => c[0] === "storage_update_playback_context" (c) => c[0] === "storage_update_playback_context"
); );
expect(call).toBeDefined(); expect(call).toBeDefined();
expect(call![1]).toHaveProperty("positionTicks", 600000000); // 60 seconds expect(call![1]).toHaveProperty("positionMs", 60000); // 60 seconds
}); });
it("should use single context by default", async () => { it("should use single context by default", async () => {
@@ -121,7 +121,7 @@ describe("playback reporting service", () => {
const call = invokeSpy.mock.calls.find( const call = invokeSpy.mock.calls.find(
(c) => c[0] === "storage_update_playback_progress" (c) => c[0] === "storage_update_playback_progress"
); );
expect(call![1]).toHaveProperty("positionTicks", 450000000); // 45 seconds expect(call![1]).toHaveProperty("positionMs", 45000); // 45 seconds
}); });
}); });
@@ -155,7 +155,7 @@ describe("playback reporting service", () => {
expect(mockRepo.reportPlaybackStopped).toHaveBeenCalled(); expect(mockRepo.reportPlaybackStopped).toHaveBeenCalled();
}); });
it("should convert seconds to ticks for server report", async () => { it("should convert seconds to milliseconds for server report", async () => {
const { auth } = await import("$lib/stores/auth"); const { auth } = await import("$lib/stores/auth");
const authModule = vi.mocked(auth); const authModule = vi.mocked(auth);
const mockRepo = { const mockRepo = {
@@ -167,7 +167,7 @@ describe("playback reporting service", () => {
expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith( expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith(
"item-123", "item-123",
900000000 // 90 seconds in ticks 90000 // 90 seconds in ms
); );
}); });
@@ -207,7 +207,7 @@ describe("playback reporting service", () => {
getItem: vi.fn(async () => ({ getItem: vi.fn(async () => ({
id: "item-123", id: "item-123",
name: "Item", name: "Item",
runTimeTicks: 100000000, durationMs: 10000,
})), })),
}; };
authModule.getRepository = vi.fn(() => mockRepo as any); authModule.getRepository = vi.fn(() => mockRepo as any);
@@ -217,11 +217,11 @@ describe("playback reporting service", () => {
expect(mockRepo.getItem).toHaveBeenCalledWith("item-123"); expect(mockRepo.getItem).toHaveBeenCalledWith("item-123");
expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith( expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith(
"item-123", "item-123",
100000000 10000
); );
}); });
it("should handle items without runTimeTicks", async () => { it("should handle items without durationMs", async () => {
const { auth } = await import("$lib/stores/auth"); const { auth } = await import("$lib/stores/auth");
const authModule = vi.mocked(auth); const authModule = vi.mocked(auth);
const mockRepo = { const mockRepo = {
@@ -229,7 +229,7 @@ describe("playback reporting service", () => {
getItem: vi.fn(async () => ({ getItem: vi.fn(async () => ({
id: "item-123", id: "item-123",
name: "Item", name: "Item",
runTimeTicks: null, durationMs: null,
})), })),
}; };
authModule.getRepository = vi.fn(() => mockRepo as any); authModule.getRepository = vi.fn(() => mockRepo as any);
+9 -9
View File
@@ -26,7 +26,7 @@ export async function reportPlaybackStart(
contextType: "container" | "single" = "single", contextType: "container" | "single" = "single",
contextId: string | null = null contextId: string | null = null
): Promise<void> { ): Promise<void> {
const positionTicks = Math.floor(positionSeconds * 10000000); const positionMs = Math.floor(positionSeconds * 1000);
const userId = auth.getUserId(); const userId = auth.getUserId();
console.log( console.log(
@@ -42,7 +42,7 @@ export async function reportPlaybackStart(
// Update local DB with context (always works, even offline) // Update local DB with context (always works, even offline)
if (userId) { if (userId) {
try { try {
await commands.storageUpdatePlaybackContext(userId, itemId, positionTicks, contextType, contextId); await commands.storageUpdatePlaybackContext(userId, itemId, positionMs, contextType, contextId);
} catch (e) { } catch (e) {
console.error("[PlaybackReporting] Failed to update playback context:", e); console.error("[PlaybackReporting] Failed to update playback context:", e);
} }
@@ -62,7 +62,7 @@ export async function reportPlaybackProgress(
positionSeconds: number, positionSeconds: number,
_isPaused = false _isPaused = false
): Promise<void> { ): Promise<void> {
const positionTicks = Math.floor(positionSeconds * 10000000); const positionMs = Math.floor(positionSeconds * 1000);
const userId = auth.getUserId(); const userId = auth.getUserId();
// Reduce logging for frequent progress updates // Reduce logging for frequent progress updates
@@ -73,7 +73,7 @@ export async function reportPlaybackProgress(
// Update local DB only (progress updates are frequent, don't report to server) // Update local DB only (progress updates are frequent, don't report to server)
if (userId) { if (userId) {
try { try {
await commands.storageUpdatePlaybackProgress(userId, itemId, positionTicks); await commands.storageUpdatePlaybackProgress(userId, itemId, positionMs);
} catch (e) { } catch (e) {
console.error("[PlaybackReporting] Failed to update local progress:", e); console.error("[PlaybackReporting] Failed to update local progress:", e);
} }
@@ -89,7 +89,7 @@ export async function reportPlaybackProgress(
* TRACES: UR-005, UR-025 | DR-028 * TRACES: UR-005, UR-025 | DR-028
*/ */
export async function reportPlaybackStopped(itemId: string, positionSeconds: number): Promise<void> { export async function reportPlaybackStopped(itemId: string, positionSeconds: number): Promise<void> {
const positionTicks = Math.floor(positionSeconds * 10000000); const positionMs = Math.floor(positionSeconds * 1000);
const userId = auth.getUserId(); const userId = auth.getUserId();
console.log("[PlaybackReporting] reportPlaybackStopped - itemId:", itemId, "positionSeconds:", positionSeconds); console.log("[PlaybackReporting] reportPlaybackStopped - itemId:", itemId, "positionSeconds:", positionSeconds);
@@ -97,7 +97,7 @@ export async function reportPlaybackStopped(itemId: string, positionSeconds: num
// Update local DB first (always works, even offline) // Update local DB first (always works, even offline)
if (userId) { if (userId) {
try { try {
await commands.storageUpdatePlaybackProgress(userId, itemId, positionTicks); await commands.storageUpdatePlaybackProgress(userId, itemId, positionMs);
} catch (e) { } catch (e) {
console.error("[PlaybackReporting] Failed to update local progress:", e); console.error("[PlaybackReporting] Failed to update local progress:", e);
} }
@@ -108,7 +108,7 @@ export async function reportPlaybackStopped(itemId: string, positionSeconds: num
try { try {
// Get the repository to check if we should queue // Get the repository to check if we should queue
const repo = auth.getRepository(); const repo = auth.getRepository();
await repo.reportPlaybackStopped(itemId, positionTicks); await repo.reportPlaybackStopped(itemId, positionMs);
} catch (e) { } catch (e) {
console.error("[PlaybackReporting] Failed to report to server:", e); console.error("[PlaybackReporting] Failed to report to server:", e);
// Server error - could queue, but for now just log // Server error - could queue, but for now just log
@@ -140,8 +140,8 @@ export async function markAsPlayed(itemId: string): Promise<void> {
const repo = auth.getRepository(); const repo = auth.getRepository();
const item = await repo.getItem(itemId); const item = await repo.getItem(itemId);
if (item.runTimeTicks) { if (item.durationMs) {
await repo.reportPlaybackStopped(itemId, item.runTimeTicks); await repo.reportPlaybackStopped(itemId, item.durationMs);
} }
} catch (e) { } catch (e) {
console.error("[PlaybackReporting] Failed to report as played:", e); console.error("[PlaybackReporting] Failed to report as played:", e);
@@ -73,8 +73,8 @@ function makeItem(overrides: Partial<MediaItem> = {}): MediaItem {
return { return {
id: "track-1", id: "track-1",
name: "Test Track", name: "Test Track",
type: "Audio", kind: "track",
runTimeTicks: null, durationMs: null,
...overrides, ...overrides,
} as MediaItem; } as MediaItem;
} }
@@ -102,7 +102,7 @@ describe("Player Events — pause must not zero the slider duration", () => {
it("preserves the live duration across pause when runTimeTicks is missing", async () => { it("preserves the live duration across pause when runTimeTicks is missing", async () => {
// runTimeTicks is null — the previous code recomputed duration as 0 here, // runTimeTicks is null — the previous code recomputed duration as 0 here,
// which collapsed the slider's max and snapped the thumb to the start. // which collapsed the slider's max and snapped the thumb to the start.
const item = makeItem({ runTimeTicks: null }); const item = makeItem({ durationMs: null });
currentQueueItemStore.set(item); currentQueueItemStore.set(item);
const { initPlayerEvents } = await import("./playerEvents"); const { initPlayerEvents } = await import("./playerEvents");
@@ -130,8 +130,8 @@ describe("Player Events — pause must not zero the slider duration", () => {
}); });
it("falls back to the runTimeTicks estimate when no live duration is known yet", async () => { it("falls back to the runTimeTicks estimate when no live duration is known yet", async () => {
// 70s in ticks (1 tick = 100ns) → 700_000_000. // 70 seconds = 70_000 ms.
const item = makeItem({ runTimeTicks: 700_000_000 }); const item = makeItem({ durationMs: 70_000 });
currentQueueItemStore.set(item); currentQueueItemStore.set(item);
const { initPlayerEvents } = await import("./playerEvents"); const { initPlayerEvents } = await import("./playerEvents");
+1 -1
View File
@@ -170,7 +170,7 @@ function handlePositionUpdate(position: number, duration: number): void {
* with 0 when runTimeTicks is missing (which would zero the slider's max). * with 0 when runTimeTicks is missing (which would zero the slider's max).
*/ */
function resolveDuration(currentItem: MediaItem, isSameTrack: boolean): number { function resolveDuration(currentItem: MediaItem, isSameTrack: boolean): number {
const estimate = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0; const estimate = currentItem.durationMs ? currentItem.durationMs / 1000 : 0;
if (isSameTrack) { if (isSameTrack) {
const live = get(playbackDuration); const live = get(playbackDuration);
if (live > 0) { if (live > 0) {
+3 -3
View File
@@ -104,12 +104,12 @@ class SyncService {
*/ */
async queuePlaybackProgress( async queuePlaybackProgress(
itemId: string, itemId: string,
positionTicks: number positionMs: number
): Promise<number> { ): Promise<number> {
// Update local state first // Update local state first
await commands.storageUpdatePlaybackProgress(auth.getUserId() ?? "", itemId, positionTicks); await commands.storageUpdatePlaybackProgress(auth.getUserId() ?? "", itemId, positionMs);
return this.queueMutation("update_progress", itemId, { positionTicks }); return this.queueMutation("update_progress", itemId, { positionMs });
} }
/** /**
+1 -1
View File
@@ -202,7 +202,7 @@ function createLibraryStore() {
const repo = auth.getRepository(); const repo = auth.getRepository();
const item = await repo.getItem(itemId); const item = await repo.getItem(itemId);
console.log(`[LibraryStore] loadItem(${itemId}): ${item.name} (${item.type})`); console.log(`[LibraryStore] loadItem(${itemId}): ${item.name} (${item.kind})`);
console.log(`[LibraryStore] - Has people? ${item.people ? `YES (${item.people.length})` : 'NO'}`); console.log(`[LibraryStore] - Has people? ${item.people ? `YES (${item.people.length})` : 'NO'}`);
if (item.people && item.people.length > 0) { if (item.people && item.people.length > 0) {
item.people.forEach((p, i) => { item.people.forEach((p, i) => {
+1 -1
View File
@@ -45,7 +45,7 @@ function createMoviesStore() {
/** Artwork check for hero candidates: needs a backdrop or a primary image. */ /** Artwork check for hero candidates: needs a backdrop or a primary image. */
const hasArt = (i: MediaItem) => const hasArt = (i: MediaItem) =>
!!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!i.primaryImageTag; !!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!i.imageId;
async function loadSections(libraryId: string) { async function loadSections(libraryId: string) {
update(s => ({ update(s => ({
+1 -1
View File
@@ -55,7 +55,7 @@ function createMusicStore() {
/** Artwork check for hero candidates: needs a primary image or backdrop. */ /** Artwork check for hero candidates: needs a primary image or backdrop. */
const hasArt = (i: MediaItem) => const hasArt = (i: MediaItem) =>
!!i.primaryImageTag || !!(i.backdropImageTags && i.backdropImageTags.length > 0); !!i.imageId || !!(i.backdropImageTags && i.backdropImageTags.length > 0);
async function loadSections(libraryId: string) { async function loadSections(libraryId: string) {
update(s => ({ update(s => ({
+15 -7
View File
@@ -9,7 +9,7 @@
*/ */
import { writable, derived } from "svelte/store"; import { writable, derived } from "svelte/store";
import type { MediaItem, ItemType } from "$lib/api/types"; import type { MediaItem, MediaKind } from "$lib/api/types";
import type { NowPlayingItem } from "$lib/api/bindings"; import type { NowPlayingItem } from "$lib/api/bindings";
import { isRemoteMode } from "./playbackMode"; import { isRemoteMode } from "./playbackMode";
import { selectedSession } from "./sessions"; import { selectedSession } from "./sessions";
@@ -24,7 +24,7 @@ export interface MergedMediaItem {
album: string | null; album: string | null;
albumId: string | null; albumId: string | null;
duration: number | null; duration: number | null;
primaryImageTag: string | null; imageId: string | null;
mediaType: "audio" | "video"; mediaType: "audio" | "video";
} }
@@ -170,16 +170,24 @@ export const isMuted = derived(player, ($p) => $p.muted);
* falls back to the `artists` string list when `artistItems` is absent.) * falls back to the `artists` string list when `artistItems` is absent.)
*/ */
function nowPlayingToMediaItem(npi: NowPlayingItem): MediaItem { function nowPlayingToMediaItem(npi: NowPlayingItem): MediaItem {
// NowPlayingItem is remote-session data still carrying Jellyfin field names
// (Type, runTimeTicks, primaryImageTag). Map it onto the neutral MediaItem the
// UI consumes. Only the coarse audio/video split matters here for display.
const kind: MediaKind =
npi.Type === "Movie" ? "movie" :
npi.Type === "Episode" ? "episode" :
npi.Type === "MusicAlbum" ? "album" :
"track";
return { return {
id: npi.id ?? "", id: npi.id ?? "",
name: npi.name ?? "", name: npi.name ?? "",
type: (npi.Type ?? "Audio") as ItemType, kind,
serverId: "", serverId: "",
albumName: npi.album, albumName: npi.album,
albumId: npi.albumId, albumId: npi.albumId,
artists: npi.artists, artists: npi.artists,
primaryImageTag: npi.primaryImageTag ?? npi.albumPrimaryImageTag, imageId: npi.primaryImageTag ?? npi.albumPrimaryImageTag,
runTimeTicks: npi.runTimeTicks, durationMs: npi.runTimeTicks != null ? Math.floor(npi.runTimeTicks / 10000) : null,
} as MediaItem; } as MediaItem;
} }
@@ -263,8 +271,8 @@ export const mergedVolume = derived(
*/ */
function isVideoItem(item: MediaItem | null): boolean { function isVideoItem(item: MediaItem | null): boolean {
if (!item) return false; if (!item) return false;
const type = item.type; const kind = item.kind;
if (type === "Movie" || type === "Episode" || type === "TvChannel") { if (kind === "movie" || kind === "episode" || kind === "liveChannel") {
return true; return true;
} }
// Backend PlayerMediaItem carries a lowercase mediaType discriminator that is // Backend PlayerMediaItem carries a lowercase mediaType discriminator that is
+4 -4
View File
@@ -27,8 +27,8 @@ vi.mock("./queue", () => ({ currentQueueItem }));
// Imported after the mocks so player.ts picks up the mocked stores. // Imported after the mocks so player.ts picks up the mocked stores.
const { player, shouldShowAudioMiniPlayer } = await import("./player"); const { player, shouldShowAudioMiniPlayer } = await import("./player");
const audioItem = { id: "a1", type: "Audio" } as unknown as MediaItem; const audioItem = { id: "a1", kind: "track" } as unknown as MediaItem;
const videoItem = { id: "v1", type: "Movie" } as unknown as MediaItem; const videoItem = { id: "v1", kind: "movie" } as unknown as MediaItem;
describe("shouldShowAudioMiniPlayer", () => { describe("shouldShowAudioMiniPlayer", () => {
beforeEach(() => { beforeEach(() => {
@@ -90,8 +90,8 @@ describe("shouldShowAudioMiniPlayer", () => {
}); });
it("hides for a live TV channel", () => { it("hides for a live TV channel", () => {
const channelItem = { id: "c1", type: "TvChannel" } as unknown as MediaItem; const liveChannelItem = { id: "c1", kind: "liveChannel" } as unknown as MediaItem;
player.setPlaying(channelItem, 0, 0); player.setPlaying(liveChannelItem, 0, 0);
expect(get(shouldShowAudioMiniPlayer)).toBe(false); expect(get(shouldShowAudioMiniPlayer)).toBe(false);
}); });
+2 -2
View File
@@ -50,7 +50,7 @@ function createTvStore() {
const hasArt = (i: MediaItem) => const hasArt = (i: MediaItem) =>
!!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!(i.backdropImageTags && i.backdropImageTags.length > 0) ||
!!(i.parentBackdropImageTags && i.parentBackdropImageTags.length > 0) || !!(i.parentBackdropImageTags && i.parentBackdropImageTags.length > 0) ||
!!i.primaryImageTag; !!i.imageId;
async function loadSections(libraryId: string) { async function loadSections(libraryId: string) {
update(s => ({ update(s => ({
@@ -81,7 +81,7 @@ function createTvStore() {
// Resume items are already video-only from the server, but keep episodes // Resume items are already video-only from the server, but keep episodes
// (and the occasional movie that lives in a mixed library) defensively. // (and the occasional movie that lives in a mixed library) defensively.
const continueWatching = resume.filter(i => i.type === "Episode" || i.type === "Movie"); const continueWatching = resume.filter(i => i.kind === "episode" || i.kind === "movie");
// Mix the hero: in-progress episodes first (most personal), then next-up, // Mix the hero: in-progress episodes first (most personal), then next-up,
// recent additions, and random series from across the library. // recent additions, and random series from across the library.
+16 -17
View File
@@ -8,36 +8,35 @@ import { describe, it, expect } from "vitest";
import { formatDuration, formatSecondsDuration } from "./duration"; import { formatDuration, formatSecondsDuration } from "./duration";
describe("formatDuration", () => { describe("formatDuration", () => {
it("should format duration from Jellyfin ticks (mm:ss format)", () => { it("should format duration from milliseconds (mm:ss format)", () => {
// 1 second = 10,000,000 ticks expect(formatDuration(1000)).toBe("0:01");
expect(formatDuration(10000000)).toBe("0:01"); expect(formatDuration(6000)).toBe("0:06");
expect(formatDuration(60000000)).toBe("0:06"); expect(formatDuration(60000)).toBe("1:00");
expect(formatDuration(600000000)).toBe("1:00"); expect(formatDuration(600000)).toBe("10:00");
expect(formatDuration(6000000000)).toBe("10:00"); expect(formatDuration(3661000)).toBe("61:01");
expect(formatDuration(36610000000)).toBe("61:01");
}); });
it("should format duration with hh:mm:ss format", () => { it("should format duration with hh:mm:ss format", () => {
// 1 hour = 3600 seconds = 36,000,000,000 ticks // 1 hour = 3600 seconds = 3,600,000 ms
expect(formatDuration(36000000000, "hh:mm:ss")).toBe("1:00:00"); expect(formatDuration(3600000, "hh:mm:ss")).toBe("1:00:00");
expect(formatDuration(36100000000, "hh:mm:ss")).toBe("1:00:10"); expect(formatDuration(3610000, "hh:mm:ss")).toBe("1:00:10");
expect(formatDuration(36610000000, "hh:mm:ss")).toBe("1:01:01"); expect(formatDuration(3661000, "hh:mm:ss")).toBe("1:01:01");
}); });
it("should return empty string for undefined or 0 ticks", () => { it("should return empty string for undefined or 0 duration", () => {
expect(formatDuration(undefined)).toBe(""); expect(formatDuration(undefined)).toBe("");
expect(formatDuration(0)).toBe(""); expect(formatDuration(0)).toBe("");
}); });
it("should pad seconds with leading zero", () => { it("should pad seconds with leading zero", () => {
expect(formatDuration(5000000)).toBe("0:00"); expect(formatDuration(500)).toBe("0:00");
expect(formatDuration(50000000)).toBe("0:05"); expect(formatDuration(5000)).toBe("0:05");
expect(formatDuration(150000000)).toBe("0:15"); expect(formatDuration(15000)).toBe("0:15");
}); });
it("should handle large durations", () => { it("should handle large durations", () => {
// 2 hours 30 minutes 45 seconds = 9045 seconds * 10,000,000 ticks/second // 2 hours 30 minutes 45 seconds = 9045 seconds = 9,045,000 ms
expect(formatDuration(90450000000, "hh:mm:ss")).toBe("2:30:45"); expect(formatDuration(9045000, "hh:mm:ss")).toBe("2:30:45");
}); });
}); });
+10 -10
View File
@@ -1,21 +1,21 @@
/** /**
* Duration formatting utilities * Duration formatting utilities.
* *
* Jellyfin uses "ticks" for duration where 10,000,000 ticks = 1 second * Durations are milliseconds the app's neutral time unit. The backend has
* already converted any provider unit (e.g. Jellyfin ticks) before it reaches
* the frontend, so no tick arithmetic lives here.
*/ */
/** /**
* Convert Jellyfin ticks to formatted duration string * Convert a millisecond duration to a formatted string.
* @param ticks Duration in Jellyfin ticks (10M ticks = 1 second) * @param ms Duration in milliseconds
* @param format Format type: "mm:ss" (default) or "hh:mm:ss" * @param format Format type: "mm:ss" (default) or "hh:mm:ss"
* @returns Formatted duration string or empty string if no ticks * @returns Formatted duration string or empty string if no duration
*/ */
export function formatDuration(ticks?: number | null, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string { export function formatDuration(ms?: number | null, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string {
if (!ticks) return ""; if (!ms) return "";
// Jellyfin uses 10,000,000 ticks per second const totalSeconds = Math.floor(ms / 1000);
const TICKS_PER_SECOND = 10000000;
const totalSeconds = Math.floor(ticks / TICKS_PER_SECOND);
if (format === "hh:mm:ss") { if (format === "hh:mm:ss") {
const hours = Math.floor(totalSeconds / 3600); const hours = Math.floor(totalSeconds / 3600);
-138
View File
@@ -1,138 +0,0 @@
/**
* Jellyfin Field Mapping Tests
*/
import { describe, it, expect } from "vitest";
import {
SORT_FIELD_MAP,
getJellyfinSortField,
normalizeSortOrder,
ITEM_TYPES,
ITEM_TYPE_GROUPS,
} from "./jellyfinFieldMapping";
describe("Jellyfin Field Mapping", () => {
describe("SORT_FIELD_MAP", () => {
it("should map frontend sort keys to Jellyfin fields", () => {
expect(SORT_FIELD_MAP.title).toBe("SortName");
expect(SORT_FIELD_MAP.artist).toBe("Artist");
expect(SORT_FIELD_MAP.album).toBe("Album");
expect(SORT_FIELD_MAP.year).toBe("ProductionYear");
expect(SORT_FIELD_MAP.recent).toBe("DatePlayed");
expect(SORT_FIELD_MAP.added).toBe("DateCreated");
expect(SORT_FIELD_MAP.rating).toBe("CommunityRating");
});
it("should have all common audio sorts", () => {
expect(SORT_FIELD_MAP).toHaveProperty("title");
expect(SORT_FIELD_MAP).toHaveProperty("artist");
expect(SORT_FIELD_MAP).toHaveProperty("album");
expect(SORT_FIELD_MAP).toHaveProperty("year");
expect(SORT_FIELD_MAP).toHaveProperty("recent");
});
it("should have fallback sort names", () => {
expect(SORT_FIELD_MAP.name).toBe("SortName");
});
it("should map aliases to same fields", () => {
expect(SORT_FIELD_MAP.title).toBe(SORT_FIELD_MAP.name);
expect(SORT_FIELD_MAP.recent).toBe("DatePlayed");
expect(SORT_FIELD_MAP.dateAdded).toBe("DateCreated");
expect(SORT_FIELD_MAP.datePlayed).toBe("DatePlayed");
});
});
describe("getJellyfinSortField()", () => {
it("should return mapped field for known keys", () => {
expect(getJellyfinSortField("artist")).toBe("Artist");
expect(getJellyfinSortField("album")).toBe("Album");
expect(getJellyfinSortField("year")).toBe("ProductionYear");
});
it("should fallback to SortName for unknown keys", () => {
expect(getJellyfinSortField("unknown")).toBe("SortName");
expect(getJellyfinSortField("")).toBe("SortName");
expect(getJellyfinSortField("invalidKey")).toBe("SortName");
});
it("should be case-sensitive", () => {
// Should work with exact case
expect(getJellyfinSortField("title")).toBe("SortName");
// Unknown case variations fallback to default
expect(getJellyfinSortField("Title")).toBe("SortName");
expect(getJellyfinSortField("TITLE")).toBe("SortName");
});
});
describe("normalizeSortOrder()", () => {
it("should accept valid ascending orders", () => {
expect(normalizeSortOrder("Ascending")).toBe("Ascending");
expect(normalizeSortOrder("ascending")).toBe("Ascending");
expect(normalizeSortOrder("asc")).toBe("Ascending");
expect(normalizeSortOrder(undefined)).toBe("Ascending");
});
it("should accept valid descending orders", () => {
expect(normalizeSortOrder("Descending")).toBe("Descending");
expect(normalizeSortOrder("descending")).toBe("Descending");
expect(normalizeSortOrder("desc")).toBe("Descending");
});
it("should default to Ascending for unknown values", () => {
expect(normalizeSortOrder("invalid")).toBe("Ascending");
expect(normalizeSortOrder("random")).toBe("Ascending");
expect(normalizeSortOrder("")).toBe("Ascending");
});
});
describe("ITEM_TYPES", () => {
it("should define audio types", () => {
expect(ITEM_TYPES.AUDIO).toBe("Audio");
expect(ITEM_TYPES.MUSIC_ALBUM).toBe("MusicAlbum");
expect(ITEM_TYPES.MUSIC_ARTIST).toBe("MusicArtist");
});
it("should define video types", () => {
expect(ITEM_TYPES.MOVIE).toBe("Movie");
expect(ITEM_TYPES.SERIES).toBe("Series");
expect(ITEM_TYPES.EPISODE).toBe("Episode");
});
it("should have consistent case", () => {
// Jellyfin API uses CamelCase
expect(ITEM_TYPES.MUSIC_ALBUM).toBe("MusicAlbum");
expect(ITEM_TYPES.MUSIC_ARTIST).toBe("MusicArtist");
expect(ITEM_TYPES.MUSIC_VIDEO).toBe("MusicVideo");
});
});
describe("ITEM_TYPE_GROUPS", () => {
it("should group audio types correctly", () => {
expect(ITEM_TYPE_GROUPS.audio).toContain(ITEM_TYPES.AUDIO);
expect(ITEM_TYPE_GROUPS.audio).toContain(ITEM_TYPES.MUSIC_ALBUM);
expect(ITEM_TYPE_GROUPS.audio).toContain(ITEM_TYPES.MUSIC_ARTIST);
expect(ITEM_TYPE_GROUPS.audio.length).toBe(3);
});
it("should group video types correctly", () => {
expect(ITEM_TYPE_GROUPS.video).toContain(ITEM_TYPES.MOVIE);
expect(ITEM_TYPE_GROUPS.video).toContain(ITEM_TYPES.SERIES);
expect(ITEM_TYPE_GROUPS.video).toContain(ITEM_TYPES.EPISODE);
});
it("should provide movie and TV show subgroups", () => {
expect(ITEM_TYPE_GROUPS.movies).toEqual([ITEM_TYPES.MOVIE]);
expect(ITEM_TYPE_GROUPS.tvshows).toContain(ITEM_TYPES.SERIES);
expect(ITEM_TYPE_GROUPS.tvshows).toContain(ITEM_TYPES.EPISODE);
});
it("should have music alias for audio", () => {
expect(ITEM_TYPE_GROUPS.music).toEqual(ITEM_TYPE_GROUPS.audio);
});
it("should provide episodes filter", () => {
expect(ITEM_TYPE_GROUPS.episodes).toEqual([ITEM_TYPES.EPISODE]);
});
});
});
-95
View File
@@ -1,95 +0,0 @@
/**
* Jellyfin Field Mapping
*
* Maps frontend sort option keys to Jellyfin API field names.
* This provides the single source of truth for how different UI sort options
* translate to backend database queries.
*/
/**
* Maps friendly sort names to Jellyfin API field names
* Used by all library views for consistent sorting
*/
export const SORT_FIELD_MAP = {
// Default/fallback sorts
title: "SortName",
name: "SortName",
// Audio-specific sorts
artist: "Artist",
album: "Album",
year: "ProductionYear",
recent: "DatePlayed",
added: "DateCreated",
rating: "CommunityRating",
duration: "RunTimeTicks",
// Video-specific sorts
dateAdded: "DateCreated",
datePlayed: "DatePlayed",
IMDBRating: "CommunityRating",
// Video series sorts
premiered: "PremiereDate",
episodeCount: "ChildCount",
} as const;
/**
* Type-safe sort field names
*/
export type SortField = keyof typeof SORT_FIELD_MAP;
/**
* Get Jellyfin API field name for a frontend sort key
* @param key Frontend sort key (e.g., "artist")
* @returns Jellyfin field name (e.g., "Artist")
*/
export function getJellyfinSortField(key: string): string {
const field = SORT_FIELD_MAP[key as SortField];
return field || "SortName"; // Fallback to title sort
}
/**
* Validate sort order string
* @param order Sort order value
* @returns Valid sort order for Jellyfin API
*/
export function normalizeSortOrder(order: string | undefined): "Ascending" | "Descending" {
if (order === "Descending" || order === "desc" || order === "descending") {
return "Descending";
}
return "Ascending";
}
/**
* Jellyfin ItemType constants for filtering
* Used in getItems() and search() calls
*/
export const ITEM_TYPES = {
// Audio types
AUDIO: "Audio",
MUSIC_ALBUM: "MusicAlbum",
MUSIC_ARTIST: "MusicArtist",
MUSIC_VIDEO: "MusicVideo",
// Video types
MOVIE: "Movie",
SERIES: "Series",
SEASON: "Season",
EPISODE: "Episode",
// Playlist
PLAYLIST: "Playlist",
} as const;
/**
* Predefined item type groups for easy filtering
*/
export const ITEM_TYPE_GROUPS = {
audio: [ITEM_TYPES.AUDIO, ITEM_TYPES.MUSIC_ALBUM, ITEM_TYPES.MUSIC_ARTIST],
music: [ITEM_TYPES.AUDIO, ITEM_TYPES.MUSIC_ALBUM, ITEM_TYPES.MUSIC_ARTIST],
video: [ITEM_TYPES.MOVIE, ITEM_TYPES.SERIES, ITEM_TYPES.EPISODE],
movies: [ITEM_TYPES.MOVIE],
tvshows: [ITEM_TYPES.SERIES, ITEM_TYPES.SEASON, ITEM_TYPES.EPISODE],
episodes: [ITEM_TYPES.EPISODE],
} as const;
+31
View File
@@ -0,0 +1,31 @@
// Presentation helpers for the neutral MediaKind.
//
// MediaKind is the app's provider-neutral item classification (defined in Rust,
// generated into bindings). This module holds *display* concerns over it —
// human-readable labels — which are presentation, not domain, and so live in
// the frontend.
import type { MediaKind } from "$lib/api/types";
const KIND_LABELS: Record<MediaKind, string> = {
track: "Song",
album: "Album",
artist: "Artist",
playlist: "Playlist",
movie: "Movie",
series: "Series",
season: "Season",
episode: "Episode",
person: "Person",
channel: "Channel",
liveChannel: "Live TV",
channelItem: "Channel",
folder: "Folder",
other: "",
};
/** Human-readable label for a media kind (e.g. "Album"), or "" if unknown. */
export function kindLabel(kind: MediaKind | null | undefined): string {
if (!kind) return "";
return KIND_LABELS[kind] ?? "";
}
+13 -11
View File
@@ -1,29 +1,31 @@
/** /**
* Playback unit conversion utilities * Playback unit utilities.
* *
* Jellyfin uses "ticks" for time values where 10 million ticks = 1 second. * The app's own media model speaks milliseconds (see the domain migration in
* This module provides type-safe conversion functions to eliminate magic numbers * docs/specs/frontend-domain-model.md), so catalog code does NOT use the tick
* and prevent conversion bugs across the codebase. * helpers here. Ticks survive only at the **remote Jellyfin session boundary**
* `SessionInfo.playState.positionTicks` and `NowPlayingItem.runTimeTicks` arrive
* straight from a live Jellyfin session and are converted here for display.
* `formatTime`/`calculateProgress` are plain seconds-based presentation helpers.
*/ */
/** /**
* Number of Jellyfin ticks per second (10 million) * Number of Jellyfin ticks per second (10 million).
*
* Only for the remote-session boundary (see module doc); catalog durations are
* milliseconds and never touch this.
*/ */
export const TICKS_PER_SECOND = 10_000_000; export const TICKS_PER_SECOND = 10_000_000;
/** /**
* Convert seconds to Jellyfin ticks * Convert seconds to Jellyfin ticks. Remote-session boundary only.
* @param seconds - Time in seconds (e.g., 90.5 for 1 minute 30.5 seconds)
* @returns Time in Jellyfin ticks
*/ */
export function secondsToTicks(seconds: number): number { export function secondsToTicks(seconds: number): number {
return Math.floor(seconds * TICKS_PER_SECOND); return Math.floor(seconds * TICKS_PER_SECOND);
} }
/** /**
* Convert Jellyfin ticks to seconds * Convert Jellyfin session ticks to seconds. Remote-session boundary only.
* @param ticks - Time in Jellyfin ticks
* @returns Time in seconds
*/ */
export function ticksToSeconds(ticks: number | null | undefined): number { export function ticksToSeconds(ticks: number | null | undefined): number {
return (ticks ?? 0) / TICKS_PER_SECOND; return (ticks ?? 0) / TICKS_PER_SECOND;
+8 -9
View File
@@ -57,14 +57,13 @@
}); });
function handleItemClick(item: MediaItem) { function handleItemClick(item: MediaItem) {
switch (item.type) { switch (item.kind) {
case "Series": case "series":
case "Season": case "season":
case "MusicAlbum": case "album":
case "MusicArtist": case "artist":
case "Folder": case "folder":
case "Channel": case "channel":
case "ChannelFolderItem":
goto(`/library/${item.id}`); goto(`/library/${item.id}`);
break; break;
default: default:
@@ -100,7 +99,7 @@
const heroItems = $derived($home.heroItems); const heroItems = $derived($home.heroItems);
const resumeItems = $derived($home.resumeItems.filter( const resumeItems = $derived($home.resumeItems.filter(
i => i.type === "Movie" || i.type === "Episode" i => i.kind === "movie" || i.kind === "episode"
)); ));
const nextUpItems = $derived($home.nextUpItems); const nextUpItems = $derived($home.nextUpItems);
const latestItems = $derived($home.latestItems); const latestItems = $derived($home.latestItems);
+13 -16
View File
@@ -127,30 +127,27 @@
// Prevent accidental taps during scrolling (Android) // Prevent accidental taps during scrolling (Android)
if (scrollGuard.isScrollActive()) return; if (scrollGuard.isScrollActive()) return;
if ("type" in item) { if ("kind" in item) {
// It's a MediaItem // It's a MediaItem
const mediaItem = item as MediaItem; const mediaItem = item as MediaItem;
// A ChannelFolderItem can be a folder (drill in) or a playable leaf. // A playable channel leaf plays directly; a channel container drills in.
if (mediaItem.type === "ChannelFolderItem" && !mediaItem.isFolder) { if (mediaItem.kind === "channelItem") {
goto(`/player/${mediaItem.id}`); goto(`/player/${mediaItem.id}`);
return; return;
} }
switch (mediaItem.type) { switch (mediaItem.kind) {
case "Series": case "series":
case "Movie": case "movie":
case "MusicAlbum": case "album":
case "MusicArtist": case "artist":
case "Folder": case "folder":
case "CollectionFolder": case "playlist":
case "Playlist": case "channel":
case "Channel":
case "ChannelFolderItem":
// Navigate to detail view // Navigate to detail view
goto(`/library/${mediaItem.id}`); goto(`/library/${mediaItem.id}`);
break; break;
case "Episode": case "episode":
case "TvChannel": // Episodes play directly
// Episodes and live TV channels play directly
goto(`/player/${mediaItem.id}`); goto(`/player/${mediaItem.id}`);
break; break;
default: default:
+54 -54
View File
@@ -4,6 +4,7 @@
import { page } from "$app/stores"; import { page } from "$app/stores";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation"; import { navigateBack } from "$lib/utils/navigation";
import { kindLabel } from "$lib/utils/mediaKind";
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
import type { MediaItem, Library } from "$lib/api/types"; import type { MediaItem, Library } from "$lib/api/types";
import { library, libraryItems, isLibraryLoading, currentLibrary, libraries } from "$lib/stores/library"; import { library, libraryItems, isLibraryLoading, currentLibrary, libraries } from "$lib/stores/library";
@@ -84,7 +85,7 @@
try { try {
item = await library.loadItem(itemId); item = await library.loadItem(itemId);
console.log(`[LibraryDetail] ✓ Loaded item: ${item?.name} (${item?.type})`); console.log(`[LibraryDetail] ✓ Loaded item: ${item?.name} (${item?.kind})`);
console.log(`[LibraryDetail] - Has people? ${item?.people ? `YES (${item.people.length})` : 'NO'}`); console.log(`[LibraryDetail] - Has people? ${item?.people ? `YES (${item.people.length})` : 'NO'}`);
if (item?.people) { if (item?.people) {
item.people.forEach((p, i) => { item.people.forEach((p, i) => {
@@ -94,7 +95,7 @@
// Set currentLibrary for music items if not already set // Set currentLibrary for music items if not already set
// This ensures navigation to music library pages works correctly // This ensures navigation to music library pages works correctly
if ((item?.type === "MusicAlbum" || item?.type === "MusicArtist" || item?.type === "Audio") && !$currentLibrary) { if ((item?.kind === "album" || item?.kind === "artist" || item?.kind === "track") && !$currentLibrary) {
// Find the music library // Find the music library
if ($libraries.length === 0) { if ($libraries.length === 0) {
await library.loadLibraries(); await library.loadLibraries();
@@ -110,8 +111,8 @@
// Ensure cast/crew data is loaded for Movies, Series, and Episodes // Ensure cast/crew data is loaded for Movies, Series, and Episodes
// Some APIs/caches may not include people data on first load // Some APIs/caches may not include people data on first load
if ((item?.type === "Movie" || item?.type === "Series" || item?.type === "Episode") && (!item.people || item.people.length === 0)) { if ((item?.kind === "movie" || item?.kind === "series" || item?.kind === "episode") && (!item.people || item.people.length === 0)) {
console.log(`[LibraryDetail] ⚠ People data missing, reloading ${item?.type}...`); console.log(`[LibraryDetail] ⚠ People data missing, reloading ${item?.kind}...`);
try { try {
const repo = auth.getRepository(); const repo = auth.getRepository();
const fullItem = await repo.getItem(itemId); const fullItem = await repo.getItem(itemId);
@@ -124,20 +125,20 @@
}); });
} }
} catch (e) { } catch (e) {
console.warn(`Could not reload ${item?.type} with full cast data:`, e); console.warn(`Could not reload ${item?.kind} with full cast data:`, e);
} }
} }
// For Series, load seasons and their episodes // For Series, load seasons and their episodes
if (item?.type === "Series") { if (item?.kind === "series") {
const seasons = $libraryItems.filter((i) => i.type === "Season"); const seasons = $libraryItems.filter((i) => i.kind === "season");
const repo = auth.getRepository(); const repo = auth.getRepository();
// Load episodes for each season in parallel // Load episodes for each season in parallel
const seasonDataPromises = seasons.map(async (season) => { const seasonDataPromises = seasons.map(async (season) => {
const result = await repo.getItems(season.id, { limit: 100 }); const result = await repo.getItems(season.id, { limit: 100 });
const episodes = result.items const episodes = result.items
.filter((i) => i.type === "Episode") .filter((i) => i.kind === "episode")
.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0)); .sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0));
return { season, episodes }; return { season, episodes };
}); });
@@ -170,9 +171,9 @@
// Images now handled by CachedImage component // Images now handled by CachedImage component
function formatDuration(ticks?: number): string { function formatDuration(ms?: number | null): string {
if (!ticks) return ""; if (!ms) return "";
const seconds = Math.floor(ticks / 10000000); const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
@@ -183,28 +184,27 @@
} }
function handleItemClick(clickedItem: MediaItem | Library) { function handleItemClick(clickedItem: MediaItem | Library) {
if (!("type" in clickedItem)) { if (!("kind" in clickedItem)) {
// Library item - navigate to library // Library item - navigate to library
goto(`/library/${clickedItem.id}`); goto(`/library/${clickedItem.id}`);
return; return;
} }
// A ChannelFolderItem can be either a folder (drill in) or a playable leaf. // A ChannelFolderItem can be either a folder (drill in) or a playable leaf.
// Route non-folder channel items straight to the player. // Route non-folder channel items straight to the player.
if (clickedItem.type === "ChannelFolderItem" && !clickedItem.isFolder) { if (clickedItem.kind === "channelItem" || clickedItem.kind === "liveChannel") {
goto(`/player/${clickedItem.id}`); goto(`/player/${clickedItem.id}`);
return; return;
} }
switch (clickedItem.type) { switch (clickedItem.kind) {
case "Series": case "series":
case "Season": case "season":
case "MusicAlbum": case "album":
case "MusicArtist": case "artist":
case "Folder": case "folder":
case "Playlist": case "playlist":
case "Channel": case "channel":
case "ChannelFolderItem": case "episode":
case "Episode": case "movie":
case "Movie":
goto(`/library/${clickedItem.id}`); goto(`/library/${clickedItem.id}`);
break; break;
default: default:
@@ -223,9 +223,9 @@
async function handlePlayAll() { async function handlePlayAll() {
// For single items (Episode, Movie), play the item directly // For single items (Episode, Movie), play the item directly
if (item?.type === "Episode" || item?.type === "Movie") { if (item?.kind === "episode" || item?.kind === "movie") {
goto(`/player/${itemId}`); goto(`/player/${itemId}`);
} else if (item?.type === "MusicAlbum" && $libraryItems.length > 0) { } else if (item?.kind === "album" && $libraryItems.length > 0) {
// For albums, use the backend command (backend fetches and queues all tracks) // For albums, use the backend command (backend fetches and queues all tracks)
try { try {
const repo = auth.getRepository(); const repo = auth.getRepository();
@@ -248,7 +248,7 @@
} }
async function handleShufflePlay() { async function handleShufflePlay() {
if (item?.type === "MusicAlbum" && $libraryItems.length > 0) { if (item?.kind === "album" && $libraryItems.length > 0) {
// For albums, use the backend command with shuffle // For albums, use the backend command with shuffle
try { try {
const repo = auth.getRepository(); const repo = auth.getRepository();
@@ -284,7 +284,7 @@
); );
const isMusicItem = $derived( const isMusicItem = $derived(
item?.type === "Audio" || item?.type === "MusicAlbum" || item?.type === "MusicArtist" || item?.type === "Playlist" item?.kind === "track" || item?.kind === "album" || item?.kind === "artist" || item?.kind === "playlist"
); );
function handleBackToSeries() { function handleBackToSeries() {
@@ -331,13 +331,13 @@
</div> </div>
{:else if item} {:else if item}
<!-- Person Detail View - shown for Person items --> <!-- Person Detail View - shown for Person items -->
{#if item.type === "Person"} {#if item.kind === "person"}
<div class="pt-4"> <div class="pt-4">
<BackButton onClick={goBack} label="Back" /> <BackButton onClick={goBack} label="Back" />
</div> </div>
<PersonDetailView person={item} /> <PersonDetailView person={item} />
<!-- Episode Focus View - shown when navigating with ?episode param --> <!-- Episode Focus View - shown when navigating with ?episode param -->
{:else if item.type === "Series" && focusedEpisode} {:else if item.kind === "series" && focusedEpisode}
<EpisodeFocusView <EpisodeFocusView
episode={focusedEpisode} episode={focusedEpisode}
series={item} series={item}
@@ -355,11 +355,11 @@
<div class="flex gap-6"> <div class="flex gap-6">
<!-- Poster --> <!-- Poster -->
<div class="flex-shrink-0 w-48"> <div class="flex-shrink-0 w-48">
{#if item.primaryImageTag} {#if item.imageId}
<CachedImage <CachedImage
itemId={item.id} itemId={item.id}
imageType="Primary" imageType="Primary"
tag={item.primaryImageTag} tag={item.imageId}
maxWidth={400} maxWidth={400}
alt={item.name} alt={item.name}
class="w-full {isMusicItem ? 'aspect-square' : ''} rounded-lg shadow-lg" class="w-full {isMusicItem ? 'aspect-square' : ''} rounded-lg shadow-lg"
@@ -381,7 +381,7 @@
<div class="flex-1 space-y-4"> <div class="flex-1 space-y-4">
<div> <div>
<h1 class="text-3xl font-bold text-white">{item.name}</h1> <h1 class="text-3xl font-bold text-white">{item.name}</h1>
{#if item.type === "Episode" && (item.parentIndexNumber || item.indexNumber)} {#if item.kind === "episode" && (item.parentIndexNumber || item.indexNumber)}
<p class="text-lg text-gray-400 mt-1"> <p class="text-lg text-gray-400 mt-1">
{#if item.parentIndexNumber}Season {item.parentIndexNumber}{/if} {#if item.parentIndexNumber}Season {item.parentIndexNumber}{/if}
{#if item.parentIndexNumber && item.indexNumber}, {/if} {#if item.parentIndexNumber && item.indexNumber}, {/if}
@@ -403,11 +403,11 @@
<!-- Metadata --> <!-- Metadata -->
<div class="flex items-center gap-4 text-sm text-gray-400"> <div class="flex items-center gap-4 text-sm text-gray-400">
{#if item.type} {#if kindLabel(item.kind)}
<span class="px-2 py-1 bg-[var(--color-surface)] rounded">{item.type}</span> <span class="px-2 py-1 bg-[var(--color-surface)] rounded">{kindLabel(item.kind)}</span>
{/if} {/if}
{#if item.runTimeTicks} {#if item.durationMs}
<span>{formatDuration(item.runTimeTicks)}</span> <span>{formatDuration(item.durationMs)}</span>
{/if} {/if}
{#if item.communityRating} {#if item.communityRating}
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
@@ -430,7 +430,7 @@
</svg> </svg>
Play Play
</button> </button>
{#if item.type !== "Episode" && item.type !== "Movie"} {#if item.kind !== "episode" && item.kind !== "movie"}
<button <button
onclick={handleShufflePlay} onclick={handleShufflePlay}
class="px-6 py-2 bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)] rounded-lg font-medium flex items-center gap-2 transition-colors" class="px-6 py-2 bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)] rounded-lg font-medium flex items-center gap-2 transition-colors"
@@ -441,26 +441,26 @@
Shuffle Shuffle
</button> </button>
{/if} {/if}
{#if item.type === "MusicAlbum"} {#if item.kind === "album"}
<AlbumDownloadButton <AlbumDownloadButton
albumId={item.id} albumId={item.id}
albumName={item.name} albumName={item.name}
tracks={$libraryItems} tracks={$libraryItems}
/> />
{:else if item.type === "Series"} {:else if item.kind === "series"}
<SeriesDownloadButton <SeriesDownloadButton
seriesId={item.id} seriesId={item.id}
seriesName={item.name} seriesName={item.name}
episodeCount={allEpisodes.length || undefined} episodeCount={allEpisodes.length || undefined}
/> />
{:else if item.type === "Movie"} {:else if item.kind === "movie"}
<VideoDownloadButton <VideoDownloadButton
itemId={item.id} itemId={item.id}
itemName={item.name} itemName={item.name}
isMovie={true} isMovie={true}
size="lg" size="lg"
/> />
{:else if item.type === "Episode"} {:else if item.kind === "episode"}
<VideoDownloadButton <VideoDownloadButton
itemId={item.id} itemId={item.id}
itemName={item.name} itemName={item.name}
@@ -478,7 +478,7 @@
</div> </div>
<!-- Crew Links - for Movies and Series --> <!-- Crew Links - for Movies and Series -->
{#if item.people && (item.type === "Movie" || item.type === "Series")} {#if item.people && (item.kind === "movie" || item.kind === "series")}
<div class="space-y-2"> <div class="space-y-2">
{#if item.people.some(p => p.type === "Director")} {#if item.people.some(p => p.type === "Director")}
<CrewLinks <CrewLinks
@@ -512,24 +512,24 @@
<!-- Genre Tags --> <!-- Genre Tags -->
{#if item.genres?.length} {#if item.genres?.length}
<div> <div>
<GenreTags genres={item.genres ?? undefined} maxShow={6} itemType={item.type} /> <GenreTags genres={item.genres ?? undefined} maxShow={6} itemKind={item.kind} />
</div> </div>
{/if} {/if}
<!-- Cast / Related — for Movies and Episodes these sit above the content <!-- Cast / Related — for Movies and Episodes these sit above the content
block; for Series they render *below* the seasons instead, so block; for Series they render *below* the seasons instead, so
continuation content precedes discovery content (UX §5B.4). --> continuation content precedes discovery content (UX §5B.4). -->
{#if item.type !== "Series"} {#if item.kind !== "series"}
<!-- Cast Section - for Movies and Episodes --> <!-- Cast Section - for Movies and Episodes -->
{#if (item.type === "Movie" || item.type === "Episode") && item.people?.length} {#if (item.kind === "movie" || item.kind === "episode") && item.people?.length}
<CastSection people={item.people ?? undefined} /> <CastSection people={item.people ?? undefined} />
{/if} {/if}
<!-- Related Items Section - for Movies --> <!-- Related Items Section - for Movies -->
{#if item.type === "Movie" && (item.genres?.length || item.people?.length)} {#if item.kind === "movie" && (item.genres?.length || item.people?.length)}
<RelatedItemsSection <RelatedItemsSection
currentItemId={item.id} currentItemId={item.id}
itemType={item.type} itemKind={item.kind}
genres={item.genres ?? undefined} genres={item.genres ?? undefined}
people={item.people ?? undefined} people={item.people ?? undefined}
limit={12} limit={12}
@@ -539,7 +539,7 @@
<!-- Content items --> <!-- Content items -->
<div> <div>
{#if item.type === "MusicAlbum"} {#if item.kind === "album"}
<!-- Tracks in list view --> <!-- Tracks in list view -->
<div class="space-y-8"> <div class="space-y-8">
<div class="space-y-4"> <div class="space-y-4">
@@ -558,14 +558,14 @@
{#if item.genres?.length || item.artistItems?.length} {#if item.genres?.length || item.artistItems?.length}
<RelatedItemsSection <RelatedItemsSection
currentItemId={item.id} currentItemId={item.id}
itemType="MusicAlbum" itemKind="album"
genres={item.genres ?? undefined} genres={item.genres ?? undefined}
artistIds={item.artistItems?.map(a => a.id)} artistIds={item.artistItems?.map(a => a.id)}
limit={12} limit={12}
/> />
{/if} {/if}
</div> </div>
{:else if item.type === "Series"} {:else if item.kind === "series"}
<!-- Series: Seasons with episodes --> <!-- Series: Seasons with episodes -->
<div class="space-y-8"> <div class="space-y-8">
{#if $isLibraryLoading} {#if $isLibraryLoading}
@@ -595,17 +595,17 @@
{#if item.genres?.length || item.people?.length} {#if item.genres?.length || item.people?.length}
<RelatedItemsSection <RelatedItemsSection
currentItemId={item.id} currentItemId={item.id}
itemType={item.type} itemKind={item.kind}
genres={item.genres ?? undefined} genres={item.genres ?? undefined}
people={item.people ?? undefined} people={item.people ?? undefined}
limit={12} limit={12}
/> />
{/if} {/if}
</div> </div>
{:else if item.type === "MusicArtist"} {:else if item.kind === "artist"}
<!-- Enhanced artist detail view with discography --> <!-- Enhanced artist detail view with discography -->
<ArtistDetailView artist={item} /> <ArtistDetailView artist={item} />
{:else if item.type === "Playlist"} {:else if item.kind === "playlist"}
<!-- Playlist detail view with track management --> <!-- Playlist detail view with track management -->
<PlaylistDetailView playlist={item} /> <PlaylistDetailView playlist={item} />
{:else} {:else}
+1 -1
View File
@@ -56,7 +56,7 @@
}); });
function handleItemClick(item: MediaItem) { function handleItemClick(item: MediaItem) {
if (item.type === "Folder") { if (item.kind === "folder") {
goto(`/library/${item.id}`); goto(`/library/${item.id}`);
} else { } else {
// Movies play directly. // Movies play directly.
+24 -25
View File
@@ -5,7 +5,7 @@
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
import type { PlayQueueRequest } from "$lib/api/bindings"; import type { PlayQueueRequest } from "$lib/api/bindings";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem, MediaKind } from "$lib/api/types";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { library } from "$lib/stores/library"; import { library } from "$lib/stores/library";
import { queue, currentQueueItem, isShuffle, repeatMode, hasNext as hasNextStore, hasPrevious as hasPreviousStore } from "$lib/stores/queue"; import { queue, currentQueueItem, isShuffle, repeatMode, hasNext as hasNextStore, hasPrevious as hasPreviousStore } from "$lib/stores/queue";
@@ -100,7 +100,7 @@
// Only for audio content - video content uses direct loading and shouldn't be affected by audio queue // Only for audio content - video content uses direct loading and shouldn't be affected by audio queue
$effect(() => { $effect(() => {
const queueItem = $currentQueueItem; const queueItem = $currentQueueItem;
const currentIsVideo = currentMedia?.type === "Movie" || currentMedia?.type === "Episode"; const currentIsVideo = currentMedia?.kind === "movie" || currentMedia?.kind === "episode";
if (queueItem && queueItem.id !== currentMedia?.id && !currentIsVideo) { if (queueItem && queueItem.id !== currentMedia?.id && !currentIsVideo) {
currentMedia = queueItem; currentMedia = queueItem;
} }
@@ -110,8 +110,8 @@
// treat it as video when it carries a video media stream. // treat it as video when it carries a video media stream.
function isVideoChannelItem(item: MediaItem): boolean { function isVideoChannelItem(item: MediaItem): boolean {
return ( return (
item.type === "ChannelFolderItem" && item.kind === "channelItem" &&
(item.mediaStreams?.some((s) => s.type === "Video") ?? false) (item.mediaStreams?.some((s) => s.kind === "video") ?? false)
); );
} }
@@ -125,14 +125,13 @@
console.log("loadAndPlay: Loading item", id); console.log("loadAndPlay: Loading item", id);
// Load item details // Load item details
const item = await library.loadItem(id); const item = await library.loadItem(id);
console.log("loadAndPlay: Loaded item", item.name, "type:", item.type); console.log("loadAndPlay: Loaded item", item.name, "kind:", item.kind);
currentMedia = item; currentMedia = item;
// Check if this is a non-playable collection type that should be viewed in library instead // Check if this is a non-playable collection type that should be viewed in library instead
const collectionTypes = ["MusicAlbum", "MusicArtist", "Series", "Season", "Folder", "CollectionFolder", "Playlist", "Channel"]; const collectionKinds: MediaKind[] = ["album", "artist", "series", "season", "folder", "playlist", "channel"];
// A ChannelFolderItem that is itself a folder is a container, not playable. if (item.kind && collectionKinds.includes(item.kind)) {
if (collectionTypes.includes(item.type) || (item.type === "ChannelFolderItem" && item.isFolder)) { console.log("loadAndPlay: Redirecting collection type to library:", item.kind);
console.log("loadAndPlay: Redirecting collection type to library:", item.type);
goto(`/library/${id}`); goto(`/library/${id}`);
return; return;
} }
@@ -144,8 +143,8 @@
const alreadyPlayingMedia = get(storeCurrentMedia); const alreadyPlayingMedia = get(storeCurrentMedia);
if (alreadyPlayingMedia?.id === id && !startPosition && !forceRestart) { if (alreadyPlayingMedia?.id === id && !startPosition && !forceRestart) {
console.log("loadAndPlay: Track already playing, showing UI without restarting"); console.log("loadAndPlay: Track already playing, showing UI without restarting");
isLive = item.type === "TvChannel"; isLive = item.kind === "liveChannel";
isVideo = item.type === "Movie" || item.type === "Episode" || isLive || isVideoChannelItem(item); isVideo = item.kind === "movie" || item.kind === "episode" || isLive || isVideoChannelItem(item);
isPlaying = true; isPlaying = true;
loading = false; loading = false;
// hasNext/hasPrevious come from the event-driven queue store. // hasNext/hasPrevious come from the event-driven queue store.
@@ -158,8 +157,8 @@
// Determine if this is video content (Movie, Episode, live TV channels, and // Determine if this is video content (Movie, Episode, live TV channels, and
// channel leaf items that carry a video stream). // channel leaf items that carry a video stream).
isLive = item.type === "TvChannel"; isLive = item.kind === "liveChannel";
isVideo = item.type === "Movie" || item.type === "Episode" || isLive || isVideoChannelItem(item); isVideo = item.kind === "movie" || item.kind === "episode" || isLive || isVideoChannelItem(item);
// When switching to video, stop audio playback and clear the queue // When switching to video, stop audio playback and clear the queue
// This prevents audio from continuing in the background and clears stale state // This prevents audio from continuing in the background and clears stale state
@@ -185,9 +184,9 @@
const progress = await commands.storageGetPlaybackProgress(userId, id); const progress = await commands.storageGetPlaybackProgress(userId, id);
console.log("Resume check - retrieved progress:", progress); console.log("Resume check - retrieved progress:", progress);
if (progress && progress.positionTicks > 0 && item.runTimeTicks) { if (progress && progress.positionMs > 0 && item.durationMs) {
const positionSeconds = progress.positionTicks / 10_000_000; const positionSeconds = progress.positionMs / 1000;
const totalSeconds = item.runTimeTicks / 10_000_000; const totalSeconds = item.durationMs / 1000;
const progressPercent = (positionSeconds / totalSeconds) * 100; const progressPercent = (positionSeconds / totalSeconds) * 100;
console.log("Resume check - positionSeconds:", positionSeconds, "totalSeconds:", totalSeconds, "progressPercent:", progressPercent); console.log("Resume check - positionSeconds:", positionSeconds, "totalSeconds:", totalSeconds, "progressPercent:", progressPercent);
@@ -206,7 +205,7 @@
console.log("Resume check - NOT showing dialog. Position > 30?", positionSeconds > 30, "Progress < 90?", progressPercent < 90); console.log("Resume check - NOT showing dialog. Position > 30?", positionSeconds > 30, "Progress < 90?", progressPercent < 90);
} }
} else { } else {
console.log("Resume check - No valid progress found. Has progress?", !!progress, "Has position?", progress?.positionTicks, "Has runtime?", !!item.runTimeTicks); console.log("Resume check - No valid progress found. Has progress?", !!progress, "Has position?", progress?.positionMs, "Has runtime?", !!item.durationMs);
} }
} catch (e) { } catch (e) {
console.error("Failed to check saved progress:", e); console.error("Failed to check saved progress:", e);
@@ -330,7 +329,7 @@
sortOrder: "Ascending", sortOrder: "Ascending",
limit: 500, limit: 500,
}); });
const audioTracks = result.items.filter(t => t.type === "Audio"); const audioTracks = result.items.filter(t => t.kind === "track");
if (audioTracks.length > 0) { if (audioTracks.length > 0) {
// Find the index of the current item in the tracks // Find the index of the current item in the tracks
@@ -354,9 +353,9 @@
title: t.name, title: t.name,
artist: t.artists?.join(", ") || null, artist: t.artists?.join(", ") || null,
album: t.albumName || null, album: t.albumName || null,
duration: t.runTimeTicks ? t.runTimeTicks / 10000000 : null, duration: t.durationMs ? t.durationMs / 1000 : null,
artworkUrl: t.primaryImageTag artworkUrl: t.imageId
? repo.getImageUrl(t.albumId || t.id, "Primary", { maxWidth: 300, tag: t.primaryImageTag }) ? repo.getImageUrl(t.albumId || t.id, "Primary", { maxWidth: 300, tag: t.imageId })
: null, : null,
mediaType: "audio", mediaType: "audio",
streamUrl: trackStreamUrl, streamUrl: trackStreamUrl,
@@ -428,7 +427,7 @@
loading = false; loading = false;
// Fetch next episode for video episodes (for skip button) // Fetch next episode for video episodes (for skip button)
console.log("[NextEpisode] Post-load check: isVideo=", isVideo, "currentMedia=", currentMedia?.type, currentMedia?.name); console.log("[NextEpisode] Post-load check: isVideo=", isVideo, "currentMedia=", currentMedia?.kind, currentMedia?.name);
if (isVideo && currentMedia) { if (isVideo && currentMedia) {
fetchNextEpisode(currentMedia); fetchNextEpisode(currentMedia);
} else { } else {
@@ -566,8 +565,8 @@
async function fetchNextEpisode(media: MediaItem) { async function fetchNextEpisode(media: MediaItem) {
nextEpisode = null; nextEpisode = null;
console.log("[NextEpisode] fetchNextEpisode called:", { type: media.type, seriesId: media.seriesId, seasonId: media.seasonId, indexNumber: media.indexNumber, id: media.id, name: media.name }); console.log("[NextEpisode] fetchNextEpisode called:", { kind: media.kind, seriesId: media.seriesId, seasonId: media.seasonId, indexNumber: media.indexNumber, id: media.id, name: media.name });
if (media.type !== "Episode" || !media.seasonId || media.indexNumber == null) { if (media.kind !== "episode" || !media.seasonId || media.indexNumber == null) {
console.log("[NextEpisode] Skipping - not an episode or missing seasonId/indexNumber"); console.log("[NextEpisode] Skipping - not an episode or missing seasonId/indexNumber");
return; return;
} }
@@ -575,7 +574,7 @@
const repo = auth.getRepository(); const repo = auth.getRepository();
// Fetch all episodes in the season sorted by episode number // Fetch all episodes in the season sorted by episode number
const result = await repo.getItems(media.seasonId, { sortBy: "IndexNumber", sortOrder: "Ascending", limit: 500 }); const result = await repo.getItems(media.seasonId, { sortBy: "IndexNumber", sortOrder: "Ascending", limit: 500 });
const episodes = result.items.filter(e => e.type === "Episode"); const episodes = result.items.filter(e => e.kind === "episode");
console.log("[NextEpisode] Season has", episodes.length, "episodes, current index:", media.indexNumber); console.log("[NextEpisode] Season has", episodes.length, "episodes, current index:", media.indexNumber);
// Find the episode after the current one by index number // Find the episode after the current one by index number