From 55fa26377a8ab38d401bf6d62b96a311f17c3a8d Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 23 Jul 2026 20:53:47 +0200 Subject: [PATCH] domain: introduce provider-neutral media model (phase 1) Establish src-tauri/src/domain/ as the single source of truth for the media model, with all Jellyfin translation isolated in from_jellyfin.rs. Adds MediaKind enum and neutral duration_ms/image_id fields to MediaItem as additive, defaulted dual-carry alongside the legacy Jellyfin-named fields, so nothing breaks while the frontend migrates off them. - domain/media.rs: canonical MediaKind (closed enum, replaces stringly item_type), Default = Other so unknown/defaulted items are inert. - domain/from_jellyfin.rs: total, panic-free item_type -> MediaKind classification (all audited types + person subroles) and ticks->ms. - MediaItem gains kind/duration_ms/image_id, populated at both mapping seams (online to_media_item, offline cached_item_to_media_item) and the synthesized-album/person sites. - Regenerated bindings.ts: frontend now HAS the neutral model available. Phase 1 of docs/specs/frontend-domain-model.md. No frontend behaviour change yet; wire shape is a superset of before. Rust 456 tests, frontend 644 tests, check + check:boundary all green. --- bun.lock | 79 ++++++++++---- src-tauri/src/domain/from_jellyfin.rs | 151 ++++++++++++++++++++++++++ src-tauri/src/domain/media.rs | 68 ++++++++++++ src-tauri/src/domain/mod.rs | 10 ++ src-tauri/src/lib.rs | 1 + src-tauri/src/player/mod.rs | 3 + src-tauri/src/repository/hybrid.rs | 3 + src-tauri/src/repository/offline.rs | 15 ++- src-tauri/src/repository/online.rs | 10 +- src-tauri/src/repository/types.rs | 36 +++++- src/lib/api/bindings.ts | 99 ++++++++++++++++- 11 files changed, 448 insertions(+), 27 deletions(-) create mode 100644 src-tauri/src/domain/from_jellyfin.rs create mode 100644 src-tauri/src/domain/media.rs create mode 100644 src-tauri/src/domain/mod.rs 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/domain/from_jellyfin.rs b/src-tauri/src/domain/from_jellyfin.rs new file mode 100644 index 00000000..06411dcc --- /dev/null +++ b/src-tauri/src/domain/from_jellyfin.rs @@ -0,0 +1,151 @@ +//! 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; + +/// 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 + } + + // Live TV / channels + "TvChannel" | "LiveTvChannel" | "Channel" => MediaKind::Channel, + + // Containers + "Folder" | "CollectionFolder" | "UserView" | "BoxSet" => MediaKind::Folder, + // ChannelFolderItem is a container when it is a folder, else a leaf we + // do not model further. + "ChannelFolderItem" => { + if is_folder { + MediaKind::Folder + } else { + MediaKind::Other + } + } + + // 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::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::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..021d79c2 --- /dev/null +++ b/src-tauri/src/domain/media.rs @@ -0,0 +1,68 @@ +//! 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 + Channel, + Folder, + /// 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, +} + +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..90b35f92 --- /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, ticks_to_ms}; +pub use media::MediaKind; 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/player/mod.rs b/src-tauri/src/player/mod.rs index 29736b7a..d81f2c2c 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -2177,6 +2177,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 +2185,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/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..cf21714e 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, @@ -1599,6 +1604,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 +1612,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 +2108,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 +2116,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..d07a6a90 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, @@ -923,6 +928,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 +940,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..796b927c 100644 --- a/src-tauri/src/repository/types.rs +++ b/src-tauri/src/repository/types.rs @@ -97,13 +97,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 +138,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")] @@ -525,6 +550,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 +562,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 +703,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 +715,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 +767,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 +779,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..84369664 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -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,46 @@ 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" | "channel" | "folder" | +/** + * 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 */ @@ -2276,7 +2330,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 +2355,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) */