diff --git a/bun.lock b/bun.lock index 59980f7c..fb6d09df 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "@tailwindcss/vite": "^4.1.18", "@tauri-apps/cli": "^2", "@testing-library/svelte": "^5.3.1", + "@vitest/coverage-v8": "^4.0.18", "@vitest/ui": "^4.0.16", "@wdio/cli": "^9.5.0", "@wdio/local-runner": "^9.5.0", @@ -30,7 +31,7 @@ "tailwindcss": "^4.1.18", "typescript": "~5.6.2", "vite": "^6.0.3", - "vitest": "^4.0.16", + "vitest": ">=1.0.0 <5.0.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/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/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/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/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=="], + "@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/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/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=="], @@ -422,6 +433,8 @@ "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-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="], @@ -498,6 +511,8 @@ "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=="], "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-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "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=="], @@ -738,6 +755,12 @@ "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=="], "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=="], - "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=="], @@ -828,6 +851,10 @@ "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=="], "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=="], - "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=="], @@ -1042,7 +1069,7 @@ "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=="], @@ -1068,7 +1095,7 @@ "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=="], @@ -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=="], + "@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/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=="], + "@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/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=="], "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/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-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/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/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/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=="], @@ -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/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-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-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-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=="], "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/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/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/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/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=="], diff --git a/src-tauri/src/commands/player/mod.rs b/src-tauri/src/commands/player/mod.rs index d4f93b89..a3c39100 100644 --- a/src-tauri/src/commands/player/mod.rs +++ b/src-tauri/src/commands/player/mod.rs @@ -106,6 +106,8 @@ pub struct MergedMediaItem { pub album_id: Option, pub duration: Option, pub primary_image_tag: Option, + /// Neutral image identifier — replaces `primary_image_tag` (same value). + pub image_id: Option, pub media_type: String, } @@ -120,6 +122,7 @@ impl From<&crate::player::MediaItem> for MergedMediaItem { album_id: item.album_id.clone(), duration: item.duration, primary_image_tag: item.primary_image_tag.clone(), + image_id: item.primary_image_tag.clone(), media_type: match item.media_type { crate::player::MediaType::Audio => "audio".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(), duration: item.run_time_ticks.map(|ticks| ticks as f64 / 10_000_000.0), primary_image_tag: item.primary_image_tag.clone(), + image_id: item.primary_image_tag.clone(), media_type: item .item_type .clone() @@ -361,10 +365,11 @@ pub(super) async fn create_media_item( artist_items: 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 - item_type: None, // Not available from video-only request - playlist_id: None, // Not available from video-only request - duration: None, // Not available from video-only request - artwork_url: None, // Not available from video-only request + image_id: None, + item_type: None, // Not available from video-only request + playlist_id: 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 source, video_codec: Some(req.video_codec), @@ -595,6 +600,7 @@ pub async fn player_enter_background_audio( artist_items: None, artists: None, primary_image_tag: item.primary_image_tag.clone(), + image_id: item.primary_image_tag.clone(), item_type: None, playlist_id: None, // 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 artists: track.artists.clone(), // Fallback artist info 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 playlist_id: None, 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 artists: track.artists.clone(), // Fallback artist info 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 playlist_id: None, // Set based on context below duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0), @@ -2428,6 +2436,7 @@ mod tests { artist_items: None, artists: None, primary_image_tag: None, + image_id: None, item_type: None, playlist_id: None, duration: None, diff --git a/src-tauri/src/commands/player/queue.rs b/src-tauri/src/commands/player/queue.rs index b479620b..93a1f5f5 100644 --- a/src-tauri/src/commands/player/queue.rs +++ b/src-tauri/src/commands/player/queue.rs @@ -211,6 +211,7 @@ pub async fn player_add_track_by_id( artist_items: track.artist_items.clone(), // For clickable artist links artists: track.artists.clone(), // Fallback artist info 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 playlist_id: None, 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 artists: track.artists.clone(), // Fallback artist info 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 playlist_id: None, duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0), diff --git a/src-tauri/src/commands/repository.rs b/src-tauri/src/commands/repository.rs index 63d11cbd..6f2028ef 100644 --- a/src-tauri/src/commands/repository.rs +++ b/src-tauri/src/commands/repository.rs @@ -579,8 +579,9 @@ pub async fn repository_report_playback_start( manager: State<'_, RepositoryManagerWrapper>, handle: String, item_id: String, - position_ticks: i64, + position_ms: i64, ) -> Result<(), String> { + let position_ticks = position_ms * 10_000; let repo = manager.0.get(&handle).ok_or("Repository not found")?; repo.as_ref() .report_playback_start(&item_id, position_ticks) @@ -595,8 +596,9 @@ pub async fn repository_report_playback_progress( manager: State<'_, RepositoryManagerWrapper>, handle: String, item_id: String, - position_ticks: i64, + position_ms: i64, ) -> Result<(), String> { + let position_ticks = position_ms * 10_000; let repo = manager.0.get(&handle).ok_or("Repository not found")?; repo.as_ref() .report_playback_progress(&item_id, position_ticks) @@ -611,8 +613,10 @@ pub async fn repository_report_playback_stopped( manager: State<'_, RepositoryManagerWrapper>, handle: String, item_id: String, - position_ticks: i64, + position_ms: i64, ) -> 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")?; repo.as_ref() .report_playback_stopped(&item_id, position_ticks) diff --git a/src-tauri/src/commands/storage/mod.rs b/src-tauri/src/commands/storage/mod.rs index f537a2b6..51c72547 100644 --- a/src-tauri/src/commands/storage/mod.rs +++ b/src-tauri/src/commands/storage/mod.rs @@ -583,7 +583,9 @@ pub async fn storage_delete_user( #[serde(rename_all = "camelCase")] pub struct PlaybackProgress { 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_favorite: bool, pub play_count: i32, @@ -597,8 +599,11 @@ pub async fn storage_update_playback_progress( db: State<'_, DatabaseWrapper>, user_id: String, item_id: String, - position_ticks: i64, + position_ms: i64, ) -> 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 database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) @@ -649,12 +654,14 @@ pub async fn storage_update_playback_context( db: State<'_, DatabaseWrapper>, user_id: String, item_id: String, - position_ticks: i64, + position_ms: i64, context_type: Option, context_id: Option, ) -> Result<(), String> { 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 database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) @@ -857,9 +864,10 @@ pub async fn storage_get_playback_progress( db_service .query_optional(query, |row| { + let position_ticks: i64 = row.get(1)?; Ok(PlaybackProgress { item_id: row.get(0)?, - position_ticks: row.get(1)?, + position_ms: position_ticks / 10_000, is_played: row.get::<_, i32>(2)? != 0, is_favorite: row.get::<_, i32>(3)? != 0, play_count: row.get(4)?, @@ -1495,7 +1503,7 @@ mod tests { fn test_playback_progress_serialization() { let progress = PlaybackProgress { item_id: "item-123".to_string(), - position_ticks: 150_000_000, + position_ms: 150_000_000, is_played: true, is_favorite: false, play_count: 3, @@ -1512,7 +1520,7 @@ mod tests { fn test_playback_progress_played_status() { let progress = PlaybackProgress { item_id: "item-456".to_string(), - position_ticks: 0, + position_ms: 0, is_played: true, is_favorite: true, play_count: 1, @@ -1530,7 +1538,7 @@ mod tests { fn test_playback_progress_not_played() { let progress = PlaybackProgress { item_id: "item-789".to_string(), - position_ticks: 30_000_000, + position_ms: 30_000_000, is_played: false, is_favorite: false, play_count: 0, @@ -1600,7 +1608,7 @@ mod tests { fn test_playback_progress_camel_case() { let progress = PlaybackProgress { item_id: "i1".to_string(), - position_ticks: 100, + position_ms: 100, is_played: true, is_favorite: false, play_count: 1, @@ -1609,7 +1617,7 @@ mod tests { let json = serde_json::to_string(&progress).unwrap(); // Verify camelCase serialization assert!(json.contains("itemId")); - assert!(json.contains("positionTicks")); + assert!(json.contains("positionMs")); assert!(json.contains("isPlayed")); assert!(json.contains("isFavorite")); assert!(json.contains("playCount")); diff --git a/src-tauri/src/domain/from_jellyfin.rs b/src-tauri/src/domain/from_jellyfin.rs new file mode 100644 index 00000000..42e3f028 --- /dev/null +++ b/src-tauri/src/domain/from_jellyfin.rs @@ -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_.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); + } +} diff --git a/src-tauri/src/domain/media.rs b/src-tauri/src/domain/media.rs new file mode 100644 index 00000000..17fbc9c2 --- /dev/null +++ b/src-tauri/src/domain/media.rs @@ -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 + ) + } +} diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs new file mode 100644 index 00000000..8cbeea61 --- /dev/null +++ b/src-tauri/src/domain/mod.rs @@ -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}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d6a998ca..6d3549ed 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod auth; mod commands; mod connectivity; mod credentials; +mod domain; mod download; mod jellyfin; mod playback_mode; diff --git a/src-tauri/src/playback_mode/mod.rs b/src-tauri/src/playback_mode/mod.rs index 59523ef5..e15fb947 100644 --- a/src-tauri/src/playback_mode/mod.rs +++ b/src-tauri/src/playback_mode/mod.rs @@ -948,6 +948,7 @@ mod tests { artist_items: None, artists: Some(vec!["Test Artist".to_string()]), primary_image_tag: None, + image_id: None, item_type: Some("Audio".to_string()), playlist_id: None, duration: Some(180.0), @@ -979,6 +980,7 @@ mod tests { artist_items: None, artists: Some(vec!["Test Artist".to_string()]), primary_image_tag: None, + image_id: None, item_type: Some("Audio".to_string()), playlist_id: None, duration: Some(180.0), diff --git a/src-tauri/src/player/backend.rs b/src-tauri/src/player/backend.rs index a3874b7e..b1280f47 100644 --- a/src-tauri/src/player/backend.rs +++ b/src-tauri/src/player/backend.rs @@ -380,6 +380,7 @@ mod tests { artist_items: None, artists: Some(vec!["Test Artist".to_string()]), primary_image_tag: None, + image_id: None, item_type: Some("Audio".to_string()), playlist_id: None, duration: Some(180.0), @@ -438,6 +439,7 @@ mod tests { artist_items: None, artists: Some(vec!["Test Artist".to_string()]), primary_image_tag: None, + image_id: None, item_type: Some("Audio".to_string()), playlist_id: None, duration: Some(180.0), @@ -490,6 +492,7 @@ mod tests { artist_items: None, artists: Some(vec!["Test Artist".to_string()]), primary_image_tag: None, + image_id: None, item_type: Some("Audio".to_string()), playlist_id: None, duration: Some(180.0), diff --git a/src-tauri/src/player/media.rs b/src-tauri/src/player/media.rs index e24863ff..7bf70ecc 100644 --- a/src-tauri/src/player/media.rs +++ b/src-tauri/src/player/media.rs @@ -67,9 +67,16 @@ pub struct MediaItem { /// Artists as array of strings (fallback when artist_items not available) #[serde(default, skip_serializing_if = "Option::is_none")] pub artists: Option>, - /// 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")] pub primary_image_tag: Option, + /// 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, /// Item type (Audio, Movie, Episode, etc.) #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] pub item_type: Option, @@ -345,6 +352,7 @@ mod tests { artist_items: None, artists: None, primary_image_tag: None, + image_id: None, item_type: None, playlist_id: None, duration: None, @@ -380,6 +388,7 @@ mod tests { artist_items: None, artists: None, primary_image_tag: None, + image_id: None, item_type: None, playlist_id: None, duration: None, @@ -414,6 +423,7 @@ mod tests { artist_items: None, artists: None, primary_image_tag: None, + image_id: None, item_type: None, playlist_id: None, duration: None, @@ -448,6 +458,7 @@ mod tests { artist_items: None, artists: None, primary_image_tag: None, + image_id: None, item_type: None, playlist_id: None, duration: None, @@ -489,6 +500,7 @@ mod tests { artist_items: None, artists: None, primary_image_tag: None, + image_id: None, item_type: None, playlist_id: None, duration: None, @@ -523,6 +535,7 @@ mod tests { artist_items: None, artists: None, primary_image_tag: None, + image_id: None, item_type: Some("Movie".to_string()), playlist_id: None, duration: Some(120.0), diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index 29736b7a..7ece882d 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -1397,6 +1397,7 @@ mod tests { artist_items: None, artists: Some(vec!["Test Artist".to_string()]), primary_image_tag: None, + image_id: None, item_type: Some("Audio".to_string()), playlist_id: None, duration: Some(180.0), @@ -2177,6 +2178,7 @@ mod tests { id: id.to_string(), name: format!("Episode {}", index), item_type: "Episode".to_string(), + kind: crate::domain::MediaKind::Episode, is_folder: false, server_id: "server".to_string(), parent_id: Some("season1".to_string()), @@ -2184,11 +2186,13 @@ mod tests { overview: None, genres: None, runtime_ticks: None, + duration_ms: None, production_year: None, premiere_date: None, community_rating: None, official_rating: None, primary_image_tag: None, + image_id: None, backdrop_image_tags: None, parent_backdrop_image_tags: None, album_id: None, diff --git a/src-tauri/src/player/queue.rs b/src-tauri/src/player/queue.rs index 60800b18..335c847f 100644 --- a/src-tauri/src/player/queue.rs +++ b/src-tauri/src/player/queue.rs @@ -551,6 +551,7 @@ mod tests { artist_items: None, artists: Some(vec!["Artist".to_string()]), primary_image_tag: None, + image_id: None, item_type: Some("Audio".to_string()), playlist_id: None, duration: Some(180.0), diff --git a/src-tauri/src/player/session.rs b/src-tauri/src/player/session.rs index 4713e65d..d8b78fd7 100644 --- a/src-tauri/src/player/session.rs +++ b/src-tauri/src/player/session.rs @@ -242,6 +242,7 @@ mod tests { artist_items: None, artists: Some(vec!["Test Artist".to_string()]), primary_image_tag: None, + image_id: None, item_type: Some("Audio".to_string()), playlist_id: None, duration: Some(180.0), @@ -272,6 +273,7 @@ mod tests { artist_items: None, artists: None, primary_image_tag: None, + image_id: None, item_type: Some("Movie".to_string()), playlist_id: None, duration: Some(7200.0), diff --git a/src-tauri/src/player/state.rs b/src-tauri/src/player/state.rs index 7e2a60d4..9eb61a0c 100644 --- a/src-tauri/src/player/state.rs +++ b/src-tauri/src/player/state.rs @@ -323,6 +323,7 @@ mod tests { artist_items: None, artists: None, primary_image_tag: None, + image_id: None, item_type: Some("Video".to_string()), playlist_id: None, duration: Some(100.0), diff --git a/src-tauri/src/repository/hybrid.rs b/src-tauri/src/repository/hybrid.rs index 2dc8bae8..b4ba9a0c 100644 --- a/src-tauri/src/repository/hybrid.rs +++ b/src-tauri/src/repository/hybrid.rs @@ -1422,6 +1422,7 @@ mod tests { id: id.to_string(), name: name.to_string(), item_type: "Movie".to_string(), + kind: crate::domain::MediaKind::Movie, is_folder: false, server_id: "test-server".to_string(), parent_id: Some("parent-123".to_string()), @@ -1429,11 +1430,13 @@ mod tests { overview: Some("Test overview".to_string()), genres: Some(vec!["Action".to_string(), "Adventure".to_string()]), runtime_ticks: Some(7200000000), + duration_ms: Some(720000), production_year: Some(2024), premiere_date: None, community_rating: Some(8.5), official_rating: Some("PG-13".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()]), parent_backdrop_image_tags: None, album_id: None, diff --git a/src-tauri/src/repository/offline.rs b/src-tauri/src/repository/offline.rs index 63e3c626..9f0a6b0c 100644 --- a/src-tauri/src/repository/offline.rs +++ b/src-tauri/src/repository/offline.rs @@ -68,10 +68,13 @@ impl OfflineRepository { .and_then(|s| serde_json::from_str::>(s).ok()) .unwrap_or_default(); + let kind = crate::domain::kind_from_jellyfin(&item.item_type, item.is_folder); + MediaItem { id: item.id.clone(), name: item.name, item_type: item.item_type, + kind, is_folder: item.is_folder, server_id: item.server_id, parent_id: item.parent_id, @@ -82,11 +85,13 @@ impl OfflineRepository { .as_ref() .and_then(|s| serde_json::from_str::>(s).ok()), runtime_ticks: item.runtime_ticks, + duration_ms: item.runtime_ticks.map(crate::domain::ticks_to_ms), production_year: item.production_year, premiere_date: item.premiere_date, community_rating: item.community_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, parent_backdrop_image_tags: item.parent_backdrop_image_tags, album_id: item.album_id, @@ -120,8 +125,10 @@ impl OfflineRepository { self.db_service .query_optional(query, |row| { + let playback_position_ticks: Option = row.get(0).ok(); 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>(1).ok().flatten().map(|v| v != 0), is_favorite: row.get::<_, Option>(2).ok().flatten().map(|v| v != 0), play_count: row.get(3).ok(), @@ -1599,6 +1606,7 @@ impl MediaRepository for OfflineRepository { id: person_data.0, name: person_data.1, item_type: "Person".to_string(), + kind: crate::domain::MediaKind::Person, is_folder: false, server_id: self.server_id.clone(), parent_id: None, @@ -1606,11 +1614,13 @@ impl MediaRepository for OfflineRepository { overview: person_data.2, genres: None, runtime_ticks: None, + duration_ms: None, production_year: None, premiere_date: None, community_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, parent_backdrop_image_tags: None, album_id: None, @@ -2100,6 +2110,7 @@ mod tests { id: id.to_string(), name: name.to_string(), item_type: "Audio".to_string(), + kind: crate::domain::MediaKind::Track, is_folder: false, server_id: "test-server".to_string(), parent_id: parent_id.map(|s| s.to_string()), @@ -2107,11 +2118,13 @@ mod tests { overview: None, genres: None, runtime_ticks: None, + duration_ms: None, production_year: None, premiere_date: None, community_rating: None, official_rating: None, primary_image_tag: None, + image_id: None, backdrop_image_tags: None, parent_backdrop_image_tags: None, album_id: None, diff --git a/src-tauri/src/repository/online.rs b/src-tauri/src/repository/online.rs index b4bbc91f..c2800968 100644 --- a/src-tauri/src/repository/online.rs +++ b/src-tauri/src/repository/online.rs @@ -619,10 +619,13 @@ impl JellyfinItem { let primary_tag = self.image_tags.as_ref().and_then(|tags| tags.primary()); let backdrop_tags = self.backdrop_image_tags; + let kind = crate::domain::kind_from_jellyfin(&self.item_type, self.is_folder); + MediaItem { id: self.id, name: self.name, item_type: self.item_type, + kind, is_folder: self.is_folder, server_id, parent_id: self.parent_id, @@ -634,7 +637,9 @@ impl JellyfinItem { community_rating: self.community_rating, official_rating: self.official_rating, 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, parent_backdrop_image_tags: self.parent_backdrop_image_tags, album_id: self.album_id, @@ -653,6 +658,7 @@ impl JellyfinItem { streams .into_iter() .map(|s| crate::repository::types::MediaStream { + kind: crate::domain::stream_kind_from_jellyfin(&s.stream_type), stream_type: s.stream_type, codec: s.codec, language: s.language, @@ -923,6 +929,7 @@ impl MediaRepository for OnlineRepository { .clone() .unwrap_or_else(|| "Unknown Album".to_string()), item_type: "MusicAlbum".to_string(), + kind: crate::domain::MediaKind::Album, is_folder: true, server_id: first_track.server_id.clone(), parent_id: None, @@ -934,7 +941,9 @@ impl MediaRepository for OnlineRepository { community_rating: None, official_rating: None, runtime_ticks: None, + duration_ms: None, primary_image_tag: first_track.primary_image_tag.clone(), + image_id: first_track.primary_image_tag.clone(), backdrop_image_tags: None, parent_backdrop_image_tags: None, album_id: None, diff --git a/src-tauri/src/repository/types.rs b/src-tauri/src/repository/types.rs index 29e1c533..61d5c0ec 100644 --- a/src-tauri/src/repository/types.rs +++ b/src-tauri/src/repository/types.rs @@ -42,8 +42,16 @@ pub struct Library { #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] 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")] pub playback_position_ticks: Option, + /// 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, #[serde(skip_serializing_if = "Option::is_none")] pub is_played: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -97,13 +105,24 @@ pub struct Person { } /// Media item -#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] +#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct MediaItem { pub id: 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")] 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 /// decide whether a channel item drills into a list or plays directly. #[serde(default)] @@ -127,11 +146,25 @@ pub struct MediaItem { pub community_rating: Option, #[serde(skip_serializing_if = "Option::is_none")] pub official_rating: Option, + /// 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(rename = "runTimeTicks")] pub runtime_ticks: Option, + /// 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, + /// 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")] pub primary_image_tag: Option, + /// 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, #[serde(skip_serializing_if = "Option::is_none")] pub backdrop_image_tags: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -172,8 +205,13 @@ pub struct MediaItem { #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MediaStream { + /// Legacy Jellyfin stream type string ("Audio"/"Video"/"Subtitle"). Being + /// replaced by `kind`; dual-carried while the frontend migrates. #[serde(rename = "type")] 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")] pub codec: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -525,6 +563,7 @@ mod tests { id: "1".to_string(), name: "Test".to_string(), item_type: "Audio".to_string(), + kind: crate::domain::MediaKind::Track, is_folder: false, server_id: "server1".to_string(), parent_id: None, @@ -536,7 +575,9 @@ mod tests { community_rating: None, official_rating: None, runtime_ticks: None, + duration_ms: None, primary_image_tag: None, + image_id: None, backdrop_image_tags: None, parent_backdrop_image_tags: None, album_id: None, @@ -675,6 +716,7 @@ mod tests { id: "track1".to_string(), name: "Test Track".to_string(), item_type: "Audio".to_string(), + kind: crate::domain::MediaKind::Track, is_folder: false, server_id: "server1".to_string(), parent_id: None, @@ -686,7 +728,9 @@ mod tests { community_rating: None, official_rating: None, runtime_ticks: None, + duration_ms: None, primary_image_tag: None, + image_id: None, backdrop_image_tags: None, parent_backdrop_image_tags: None, album_id: None, @@ -736,6 +780,7 @@ mod tests { id: "1".to_string(), name: "Track".to_string(), item_type: "Audio".to_string(), + kind: crate::domain::MediaKind::Track, is_folder: false, server_id: "s1".to_string(), parent_id: None, @@ -747,7 +792,9 @@ mod tests { community_rating: None, official_rating: None, runtime_ticks: None, + duration_ms: None, primary_image_tag: None, + image_id: None, backdrop_image_tags: None, parent_backdrop_image_tags: None, album_id: None, diff --git a/src/lib/api/bindings.ts b/src/lib/api/bindings.ts index caef4d2b..52d55f0e 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -670,15 +670,15 @@ async storageDeleteUser(userId: string) : Promise { * Update playback progress in local database * This stores the progress locally for offline access and "continue watching" */ -async storageUpdatePlaybackProgress(userId: string, itemId: string, positionTicks: number) : Promise { - return await TAURI_INVOKE("storage_update_playback_progress", { userId, itemId, positionTicks }); +async storageUpdatePlaybackProgress(userId: string, itemId: string, positionMs: number) : Promise { + return await TAURI_INVOKE("storage_update_playback_progress", { userId, itemId, positionMs }); }, /** * Update playback progress with context in local database * 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 { - return await TAURI_INVOKE("storage_update_playback_context", { userId, itemId, positionTicks, contextType, contextId }); +async storageUpdatePlaybackContext(userId: string, itemId: string, positionMs: number, contextType: string | null, contextId: string | null) : Promise { + return await TAURI_INVOKE("storage_update_playback_context", { userId, itemId, positionMs, contextType, contextId }); }, /** * Mark item as played in local database @@ -1332,20 +1332,20 @@ async repositoryOpenLiveStream(handle: string, itemId: string) : Promise { - return await TAURI_INVOKE("repository_report_playback_start", { handle, itemId, positionTicks }); +async repositoryReportPlaybackStart(handle: string, itemId: string, positionMs: number) : Promise { + return await TAURI_INVOKE("repository_report_playback_start", { handle, itemId, positionMs }); }, /** * Report playback progress */ -async repositoryReportPlaybackProgress(handle: string, itemId: string, positionTicks: number) : Promise { - return await TAURI_INVOKE("repository_report_playback_progress", { handle, itemId, positionTicks }); +async repositoryReportPlaybackProgress(handle: string, itemId: string, positionMs: number) : Promise { + return await TAURI_INVOKE("repository_report_playback_progress", { handle, itemId, positionMs }); }, /** * Report playback stopped */ -async repositoryReportPlaybackStopped(handle: string, itemId: string, positionTicks: number) : Promise { - return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionTicks }); +async repositoryReportPlaybackStopped(handle: string, itemId: string, positionMs: number) : Promise { + return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionMs }); }, /** * Get image URL for an item @@ -1802,7 +1802,22 @@ export type LmsSyncGroup = { masterMac: string; masterName?: string; slaveMacs?: /** * 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 * 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 * 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 */ @@ -1841,13 +1912,26 @@ export type MediaSource = { id: string; name: string; container?: string | null; /** * 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" /** * Lightweight media item for merged playback state * 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`]. * @@ -1995,7 +2079,12 @@ export type PlaybackMode = { type: "local" } | { type: "remote"; session_id: str /** * 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 * @@ -2039,9 +2128,17 @@ artistItems?: ArtistItem[] | 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; +/** + * Neutral image identifier the frontend resolves to a URL — replaces + * `primary_image_tag`. + */ +imageId?: string | null; /** * Item type (Audio, Movie, Episode, etc.) */ @@ -2276,7 +2373,22 @@ export type PlaylistEntry = /** * 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 * 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 * 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) */ @@ -2391,6 +2525,15 @@ export type SmartCacheStats = { total_size: number; storage_limit: number; avail * Storage statistics for downloads */ 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 */ @@ -2430,7 +2573,19 @@ export type User = { id: string; name: string; serverId: string; primaryImageTag /** * 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 */ diff --git a/src/lib/api/repository-client.test.ts b/src/lib/api/repository-client.test.ts index a63dc352..d657b798 100644 --- a/src/lib/api/repository-client.test.ts +++ b/src/lib/api/repository-client.test.ts @@ -470,7 +470,7 @@ describe("RepositoryClient", () => { expect(invoke).toHaveBeenCalledWith("repository_report_playback_progress", { handle: "test-handle-123", itemId: "item123", - positionTicks: 5000000, + positionMs: 5000000, }); }); }); diff --git a/src/lib/api/repository-client.ts b/src/lib/api/repository-client.ts index 13461454..64063e8e 100644 --- a/src/lib/api/repository-client.ts +++ b/src/lib/api/repository-client.ts @@ -164,16 +164,16 @@ export class RepositoryClient { return commands.repositoryGetPlaybackInfo(this.ensureHandle(), itemId); } - async reportPlaybackStart(itemId: string, positionTicks: number): Promise { - await commands.repositoryReportPlaybackStart(this.ensureHandle(), itemId, positionTicks); + async reportPlaybackStart(itemId: string, positionMs: number): Promise { + await commands.repositoryReportPlaybackStart(this.ensureHandle(), itemId, positionMs); } - async reportPlaybackProgress(itemId: string, positionTicks: number): Promise { - await commands.repositoryReportPlaybackProgress(this.ensureHandle(), itemId, positionTicks); + async reportPlaybackProgress(itemId: string, positionMs: number): Promise { + await commands.repositoryReportPlaybackProgress(this.ensureHandle(), itemId, positionMs); } - async reportPlaybackStopped(itemId: string, positionTicks: number): Promise { - await commands.repositoryReportPlaybackStopped(this.ensureHandle(), itemId, positionTicks); + async reportPlaybackStopped(itemId: string, positionMs: number): Promise { + await commands.repositoryReportPlaybackStopped(this.ensureHandle(), itemId, positionMs); } // ===== Stream URL Methods (via Rust) ===== diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index ec303636..98ad9789 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -14,6 +14,7 @@ export type { Library, LiveStreamInfo, MediaItem, + MediaKind, MediaSource, MediaStream, Person, diff --git a/src/lib/components/home/HeroBanner.svelte b/src/lib/components/home/HeroBanner.svelte index 4d86509e..018bea7c 100644 --- a/src/lib/components/home/HeroBanner.svelte +++ b/src/lib/components/home/HeroBanner.svelte @@ -32,7 +32,7 @@ } // 2. For episodes, try series/season backdrops - if (currentItem.type === "Episode") { + if (currentItem.kind === "episode") { if (currentItem.seriesId && 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 - if (currentItem.type === "Audio" && currentItem.albumId) { + if (currentItem.kind === "track" && currentItem.albumId) { return { itemId: currentItem.albumId, imageType: "Backdrop" as const, tag: undefined }; } // 4. Fall back to primary image - if (currentItem.primaryImageTag) { - return { itemId: currentItem.id, imageType: "Primary" as const, tag: currentItem.primaryImageTag }; + if (currentItem.imageId) { + return { itemId: currentItem.id, imageType: "Primary" as const, tag: currentItem.imageId }; } // 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 }; } @@ -190,7 +190,7 @@ onclick={() => { // 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) - if (currentItem.type === "Episode" && currentItem.seriesId) { + if (currentItem.kind === "episode" && currentItem.seriesId) { goto(`/library/${currentItem.seriesId}`); } else { goto(`/library/${currentItem.id}`); diff --git a/src/lib/components/library/ArtistDetailView.svelte b/src/lib/components/library/ArtistDetailView.svelte index 9e29babf..1cf7bce6 100644 --- a/src/lib/components/library/ArtistDetailView.svelte +++ b/src/lib/components/library/ArtistDetailView.svelte @@ -43,7 +43,7 @@ sortBy: "DateCreated", sortOrder: "Descending" }); - albums = albumsResult.items.filter(item => item.type === "MusicAlbum"); + albums = albumsResult.items.filter(item => item.kind === "album"); } catch (e) { console.warn("Failed to load albums:", e); } finally { @@ -58,7 +58,7 @@ sortBy: "CommunityRating", sortOrder: "Descending" }); - topTracks = tracksResult.items.filter(item => item.type === "Audio"); + topTracks = tracksResult.items.filter(item => item.kind === "track"); } catch (e) { console.warn("Failed to load tracks:", e); } finally { @@ -76,7 +76,7 @@ sortOrder: "Descending" }); relatedArtists = relatedResult.items - .filter(item => item.id !== artist.id && item.type === "MusicArtist") + .filter(item => item.id !== artist.id && item.kind === "artist") .slice(0, 6); } } catch (e) { @@ -112,12 +112,12 @@
- {#if artist.primaryImageTag} + {#if artist.imageId}
- {#if album.primaryImageTag} + {#if album.imageId}
- {#if relatedArtist.primaryImageTag} + {#if relatedArtist.imageId} @@ -246,7 +246,7 @@ { - if (!episode.userData || !episode.runTimeTicks) { + if (!episode.userData || !episode.durationMs) { 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); @@ -59,7 +59,7 @@ import { goto } from "$app/navigation"; + import type { MediaKind } from "$lib/api/types"; + interface Props { genres: string[]; maxShow?: number; // Default: unlimited clickable?: boolean; // Default: true - itemType?: string; // Determines which genre browse page to open + itemKind?: MediaKind; // Determines which genre browse page to open } let { genres, maxShow, clickable = true, - itemType + itemKind }: Props = $props(); - // Map the item type to its genre-browse route - function genreBasePath(type: string | undefined): string { - switch (type) { - case "MusicAlbum": - case "MusicArtist": - case "Audio": + // Map the item kind to its genre-browse route + function genreBasePath(kind: MediaKind | undefined): string { + switch (kind) { + case "album": + case "artist": + case "track": + case "playlist": return "/library/music/genres"; - case "Series": - case "Season": - case "Episode": + case "series": + case "season": + case "episode": return "/library/shows/genres"; - case "Movie": + case "movie": return "/library/movies/genres"; default: return "/library/movies/genres"; @@ -43,7 +46,7 @@ function handleGenreClick(genre: string) { if (clickable) { - goto(`${genreBasePath(itemType)}?genre=${encodeURIComponent(genre)}`); + goto(`${genreBasePath(itemKind)}?genre=${encodeURIComponent(genre)}`); } } diff --git a/src/lib/components/library/LibraryListView.svelte b/src/lib/components/library/LibraryListView.svelte index d5b40d62..75ec6a54 100644 --- a/src/lib/components/library/LibraryListView.svelte +++ b/src/lib/components/library/LibraryListView.svelte @@ -19,7 +19,7 @@ } 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 { @@ -42,10 +42,10 @@ 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 ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100; + return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100; } function getTrackNumber(item: MediaItem | Library): string { @@ -59,7 +59,7 @@
{#each items as item, index (item.id)} {@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 trackNum = getTrackNumber(item)} {@const isPlayed = "userData" in item && item.userData?.isPlayed} diff --git a/src/lib/components/library/MediaCard.svelte b/src/lib/components/library/MediaCard.svelte index 39b06731..0453626f 100644 --- a/src/lib/components/library/MediaCard.svelte +++ b/src/lib/components/library/MediaCard.svelte @@ -101,11 +101,11 @@ }; 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(() => { - if ("type" in item) { + if ("kind" in item) { return isMusicType ? "aspect-square" : "aspect-[2/3]"; } // Library @@ -113,16 +113,16 @@ }); 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 progress = $derived(() => { - if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) { + if (!showProgress || !("userData" in item) || !item.userData || !item.durationMs) { return 0; } - return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100; + return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100; }); const subtitle = $derived(() => { diff --git a/src/lib/components/library/PersonDetailView.svelte b/src/lib/components/library/PersonDetailView.svelte index 399f9bdb..c49ab455 100644 --- a/src/lib/components/library/PersonDetailView.svelte +++ b/src/lib/components/library/PersonDetailView.svelte @@ -31,8 +31,8 @@ }); // Separate movies and series - movies = result.items.filter(item => item.type === "Movie"); - series = result.items.filter(item => item.type === "Series"); + movies = result.items.filter(item => item.kind === "movie"); + series = result.items.filter(item => item.kind === "series"); } catch (e) { console.error("Failed to load filmography:", e); } finally { @@ -53,7 +53,7 @@ ({ ...e } as MediaItem))); const totalDuration = $derived( - entries.reduce((sum, e) => sum + (e.runTimeTicks ?? 0), 0) + entries.reduce((sum, e) => sum + (e.durationMs ?? 0), 0) ); onMount(() => { @@ -146,11 +146,11 @@
- {#if playlist.primaryImageTag} + {#if playlist.imageId} item.id !== currentItemId); @@ -65,10 +65,14 @@ // Fallback: Load by genres using search (works for all item types) if (genres && genres.length > 0) { 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 itemTypeForKind: Record = { + movie: "Movie", series: "Series", album: "MusicAlbum", track: "Audio", artist: "MusicArtist", + }; const result = await repo.search(searchTerm, { - includeItemTypes: itemType === "MusicAlbum" ? ["MusicAlbum"] : itemType === "Audio" ? ["Audio"] : [itemType], + includeItemTypes: [itemTypeForKind[itemKind] ?? "Movie"], 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) - if (itemType === "MusicAlbum" && artistIds && artistIds.length > 0 && items.length === 0) { + if (itemKind === "album" && artistIds && artistIds.length > 0 && items.length === 0) { try { // Search for other albums by artist name from first artist const result = await repo.search(artistIds[0], { @@ -109,14 +113,14 @@ } function getTitle(): string { - switch (itemType) { - case "Movie": + switch (itemKind) { + case "movie": return "Related Movies"; - case "Series": + case "series": return "Related Shows"; - case "MusicAlbum": + case "album": return "Related Albums"; - case "Audio": + case "track": return "Related Tracks"; default: return "Related Items"; @@ -133,7 +137,7 @@ {#if loading} - {@const isMusicContent = itemType === "MusicAlbum" || itemType === "Audio"} + {@const isMusicContent = itemKind === "album" || itemKind === "track"}
{#each Array(6) as _}
diff --git a/src/lib/components/library/SeasonSection.svelte b/src/lib/components/library/SeasonSection.svelte index 1c26c3c7..455c81a6 100644 --- a/src/lib/components/library/SeasonSection.svelte +++ b/src/lib/components/library/SeasonSection.svelte @@ -28,7 +28,7 @@
- {formatDuration(track.runTimeTicks)} + {formatDuration(track.durationMs)}
@@ -421,7 +421,7 @@

- {formatDuration(track.runTimeTicks)} + {formatDuration(track.durationMs)}
diff --git a/src/lib/components/library/TrackList.test.ts b/src/lib/components/library/TrackList.test.ts index a5495fb5..b8b8bb0f 100644 --- a/src/lib/components/library/TrackList.test.ts +++ b/src/lib/components/library/TrackList.test.ts @@ -72,7 +72,7 @@ describe("TrackList", () => { artists: ["Artist 1"], albumName: "Album 1", albumId: "album-1", - runTimeTicks: 1800000000, // 3 minutes + durationMs: 180000, // 3 minutes primaryImageTag: "tag1", indexNumber: 1, }, @@ -84,7 +84,7 @@ describe("TrackList", () => { artists: ["Artist 2"], albumName: "Album 2", albumId: "album-2", - runTimeTicks: 2400000000, // 4 minutes + durationMs: 240000, // 4 minutes primaryImageTag: "tag2", indexNumber: 2, }, @@ -96,7 +96,7 @@ describe("TrackList", () => { artists: ["Artist 3", "Artist 4"], albumName: "Album 3", albumId: "album-3", - runTimeTicks: 3000000000, // 5 minutes + durationMs: 300000, // 5 minutes indexNumber: 3, }, ]; @@ -187,7 +187,7 @@ describe("TrackList", () => { const tracksWithoutDuration: MediaItem[] = [ { ...mockTracks[0], - runTimeTicks: undefined, + durationMs: undefined, }, ]; diff --git a/src/lib/components/player/AudioPlayer.svelte b/src/lib/components/player/AudioPlayer.svelte index e15a4c46..62f0a999 100644 --- a/src/lib/components/player/AudioPlayer.svelte +++ b/src/lib/components/player/AudioPlayer.svelte @@ -143,7 +143,7 @@ - {#if imageId && $nextEpisodeItem.primaryImageTag} + {#if imageId && $nextEpisodeItem.imageId}
- {#if item.primaryImageTag} + {#if item.imageId} - {formatDuration(item.runTimeTicks)} + {formatDuration(item.durationMs)} diff --git a/src/lib/components/player/VideoPlayer.scrubRegression.test.ts b/src/lib/components/player/VideoPlayer.scrubRegression.test.ts index fe16539d..f2f53ac8 100644 --- a/src/lib/components/player/VideoPlayer.scrubRegression.test.ts +++ b/src/lib/components/player/VideoPlayer.scrubRegression.test.ts @@ -97,8 +97,8 @@ function makeEpisode(): MediaItem { return { id: "ep1", name: "Episode 1", - type: "Episode", - runTimeTicks: 24 * 60 * 10_000_000, // 24 min + kind: "episode", + durationMs: 24 * 60 * 1000, // 24 min } as MediaItem; } diff --git a/src/lib/components/player/VideoPlayer.svelte b/src/lib/components/player/VideoPlayer.svelte index a32da2dd..f726c086 100644 --- a/src/lib/components/player/VideoPlayer.svelte +++ b/src/lib/components/player/VideoPlayer.svelte @@ -165,9 +165,9 @@ // Use known duration from media item (runTimeTicks is in 10M ticks/second) // Fallback to video element duration for direct streams const duration = $derived.by(() => { - // Explicitly check if runTimeTicks exists and is a valid number - if (media && media.runTimeTicks && media.runTimeTicks > 0) { - return media.runTimeTicks / 10_000_000; + // Explicitly check if durationMs exists and is a valid number + if (media && media.durationMs && media.durationMs > 0) { + return media.durationMs / 1000; } // Otherwise use the video element's duration return videoDuration; @@ -180,7 +180,7 @@ console.log("[VideoPlayer] No media or mediaStreams available"); 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); return tracks; }); @@ -243,7 +243,7 @@ console.log("[VideoPlayer] No media or mediaStreams available for subtitles"); 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); return tracks; }); @@ -386,7 +386,7 @@ // 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 // 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 isNearEnd = knownDuration > 0 && effectiveTime > 0 && effectiveTime / knownDuration > 0.9; @@ -520,7 +520,7 @@ // Build subtitle tracks for native player const subtitleTracks = []; 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) { try { const url = await getSubtitleUrl(sub.index); @@ -1223,7 +1223,7 @@ needsTranscoding: false, // Now-playing metadata so the lockscreen/miniplayer show the item. artist: media.seriesName ?? null, - primaryImageTag: media.primaryImageTag ?? null, + primaryImageTag: media.imageId ?? null, serverId: media.serverId ?? null, // Real duration so the lockscreen scrubber has a range to draw. durationSeconds: duration > 0 ? duration : null, @@ -1626,11 +1626,11 @@ {#if !isMediaReady}
- {#if media?.primaryImageTag} + {#if media?.imageId}
- {#if playlist.primaryImageTag} + {#if playlist.imageId} ({ getItem: vi.fn(async (id: string) => ({ id, name: "Test Item", - runTimeTicks: 100000000, + durationMs: 10000, })), })), }, @@ -61,7 +61,7 @@ describe("playback reporting service", () => { (c) => c[0] === "storage_update_playback_context" ); 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 () => { @@ -121,7 +121,7 @@ describe("playback reporting service", () => { const call = invokeSpy.mock.calls.find( (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(); }); - 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 authModule = vi.mocked(auth); const mockRepo = { @@ -167,7 +167,7 @@ describe("playback reporting service", () => { expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith( "item-123", - 900000000 // 90 seconds in ticks + 90000 // 90 seconds in ms ); }); @@ -207,7 +207,7 @@ describe("playback reporting service", () => { getItem: vi.fn(async () => ({ id: "item-123", name: "Item", - runTimeTicks: 100000000, + durationMs: 10000, })), }; authModule.getRepository = vi.fn(() => mockRepo as any); @@ -217,11 +217,11 @@ describe("playback reporting service", () => { expect(mockRepo.getItem).toHaveBeenCalledWith("item-123"); expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith( "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 authModule = vi.mocked(auth); const mockRepo = { @@ -229,7 +229,7 @@ describe("playback reporting service", () => { getItem: vi.fn(async () => ({ id: "item-123", name: "Item", - runTimeTicks: null, + durationMs: null, })), }; authModule.getRepository = vi.fn(() => mockRepo as any); diff --git a/src/lib/services/playbackReporting.ts b/src/lib/services/playbackReporting.ts index fcc04225..e2307721 100644 --- a/src/lib/services/playbackReporting.ts +++ b/src/lib/services/playbackReporting.ts @@ -26,7 +26,7 @@ export async function reportPlaybackStart( contextType: "container" | "single" = "single", contextId: string | null = null ): Promise { - const positionTicks = Math.floor(positionSeconds * 10000000); + const positionMs = Math.floor(positionSeconds * 1000); const userId = auth.getUserId(); console.log( @@ -42,7 +42,7 @@ export async function reportPlaybackStart( // Update local DB with context (always works, even offline) if (userId) { try { - await commands.storageUpdatePlaybackContext(userId, itemId, positionTicks, contextType, contextId); + await commands.storageUpdatePlaybackContext(userId, itemId, positionMs, contextType, contextId); } catch (e) { console.error("[PlaybackReporting] Failed to update playback context:", e); } @@ -62,7 +62,7 @@ export async function reportPlaybackProgress( positionSeconds: number, _isPaused = false ): Promise { - const positionTicks = Math.floor(positionSeconds * 10000000); + const positionMs = Math.floor(positionSeconds * 1000); const userId = auth.getUserId(); // 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) if (userId) { try { - await commands.storageUpdatePlaybackProgress(userId, itemId, positionTicks); + await commands.storageUpdatePlaybackProgress(userId, itemId, positionMs); } catch (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 */ export async function reportPlaybackStopped(itemId: string, positionSeconds: number): Promise { - const positionTicks = Math.floor(positionSeconds * 10000000); + const positionMs = Math.floor(positionSeconds * 1000); const userId = auth.getUserId(); 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) if (userId) { try { - await commands.storageUpdatePlaybackProgress(userId, itemId, positionTicks); + await commands.storageUpdatePlaybackProgress(userId, itemId, positionMs); } catch (e) { console.error("[PlaybackReporting] Failed to update local progress:", e); } @@ -108,7 +108,7 @@ export async function reportPlaybackStopped(itemId: string, positionSeconds: num try { // Get the repository to check if we should queue const repo = auth.getRepository(); - await repo.reportPlaybackStopped(itemId, positionTicks); + await repo.reportPlaybackStopped(itemId, positionMs); } catch (e) { console.error("[PlaybackReporting] Failed to report to server:", e); // Server error - could queue, but for now just log @@ -140,8 +140,8 @@ export async function markAsPlayed(itemId: string): Promise { const repo = auth.getRepository(); const item = await repo.getItem(itemId); - if (item.runTimeTicks) { - await repo.reportPlaybackStopped(itemId, item.runTimeTicks); + if (item.durationMs) { + await repo.reportPlaybackStopped(itemId, item.durationMs); } } catch (e) { console.error("[PlaybackReporting] Failed to report as played:", e); diff --git a/src/lib/services/playerEvents.regression.test.ts b/src/lib/services/playerEvents.regression.test.ts index 161e396b..b64be386 100644 --- a/src/lib/services/playerEvents.regression.test.ts +++ b/src/lib/services/playerEvents.regression.test.ts @@ -73,8 +73,8 @@ function makeItem(overrides: Partial = {}): MediaItem { return { id: "track-1", name: "Test Track", - type: "Audio", - runTimeTicks: null, + kind: "track", + durationMs: null, ...overrides, } 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 () => { // runTimeTicks is null — the previous code recomputed duration as 0 here, // 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); 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 () => { - // 70s in ticks (1 tick = 100ns) → 700_000_000. - const item = makeItem({ runTimeTicks: 700_000_000 }); + // 70 seconds = 70_000 ms. + const item = makeItem({ durationMs: 70_000 }); currentQueueItemStore.set(item); const { initPlayerEvents } = await import("./playerEvents"); diff --git a/src/lib/services/playerEvents.ts b/src/lib/services/playerEvents.ts index 63ba939d..8f68aef3 100644 --- a/src/lib/services/playerEvents.ts +++ b/src/lib/services/playerEvents.ts @@ -170,7 +170,7 @@ function handlePositionUpdate(position: number, duration: number): void { * with 0 when runTimeTicks is missing (which would zero the slider's max). */ 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) { const live = get(playbackDuration); if (live > 0) { diff --git a/src/lib/services/syncService.ts b/src/lib/services/syncService.ts index e356e407..8ab89b41 100644 --- a/src/lib/services/syncService.ts +++ b/src/lib/services/syncService.ts @@ -104,12 +104,12 @@ class SyncService { */ async queuePlaybackProgress( itemId: string, - positionTicks: number + positionMs: number ): Promise { // 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 }); } /** diff --git a/src/lib/stores/library.ts b/src/lib/stores/library.ts index 3edd3f41..3936bd7f 100644 --- a/src/lib/stores/library.ts +++ b/src/lib/stores/library.ts @@ -202,7 +202,7 @@ function createLibraryStore() { const repo = auth.getRepository(); 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'}`); if (item.people && item.people.length > 0) { item.people.forEach((p, i) => { diff --git a/src/lib/stores/movies.ts b/src/lib/stores/movies.ts index 542bf52c..a8e3a100 100644 --- a/src/lib/stores/movies.ts +++ b/src/lib/stores/movies.ts @@ -45,7 +45,7 @@ function createMoviesStore() { /** Artwork check for hero candidates: needs a backdrop or a primary image. */ 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) { update(s => ({ diff --git a/src/lib/stores/music.ts b/src/lib/stores/music.ts index 66ac526a..2795b1f8 100644 --- a/src/lib/stores/music.ts +++ b/src/lib/stores/music.ts @@ -55,7 +55,7 @@ function createMusicStore() { /** Artwork check for hero candidates: needs a primary image or backdrop. */ 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) { update(s => ({ diff --git a/src/lib/stores/player.ts b/src/lib/stores/player.ts index 277da543..756e7947 100644 --- a/src/lib/stores/player.ts +++ b/src/lib/stores/player.ts @@ -9,7 +9,7 @@ */ 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 { isRemoteMode } from "./playbackMode"; import { selectedSession } from "./sessions"; @@ -24,7 +24,7 @@ export interface MergedMediaItem { album: string | null; albumId: string | null; duration: number | null; - primaryImageTag: string | null; + imageId: string | null; 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.) */ 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 { id: npi.id ?? "", name: npi.name ?? "", - type: (npi.Type ?? "Audio") as ItemType, + kind, serverId: "", albumName: npi.album, albumId: npi.albumId, artists: npi.artists, - primaryImageTag: npi.primaryImageTag ?? npi.albumPrimaryImageTag, - runTimeTicks: npi.runTimeTicks, + imageId: npi.primaryImageTag ?? npi.albumPrimaryImageTag, + durationMs: npi.runTimeTicks != null ? Math.floor(npi.runTimeTicks / 10000) : null, } as MediaItem; } @@ -263,8 +271,8 @@ export const mergedVolume = derived( */ function isVideoItem(item: MediaItem | null): boolean { if (!item) return false; - const type = item.type; - if (type === "Movie" || type === "Episode" || type === "TvChannel") { + const kind = item.kind; + if (kind === "movie" || kind === "episode" || kind === "liveChannel") { return true; } // Backend PlayerMediaItem carries a lowercase mediaType discriminator that is diff --git a/src/lib/stores/playerVisibility.test.ts b/src/lib/stores/playerVisibility.test.ts index fd6ce5dd..13040956 100644 --- a/src/lib/stores/playerVisibility.test.ts +++ b/src/lib/stores/playerVisibility.test.ts @@ -27,8 +27,8 @@ vi.mock("./queue", () => ({ currentQueueItem })); // Imported after the mocks so player.ts picks up the mocked stores. const { player, shouldShowAudioMiniPlayer } = await import("./player"); -const audioItem = { id: "a1", type: "Audio" } as unknown as MediaItem; -const videoItem = { id: "v1", type: "Movie" } as unknown as MediaItem; +const audioItem = { id: "a1", kind: "track" } as unknown as MediaItem; +const videoItem = { id: "v1", kind: "movie" } as unknown as MediaItem; describe("shouldShowAudioMiniPlayer", () => { beforeEach(() => { @@ -90,8 +90,8 @@ describe("shouldShowAudioMiniPlayer", () => { }); it("hides for a live TV channel", () => { - const channelItem = { id: "c1", type: "TvChannel" } as unknown as MediaItem; - player.setPlaying(channelItem, 0, 0); + const liveChannelItem = { id: "c1", kind: "liveChannel" } as unknown as MediaItem; + player.setPlaying(liveChannelItem, 0, 0); expect(get(shouldShowAudioMiniPlayer)).toBe(false); }); diff --git a/src/lib/stores/tv.ts b/src/lib/stores/tv.ts index ea163d35..8f58bdef 100644 --- a/src/lib/stores/tv.ts +++ b/src/lib/stores/tv.ts @@ -50,7 +50,7 @@ function createTvStore() { const hasArt = (i: MediaItem) => !!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!(i.parentBackdropImageTags && i.parentBackdropImageTags.length > 0) || - !!i.primaryImageTag; + !!i.imageId; async function loadSections(libraryId: string) { update(s => ({ @@ -81,7 +81,7 @@ function createTvStore() { // Resume items are already video-only from the server, but keep episodes // (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, // recent additions, and random series from across the library. diff --git a/src/lib/utils/duration.test.ts b/src/lib/utils/duration.test.ts index 6d1ffd01..5772b870 100644 --- a/src/lib/utils/duration.test.ts +++ b/src/lib/utils/duration.test.ts @@ -8,36 +8,35 @@ import { describe, it, expect } from "vitest"; import { formatDuration, formatSecondsDuration } from "./duration"; describe("formatDuration", () => { - it("should format duration from Jellyfin ticks (mm:ss format)", () => { - // 1 second = 10,000,000 ticks - expect(formatDuration(10000000)).toBe("0:01"); - expect(formatDuration(60000000)).toBe("0:06"); - expect(formatDuration(600000000)).toBe("1:00"); - expect(formatDuration(6000000000)).toBe("10:00"); - expect(formatDuration(36610000000)).toBe("61:01"); + it("should format duration from milliseconds (mm:ss format)", () => { + expect(formatDuration(1000)).toBe("0:01"); + expect(formatDuration(6000)).toBe("0:06"); + expect(formatDuration(60000)).toBe("1:00"); + expect(formatDuration(600000)).toBe("10:00"); + expect(formatDuration(3661000)).toBe("61:01"); }); it("should format duration with hh:mm:ss format", () => { - // 1 hour = 3600 seconds = 36,000,000,000 ticks - expect(formatDuration(36000000000, "hh:mm:ss")).toBe("1:00:00"); - expect(formatDuration(36100000000, "hh:mm:ss")).toBe("1:00:10"); - expect(formatDuration(36610000000, "hh:mm:ss")).toBe("1:01:01"); + // 1 hour = 3600 seconds = 3,600,000 ms + expect(formatDuration(3600000, "hh:mm:ss")).toBe("1:00:00"); + expect(formatDuration(3610000, "hh:mm:ss")).toBe("1:00:10"); + 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(0)).toBe(""); }); it("should pad seconds with leading zero", () => { - expect(formatDuration(5000000)).toBe("0:00"); - expect(formatDuration(50000000)).toBe("0:05"); - expect(formatDuration(150000000)).toBe("0:15"); + expect(formatDuration(500)).toBe("0:00"); + expect(formatDuration(5000)).toBe("0:05"); + expect(formatDuration(15000)).toBe("0:15"); }); it("should handle large durations", () => { - // 2 hours 30 minutes 45 seconds = 9045 seconds * 10,000,000 ticks/second - expect(formatDuration(90450000000, "hh:mm:ss")).toBe("2:30:45"); + // 2 hours 30 minutes 45 seconds = 9045 seconds = 9,045,000 ms + expect(formatDuration(9045000, "hh:mm:ss")).toBe("2:30:45"); }); }); diff --git a/src/lib/utils/duration.ts b/src/lib/utils/duration.ts index 6d9df150..ca728f6d 100644 --- a/src/lib/utils/duration.ts +++ b/src/lib/utils/duration.ts @@ -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 - * @param ticks Duration in Jellyfin ticks (10M ticks = 1 second) + * Convert a millisecond duration to a formatted string. + * @param ms Duration in milliseconds * @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 { - if (!ticks) return ""; +export function formatDuration(ms?: number | null, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string { + if (!ms) return ""; - // Jellyfin uses 10,000,000 ticks per second - const TICKS_PER_SECOND = 10000000; - const totalSeconds = Math.floor(ticks / TICKS_PER_SECOND); + const totalSeconds = Math.floor(ms / 1000); if (format === "hh:mm:ss") { const hours = Math.floor(totalSeconds / 3600); diff --git a/src/lib/utils/jellyfinFieldMapping.test.ts b/src/lib/utils/jellyfinFieldMapping.test.ts deleted file mode 100644 index 20f60fd4..00000000 --- a/src/lib/utils/jellyfinFieldMapping.test.ts +++ /dev/null @@ -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]); - }); - }); -}); diff --git a/src/lib/utils/jellyfinFieldMapping.ts b/src/lib/utils/jellyfinFieldMapping.ts deleted file mode 100644 index febfe866..00000000 --- a/src/lib/utils/jellyfinFieldMapping.ts +++ /dev/null @@ -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; diff --git a/src/lib/utils/mediaKind.ts b/src/lib/utils/mediaKind.ts new file mode 100644 index 00000000..b53d5452 --- /dev/null +++ b/src/lib/utils/mediaKind.ts @@ -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 = { + 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] ?? ""; +} diff --git a/src/lib/utils/playbackUnits.ts b/src/lib/utils/playbackUnits.ts index 3ed4cd44..b1fe4ff1 100644 --- a/src/lib/utils/playbackUnits.ts +++ b/src/lib/utils/playbackUnits.ts @@ -1,29 +1,31 @@ /** - * Playback unit conversion utilities + * Playback unit utilities. * - * Jellyfin uses "ticks" for time values where 10 million ticks = 1 second. - * This module provides type-safe conversion functions to eliminate magic numbers - * and prevent conversion bugs across the codebase. + * The app's own media model speaks milliseconds (see the domain migration in + * docs/specs/frontend-domain-model.md), so catalog code does NOT use the tick + * 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; /** - * Convert seconds to Jellyfin ticks - * @param seconds - Time in seconds (e.g., 90.5 for 1 minute 30.5 seconds) - * @returns Time in Jellyfin ticks + * Convert seconds to Jellyfin ticks. Remote-session boundary only. */ export function secondsToTicks(seconds: number): number { return Math.floor(seconds * TICKS_PER_SECOND); } /** - * Convert Jellyfin ticks to seconds - * @param ticks - Time in Jellyfin ticks - * @returns Time in seconds + * Convert Jellyfin session ticks to seconds. Remote-session boundary only. */ export function ticksToSeconds(ticks: number | null | undefined): number { return (ticks ?? 0) / TICKS_PER_SECOND; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 361f9231..0b0a12ce 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -57,14 +57,13 @@ }); function handleItemClick(item: MediaItem) { - switch (item.type) { - case "Series": - case "Season": - case "MusicAlbum": - case "MusicArtist": - case "Folder": - case "Channel": - case "ChannelFolderItem": + switch (item.kind) { + case "series": + case "season": + case "album": + case "artist": + case "folder": + case "channel": goto(`/library/${item.id}`); break; default: @@ -100,7 +99,7 @@ const heroItems = $derived($home.heroItems); 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 latestItems = $derived($home.latestItems); diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte index 9d1301d5..28d941e1 100644 --- a/src/routes/library/+page.svelte +++ b/src/routes/library/+page.svelte @@ -127,30 +127,27 @@ // Prevent accidental taps during scrolling (Android) if (scrollGuard.isScrollActive()) return; - if ("type" in item) { + if ("kind" in item) { // It's a MediaItem const mediaItem = item as MediaItem; - // A ChannelFolderItem can be a folder (drill in) or a playable leaf. - if (mediaItem.type === "ChannelFolderItem" && !mediaItem.isFolder) { + // A playable channel leaf plays directly; a channel container drills in. + if (mediaItem.kind === "channelItem") { goto(`/player/${mediaItem.id}`); return; } - switch (mediaItem.type) { - case "Series": - case "Movie": - case "MusicAlbum": - case "MusicArtist": - case "Folder": - case "CollectionFolder": - case "Playlist": - case "Channel": - case "ChannelFolderItem": + switch (mediaItem.kind) { + case "series": + case "movie": + case "album": + case "artist": + case "folder": + case "playlist": + case "channel": // Navigate to detail view goto(`/library/${mediaItem.id}`); break; - case "Episode": - case "TvChannel": - // Episodes and live TV channels play directly + case "episode": + // Episodes play directly goto(`/player/${mediaItem.id}`); break; default: diff --git a/src/routes/library/[id]/+page.svelte b/src/routes/library/[id]/+page.svelte index 890e549e..d6c9af7f 100644 --- a/src/routes/library/[id]/+page.svelte +++ b/src/routes/library/[id]/+page.svelte @@ -4,6 +4,7 @@ import { page } from "$app/stores"; import { goto } from "$app/navigation"; import { navigateBack } from "$lib/utils/navigation"; + import { kindLabel } from "$lib/utils/mediaKind"; import { commands } from "$lib/api/bindings"; import type { MediaItem, Library } from "$lib/api/types"; import { library, libraryItems, isLibraryLoading, currentLibrary, libraries } from "$lib/stores/library"; @@ -84,7 +85,7 @@ try { 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'}`); if (item?.people) { item.people.forEach((p, i) => { @@ -94,7 +95,7 @@ // Set currentLibrary for music items if not already set // 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 if ($libraries.length === 0) { await library.loadLibraries(); @@ -110,8 +111,8 @@ // Ensure cast/crew data is loaded for Movies, Series, and Episodes // 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)) { - console.log(`[LibraryDetail] ⚠ People data missing, reloading ${item?.type}...`); + if ((item?.kind === "movie" || item?.kind === "series" || item?.kind === "episode") && (!item.people || item.people.length === 0)) { + console.log(`[LibraryDetail] ⚠ People data missing, reloading ${item?.kind}...`); try { const repo = auth.getRepository(); const fullItem = await repo.getItem(itemId); @@ -124,20 +125,20 @@ }); } } 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 - if (item?.type === "Series") { - const seasons = $libraryItems.filter((i) => i.type === "Season"); + if (item?.kind === "series") { + const seasons = $libraryItems.filter((i) => i.kind === "season"); const repo = auth.getRepository(); // Load episodes for each season in parallel const seasonDataPromises = seasons.map(async (season) => { const result = await repo.getItems(season.id, { limit: 100 }); const episodes = result.items - .filter((i) => i.type === "Episode") + .filter((i) => i.kind === "episode") .sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0)); return { season, episodes }; }); @@ -170,9 +171,9 @@ // Images now handled by CachedImage component - function formatDuration(ticks?: number): string { - if (!ticks) return ""; - const seconds = Math.floor(ticks / 10000000); + function formatDuration(ms?: number | null): string { + if (!ms) return ""; + const seconds = Math.floor(ms / 1000); const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); @@ -183,28 +184,27 @@ } function handleItemClick(clickedItem: MediaItem | Library) { - if (!("type" in clickedItem)) { + if (!("kind" in clickedItem)) { // Library item - navigate to library goto(`/library/${clickedItem.id}`); return; } // A ChannelFolderItem can be either a folder (drill in) or a playable leaf. // 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}`); return; } - switch (clickedItem.type) { - case "Series": - case "Season": - case "MusicAlbum": - case "MusicArtist": - case "Folder": - case "Playlist": - case "Channel": - case "ChannelFolderItem": - case "Episode": - case "Movie": + switch (clickedItem.kind) { + case "series": + case "season": + case "album": + case "artist": + case "folder": + case "playlist": + case "channel": + case "episode": + case "movie": goto(`/library/${clickedItem.id}`); break; default: @@ -223,9 +223,9 @@ async function handlePlayAll() { // 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}`); - } 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) try { const repo = auth.getRepository(); @@ -248,7 +248,7 @@ } 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 try { const repo = auth.getRepository(); @@ -284,7 +284,7 @@ ); 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() { @@ -331,13 +331,13 @@
{:else if item} - {#if item.type === "Person"} + {#if item.kind === "person"}
- {:else if item.type === "Series" && focusedEpisode} + {:else if item.kind === "series" && focusedEpisode}
- {#if item.primaryImageTag} + {#if item.imageId}

{item.name}

- {#if item.type === "Episode" && (item.parentIndexNumber || item.indexNumber)} + {#if item.kind === "episode" && (item.parentIndexNumber || item.indexNumber)}

{#if item.parentIndexNumber}Season {item.parentIndexNumber}{/if} {#if item.parentIndexNumber && item.indexNumber}, {/if} @@ -403,11 +403,11 @@

- {#if item.type} - {item.type} + {#if kindLabel(item.kind)} + {kindLabel(item.kind)} {/if} - {#if item.runTimeTicks} - {formatDuration(item.runTimeTicks)} + {#if item.durationMs} + {formatDuration(item.durationMs)} {/if} {#if item.communityRating} @@ -430,7 +430,7 @@ Play - {#if item.type !== "Episode" && item.type !== "Movie"} + {#if item.kind !== "episode" && item.kind !== "movie"}