Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90f03dd142 | ||
|
|
514e42fccb | ||
|
|
9b1c9b3c91 | ||
|
|
1780109fb1 | ||
|
|
3a18ad060b | ||
|
|
1968c06172 | ||
|
|
ec8a7610f5 | ||
|
|
93d198ce21 | ||
|
|
2b42b74912 | ||
|
|
7660a33dfc | ||
|
|
3e962a202c | ||
|
|
43ddc5a889 | ||
|
|
772e9ca6d5 | ||
|
|
55fa26377a | ||
|
|
f89b241ad6 | ||
|
|
8b028b6b60 | ||
|
|
dd9d4191f1 | ||
|
|
bacb9ca0bb | ||
|
|
cf9472f04f | ||
|
|
f25deba824 | ||
|
|
8f4f651bac | ||
|
|
c175378f38 | ||
|
|
e083b53ee8 | ||
|
|
8f8433eebe | ||
|
|
6f057ad14a |
@@ -49,6 +49,13 @@ jobs:
|
||||
run: |
|
||||
bun install
|
||||
|
||||
# Tripwire for domain-taxonomy leaks into the presentation layer (a
|
||||
# multi-type includeItemTypes query defining a category in the frontend).
|
||||
# See scripts/check-frontend-boundary.sh and
|
||||
# docs/specs/scoped-search-boundary.md.
|
||||
- name: Check frontend/backend boundary
|
||||
run: bash scripts/check-frontend-boundary.sh
|
||||
|
||||
- name: Run frontend tests
|
||||
run: |
|
||||
bunx svelte-kit sync
|
||||
|
||||
@@ -117,6 +117,8 @@ jobs:
|
||||
git config user.email "actions@gitea.tourolle.paris"
|
||||
git checkout -q -b gitea-pages
|
||||
git add -A
|
||||
git commit -q -m "docs: publish site from ${GITHUB_SHA::8}"
|
||||
# POSIX sh has no ${VAR::N} substring expansion — cut instead.
|
||||
SHORT_SHA="$(printf '%s' "$GITHUB_SHA" | cut -c1-8)"
|
||||
git commit -q -m "docs: publish site from ${SHORT_SHA}"
|
||||
echo "🚀 Force-pushing to gitea-pages"
|
||||
git push -f "$REMOTE" gitea-pages
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
name: Requirement Traceability Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
traceability:
|
||||
name: Validate Requirement Traces
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
|
||||
# action needed — fetching it stalls on this Gitea runner.
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Extract requirement traces
|
||||
run: bun run traces:json > traces.json
|
||||
|
||||
- name: Validate trace format
|
||||
run: |
|
||||
if ! jq empty traces.json 2>/dev/null; then
|
||||
echo "❌ Invalid traces.json format"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Traces JSON is valid"
|
||||
|
||||
- name: Check requirement coverage
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Extract coverage stats
|
||||
TOTAL_TRACES=$(jq '.totalTraces' traces.json)
|
||||
UR_COUNT=$(jq '.byType.UR | length' traces.json)
|
||||
IR_COUNT=$(jq '.byType.IR | length' traces.json)
|
||||
DR_COUNT=$(jq '.byType.DR | length' traces.json)
|
||||
JA_COUNT=$(jq '.byType.JA | length' traces.json)
|
||||
|
||||
echo "## 📊 Requirement Traceability Report"
|
||||
echo ""
|
||||
echo "**Total TRACES Found:** $TOTAL_TRACES"
|
||||
echo ""
|
||||
echo "### Requirements Covered:"
|
||||
echo "- User Requirements (UR): $UR_COUNT / 39 ($(( UR_COUNT * 100 / 39 ))%)"
|
||||
echo "- Integration Requirements (IR): $IR_COUNT / 24 ($(( IR_COUNT * 100 / 24 ))%)"
|
||||
echo "- Development Requirements (DR): $DR_COUNT / 48 ($(( DR_COUNT * 100 / 48 ))%)"
|
||||
echo "- Jellyfin API Requirements (JA): $JA_COUNT / 3 ($(( JA_COUNT * 100 / 3 ))%)"
|
||||
echo ""
|
||||
|
||||
# Set minimum coverage threshold (50%)
|
||||
TOTAL_REQS=114
|
||||
MIN_COVERAGE=$((TOTAL_REQS / 2))
|
||||
COVERED=$((UR_COUNT + IR_COUNT + DR_COUNT + JA_COUNT))
|
||||
COVERAGE_PERCENT=$((COVERED * 100 / TOTAL_REQS))
|
||||
|
||||
echo "**Overall Coverage:** $COVERED / $TOTAL_REQS ($COVERAGE_PERCENT%)"
|
||||
echo ""
|
||||
|
||||
if [ "$COVERED" -lt "$MIN_COVERAGE" ]; then
|
||||
echo "❌ Coverage below minimum threshold ($COVERAGE_PERCENT% < 50%)"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Coverage meets minimum threshold ($COVERAGE_PERCENT% >= 50%)"
|
||||
fi
|
||||
|
||||
- name: Check for new untraced code
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Find files modified in this PR/push
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(ts|tsx|svelte|rs)$' || true)
|
||||
else
|
||||
CHANGED_FILES=$(git diff --name-only HEAD~1 | grep -E '\.(ts|tsx|svelte|rs)$' || true)
|
||||
fi
|
||||
|
||||
if [ -z "$CHANGED_FILES" ]; then
|
||||
echo "✅ No source files changed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "### Files Changed:"
|
||||
echo "$CHANGED_FILES" | sed 's/^/- /'
|
||||
echo ""
|
||||
|
||||
# Check if changed files have TRACES
|
||||
UNTRACED_FILES=""
|
||||
while IFS= read -r file; do
|
||||
if [ -f "$file" ]; then
|
||||
# Skip test files and generated code
|
||||
if [[ "$file" == *".test."* ]] || [[ "$file" == *"node_modules"* ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if file has TRACES comments
|
||||
if ! grep -q "TRACES:" "$file" 2>/dev/null; then
|
||||
UNTRACED_FILES+="$file"$'\n'
|
||||
fi
|
||||
fi
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
if [ -n "$UNTRACED_FILES" ]; then
|
||||
echo "⚠️ New files without TRACES:"
|
||||
echo "$UNTRACED_FILES" | sed 's/^/ - /'
|
||||
echo ""
|
||||
echo "💡 Add TRACES comments to link code to requirements:"
|
||||
echo " // TRACES: UR-001, UR-002 | DR-003"
|
||||
else
|
||||
echo "✅ All changed files have TRACES comments"
|
||||
fi
|
||||
|
||||
- name: Generate traceability report
|
||||
if: always()
|
||||
run: bun run traces:markdown
|
||||
|
||||
- name: Upload traceability report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: traceability-report
|
||||
path: docs/traceability.md
|
||||
retention-days: 30
|
||||
|
||||
- name: Comment PR with coverage report
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const traces = JSON.parse(fs.readFileSync('traces.json', 'utf8'));
|
||||
|
||||
const urCount = traces.byType.UR.length;
|
||||
const irCount = traces.byType.IR.length;
|
||||
const drCount = traces.byType.DR.length;
|
||||
const jaCount = traces.byType.JA.length;
|
||||
const total = urCount + irCount + drCount + jaCount;
|
||||
const coverage = Math.round((total / 114) * 100);
|
||||
|
||||
const comment = `## 📊 Requirement Traceability Report
|
||||
|
||||
**Coverage:** ${coverage}% (${total}/114 requirements traced)
|
||||
|
||||
### By Type:
|
||||
- **User Requirements (UR):** ${urCount}/39 (${Math.round(urCount/39*100)}%)
|
||||
- **Integration Requirements (IR):** ${irCount}/24 (${Math.round(irCount/24*100)}%)
|
||||
- **Development Requirements (DR):** ${drCount}/48 (${Math.round(drCount/48*100)}%)
|
||||
- **Jellyfin API (JA):** ${jaCount}/3 (${Math.round(jaCount/3*100)}%)
|
||||
|
||||
**Total Traces:** ${traces.totalTraces}
|
||||
|
||||
[View full report](artifacts) | [Format Guide](https://github.com/yourusername/jellytau/blob/master/scripts/README.md#extract-tracests)`;
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: comment
|
||||
});
|
||||
@@ -39,6 +39,8 @@ only against the mirror if one exists; the canonical remote is
|
||||
|
||||
- Frontend: `bun run check` and `bun run test` must pass.
|
||||
- Rust: `cd src-tauri && cargo fmt` then `cargo clippy`, plus `bun run test:rust`.
|
||||
- **Boundary**: `bun run check:boundary` must pass — no domain taxonomy (Jellyfin
|
||||
item-type category sets) leaked into the frontend. See below.
|
||||
- **Traceability**: new requirement-implementing code must carry a `// TRACES:`
|
||||
comment (see below).
|
||||
- **Android source edits**: edit `src-tauri/android/src` (the canonical tree),
|
||||
@@ -161,6 +163,25 @@ and [docs/build-release.md](docs/build-release.md).
|
||||
- **Graceful backend init.** If a native player backend fails to initialize, the
|
||||
app falls back to a no-op backend and emits `backend-init-failed` rather than
|
||||
crashing.
|
||||
- **Domain vocabulary lives in Rust.** The frontend is presentation-only and must
|
||||
not encode Jellyfin's *taxonomy* — e.g. the set of item types that defines a
|
||||
category like "Music". Send an opaque scope/enum across the boundary and let the
|
||||
backend expand it. Single-type presentation (`itemType: "Movie"`, "this page
|
||||
shows albums") is fine; a *category → set of types* mapping in `src/` is a leak.
|
||||
`bun run check:boundary` is the tripwire; the real gate is the spec's layer
|
||||
assignment. See [scoped-search-boundary.md](docs/specs/scoped-search-boundary.md)
|
||||
for the incident this rule came from.
|
||||
|
||||
## Writing specs
|
||||
|
||||
New feature specs go in [docs/specs/](docs/specs/). **Start from
|
||||
[SPEC-TEMPLATE.md](docs/specs/SPEC-TEMPLATE.md)** — its "Layer assignment" section
|
||||
forces each piece of *logic* to be placed in the correct layer (Rust = domain,
|
||||
frontend = presentation) *with a reason*, which is what prevents boundary leaks.
|
||||
Before accepting a spec, run it past
|
||||
[SPEC-REVIEW-CHECKLIST.md](docs/specs/SPEC-REVIEW-CHECKLIST.md). Do **not** frame
|
||||
a spec around "no Rust changes required" — correct layer placement is the goal,
|
||||
not minimal backend churn.
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -51,14 +51,19 @@ graph TD
|
||||
|
||||
**View Enforcement:**
|
||||
|
||||
Ordinal content (where position carries meaning) is always a list. Everything
|
||||
else honours the user's persisted grid/list preference — see
|
||||
[ux-flows.md §5A.2](../ux-flows.md).
|
||||
|
||||
| Content Type | View Mode | Toggle Visible | Component Used |
|
||||
|--------------|-----------|----------------|----------------|
|
||||
| Tracks | List (forced) | No | `TrackList` |
|
||||
| Artists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||
| Albums | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||
| Playlists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||
| Genres | Grid (both levels) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||
| Album Detail Tracks | List (forced) | No | `TrackList` |
|
||||
| Tracks | List (forced — ordinal) | No | `TrackList` |
|
||||
| Artists | User preference | Yes | `LibraryGrid` |
|
||||
| Albums | User preference | Yes | `LibraryGrid` |
|
||||
| Playlists | User preference | Yes | `LibraryGrid` |
|
||||
| Genres | User preference (both levels) | Yes | `LibraryGrid` |
|
||||
| Album Detail Tracks | List (forced — ordinal) | No | `TrackList` |
|
||||
| Season Episodes | List (forced — ordinal) | No | `SeasonSection` |
|
||||
|
||||
**TrackList Component:**
|
||||
|
||||
@@ -80,9 +85,16 @@ The `TrackList` component (`src/lib/components/library/TrackList.svelte`) is a d
|
||||
/>
|
||||
```
|
||||
|
||||
**LibraryGrid forceGrid Prop:**
|
||||
**LibraryGrid view mode:**
|
||||
|
||||
The `forceGrid` prop prevents the grid/list view toggle from appearing and forces grid view regardless of user preference. This ensures visual content (artists, albums, playlists) is always displayed as cards with artwork.
|
||||
`LibraryGrid` reads the global `viewMode` store (persisted to `localStorage`)
|
||||
and renders `LibraryListView` or the card grid accordingly. The `showViewToggle`
|
||||
prop controls whether the toggle buttons appear in the page header; the grid
|
||||
itself always follows the stored preference.
|
||||
|
||||
A `forceGrid` prop previously existed to pin pages to grid regardless of
|
||||
preference. No caller ever passed it, so it was removed — pages that were
|
||||
documented as "forced grid" have in practice always honoured the toggle.
|
||||
|
||||
## Playback Reporting Service
|
||||
|
||||
|
||||
@@ -58,6 +58,16 @@ For a narrative overview of the system design, see
|
||||
| UR-045 | Predictively pre-cache likely-next media (queue lookahead and album affinity) within a storage budget | Low | Done |
|
||||
| UR-046 | Group multiple remote players into a synchronized playback group (LMS SyncGroups) | Low | Done |
|
||||
| UR-047 | Manage multiple Jellyfin servers (add, list, remove) and switch the active server/account | Medium | Planned (backend store done; switcher UI pending) |
|
||||
| UR-048 | See the next episodes of a series directly below the episode/series being viewed, above cast and similar-shows content, so continuing a show is the shortest path (see [ux-flows.md §5B](ux-flows.md)) | High | Done |
|
||||
| UR-049 | Search is scoped by where it was started — inside a library it searches that library, from Home/library-root/search-tab it searches everything — with the scope shown as filter chips under the search bar that preselect from context and can be changed without retyping (see [ux-flows.md §6.1](ux-flows.md)) | High | Implemented |
|
||||
| UR-050 | Reorder search result groups (Songs, Albums, Artists, Movies, TV Shows) by drag and drop in settings, so the media a user cares about most appears first (see [ux-flows.md §6.3](ux-flows.md)) | Medium | Implemented |
|
||||
| UR-051 | Browse library pages in a consistent layout where card shape signals media type (square music, poster video, thumbnail episode), ordinal content stays listed, and the grid/list preference persists across pages (see [ux-flows.md §5A](ux-flows.md)) | Medium | Partial (implemented; toggle not reachable from settings) |
|
||||
| UR-052 | While offline, library pages show only media available on the device by default, with an opt-in toggle that additionally reveals the cached server catalog as greyed-out entries which can be queued for download on the next reconnect | High | Broken (toggle does not gate the listing; see issue #10) |
|
||||
| UR-053 | Restrict media downloads to unmetered networks via a "WiFi Only" setting: when enabled, queued downloads are held while the device is on cellular or a metered connection (including metered WiFi hotspots) and resume automatically once an unmetered network is available | Medium | Done (pending device verification) |
|
||||
| UR-054 | Reach account actions (Settings, Downloads, Display preferences, Sign out) from every authenticated screen via a single account menu anchored to the user's name, identical on desktop and mobile (see [ux-flows.md §1.2](ux-flows.md)) | High | Done |
|
||||
| UR-055 | Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see [ux-flows.md §7.2](ux-flows.md)) | High | Planned |
|
||||
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Planned |
|
||||
| UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.1](ux-flows.md)) | Medium | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -97,6 +107,7 @@ External system integrations and platform-specific implementations.
|
||||
| IR-026 | Android picture-in-picture: auto-enter on user-leave-hint via `enterPictureInPictureMode`, **only while a local video surface is actively rendering** (never for audio-only playback, menu/library browsing, or remote/cast sessions — enforced by the native `canEnterPip` guard, re-checked at leave time); aspect-ratio sizing; a play/pause RemoteAction that **reflects live player play/pause state** (updated whenever playback state changes, not only on button press); WebView hide/restore on mode change | Platform | UR-041 | Done |
|
||||
| IR-027 | Jellyfin `/System/Info/Public` reachability probe used as an offline→online recovery detector | API | UR-043 | Done |
|
||||
| IR-028 | Jellyfin/LMS SyncGroups API client (list, create, join, unsync, dissolve sync groups) | API | UR-046 | Done |
|
||||
| IR-029 | Android `ConnectivityManager`/`NetworkCapabilities` transport probe with a `NetworkCallback` change subscription, surfaced to the frontend via the `AndroidNetworkType` JS bridge and the `jellytau-network-changed` WebView event (requires `ACCESS_NETWORK_STATE`) | Platform | UR-053 | Done (pending device verification) |
|
||||
|
||||
### 2.2 Jellyfin API Requirements
|
||||
|
||||
@@ -203,6 +214,29 @@ Internal architecture, components, and application logic.
|
||||
| DR-058 | Remote sync-group control (LMS SyncGroups): list, create, unsync a player, dissolve a group | Player | UR-046 | Done |
|
||||
| DR-059 | Playback-mode transfer state machine: get/set current mode, transferring guard, transfer-to-remote / transfer-to-local, remote session status | Player | UR-010 | Done |
|
||||
| DR-060 | Multi-server store and active-account selection: save/get/delete server, save/get user, set/get active user (per-server), active-session resolution | Storage | UR-047 | Partial (store done; server-switcher UI pending) |
|
||||
| DR-061 | Episode Focus View: episode hero followed *immediately* by the "More Episodes" strip — a forward-biased window (~3 before / ~6 after) around the current episode, spanning season boundaries in series order, with the current episode present and badged, per-card resume progress and watched state, and click-to-swap focus (no playback) | UI | UR-048 | Done |
|
||||
| DR-062 | Detail-page section ordering: continuation content precedes discovery content — Episode Focus View renders hero → episode strip → cast → similar; Series renders hero → seasons/episodes → cast → similar | UI | UR-048 | Done |
|
||||
| DR-063 | Search scope resolver mapping the originating route to an `includeItemTypes` set (All / Music / Movies / TV), defaulting to All for Home, `/library`, and the search tab | UI | UR-049 | Implemented |
|
||||
| DR-064 | Scope chip row rendered under the search bar on both the search page and the in-library header search: preselected from context, horizontally scrollable, re-runs the search preserving the query on change | UI | UR-049 | Implemented |
|
||||
| DR-065 | Thread `SearchOptions.includeItemTypes` through `library.search()` so the global/header search honours scope (backend online + offline paths already support it) | UI | UR-049 | Implemented |
|
||||
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (Songs → Albums → Artists → Movies → TV Shows), and empty-group omission | Settings | UR-050 | Implemented |
|
||||
| DR-067 | `SearchResults` renders groups in the user-configured order rather than hardcoded markup order, without altering intra-group ranking | UI | UR-050 | Implemented |
|
||||
| DR-068 | Library card shape by media type: 1:1 square for music (circular mask for artists), 2:3 poster for movies/series/seasons, 16:9 for episodes and collection folders | UI | UR-051 | Done |
|
||||
| DR-069 | Responsive library grid (2/3/4/5/6 columns across base→xl) with two-line truncated card text and artwork-overlay progress/watched state | UI | UR-051 | Done |
|
||||
| DR-070 | Global persisted grid/list view preference honoured by browse pages, suppressed for ordinal content (album tracks, season episodes) | UI | UR-051, UR-029 | Partial (persisted store + page-header toggle; no settings entry) |
|
||||
| DR-075 | Shared `AccountMenu` component: identity header (user + server), Downloads / Settings / Display entries, divider, Sign out last; anchored to the username/avatar trigger and identical on desktop and mobile | UI | UR-054 | Done |
|
||||
| DR-076 | App shell exposes the header (and therefore the account menu) on every authenticated non-immersive route, including `/`, `/search`, and `/downloads`; only `/player/*` and `/login` remain chrome-free | UI | UR-054 | Done |
|
||||
| DR-077 | Display section in Settings binding the existing persisted grid/list `viewMode` store, giving the preference a discoverable home | Settings | UR-054, UR-029 | Done |
|
||||
| DR-078 | Catalog-visibility gate spanning the "Show all server media" toggle → `set_show_server_catalog` → `INCLUDE_CATALOG_BROWSE` → the synced-catalog UNION branch of offline `get_items`. Visibility resolves to `serverReachable \|\| showServerCatalog`, so offline with the toggle off lists downloaded/local media only | Storage | UR-052, UR-002 | Partial (gate implemented and unit-tested; defeated upstream by DR-079 and by the repository fallback in DR-080) |
|
||||
| DR-079 | `isConnected` derives from backend-reported server reachability alone; `navigator.onLine` is advisory and may only trigger a recheck, never force or clear the offline state (a reachable LAN server while the browser reports offline, and an unreachable server on a live link, must both resolve correctly) | Connectivity | UR-052, UR-043 | Broken (`isConnected` ANDs in `navigator.onLine`, so a live link with an unreachable server never enters offline listing) |
|
||||
| DR-080 | With the catalog-browse gate off, an empty offline `get_items` result is authoritative "no downloads here" and must be returned as-is; the hybrid repository must not treat it as a cache miss and fall through to the server | Storage | UR-052, UR-013 | Broken (`has_content()` cache-hit test in `HybridRepository::get_items`/`parallel_race` falls through to the server on an intentionally empty result) |
|
||||
| DR-074 | WiFi-only download gate: `NetworkState`/`NetworkType` transport model reported from the platform via `set_network_state`, checked in `pump_download_queue` before starting any pending row (cellular/metered/unknown fail closed, WiFi and Ethernet require `NOT_METERED`); blocked rows stay `pending` and re-pump on network change, with a `waitingForNetwork` event driving the "Waiting for WiFi" notice. Also wires the previously inert Smart Caching / Queue Pre-caching / WiFi Only settings toggles to `CacheConfig` | Downloads | UR-053 | Done (pending device verification) |
|
||||
| DR-081 | `/downloads` split into a default **Downloaded** browse view and a secondary **Transfers** activity view, with a view switch and a Transfers badge shown only while transfers are active | UI | UR-055 | Planned |
|
||||
| DR-082 | Offline-scoped browse entry point in the repository client: browse downloaded content only (offline repository `get_items`/`get_libraries` — downloaded items plus their containers) independent of server reachability, without merging server catalog | Storage | UR-055 | Planned |
|
||||
| DR-083 | Downloaded browse reuses library grids, cards, and detail pages via the offline-scoped source; omits libraries/containers with no downloaded content; badges partially- vs fully-downloaded containers; play uses the local file; remove available at item/album/season/series level | UI | UR-055 | Planned |
|
||||
| DR-084 | Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in Downloaded | UI | UR-055 | Planned |
|
||||
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Planned |
|
||||
| DR-086 | Settings page persists each control on change via per-group writers (`playerSetAudioSettings` / `playerSetVideoSettings` / `updateCacheConfig`) rather than a batch Save action; slider controls persist on `change` (pointer release) not each `input` tick; no Save button, `saving`, or `saveMessage` state | Settings | UR-057 | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -259,6 +293,16 @@ Internal architecture, components, and application logic.
|
||||
| UR-045 | - | DR-057 |
|
||||
| UR-046 | IR-028 | DR-058 |
|
||||
| UR-047 | IR-013 | DR-060 |
|
||||
| UR-048 | - | DR-061, DR-062 |
|
||||
| UR-049 | IR-010 | DR-063, DR-064, DR-065 |
|
||||
| UR-050 | - | DR-066, DR-067 |
|
||||
| UR-051 | - | DR-068, DR-069, DR-070 |
|
||||
| UR-052 | IR-027 | DR-078, DR-079, DR-080 |
|
||||
| UR-053 | IR-029 | DR-074 |
|
||||
| UR-054 | - | DR-075, DR-076, DR-077 |
|
||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 |
|
||||
| UR-056 | - | DR-085 |
|
||||
| UR-057 | - | DR-086 |
|
||||
|
||||
---
|
||||
|
||||
@@ -329,6 +373,11 @@ Internal architecture, components, and application logic.
|
||||
| UT-059 | Audio-only stream URL builder for a video item (selected audio-stream index) | JA-032, DR-052 | Pending |
|
||||
| UT-060 | Background-audio handoff state machine (background→audio, foreground→video; no dual audio) | DR-052 | Pending |
|
||||
| UT-061 | Background-audio Tauri command param naming (camelCase) | DR-052 | Pending |
|
||||
| UT-067 | Offline `get_items` gates the synced-catalog UNION on the catalog-browse flag (downloads only when off, full catalog when on) | DR-078 | Done |
|
||||
| UT-068 | Catalog visibility resolves to `serverReachable \|\| showServerCatalog`, and is pushed to the backend on every change of either input | DR-078, DR-079 | Pending |
|
||||
| UT-069 | `isConnected` follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while `navigator.onLine` is false | DR-079 | Pending |
|
||||
| UT-070 | Hybrid `get_items` returns an empty offline result as-is when the catalog-browse gate is off, without querying the server | DR-080 | Pending |
|
||||
| UT-066 | WiFi-only download gate: cellular and metered WiFi blocked, unmetered WiFi/Ethernet allowed, unknown/none fail closed, desktop default ungated; plus the frontend network reporter (transport reporting, change subscription, teardown, fail-open queries) | DR-074 | Done |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
@@ -347,6 +396,8 @@ Internal architecture, components, and application logic.
|
||||
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
|
||||
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
|
||||
| IT-013 | Background-audio handoff on Android: background/lock continues audio via native service and stops video decode; foreground resumes video at position | IR-025, UR-040 | Pending |
|
||||
| IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Pending |
|
||||
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Pending |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Spec review checklist
|
||||
|
||||
Run a spec past this before accepting it. It exists because JellyTau's
|
||||
backend/frontend boundary is a **stated rule with, historically, no gate** — the
|
||||
rule lived in the architecture docs, but nothing forced a spec author to check a
|
||||
new design against it, and a "minimal-change" spec quietly leaked domain
|
||||
taxonomy into the frontend (see [scoped-search-boundary.md](scoped-search-boundary.md)).
|
||||
This checklist is the human gate. The CI check
|
||||
(`scripts/check-frontend-boundary.sh`) is only a crude tripwire for one leak
|
||||
signature — it does **not** replace this.
|
||||
|
||||
Copy the boxes into the review comment (or the PR) and tick them.
|
||||
|
||||
## Boundary (the one that bites)
|
||||
|
||||
- [ ] **The spec has a filled-in "Layer assignment" table**, and it assigns
|
||||
*logic*, not files. A spec without this section is not ready to review.
|
||||
- [ ] **No domain vocabulary is placed in the frontend.** In particular: Jellyfin
|
||||
item-type sets that define a *category* (what "Music"/"TV"/"Movies" means),
|
||||
query-shaping rules, business rules, reachability/sync policy. If the
|
||||
frontend names a *set* of item types to define a category, that is a leak —
|
||||
it belongs behind an opaque enum the backend expands.
|
||||
- [ ] **"The backend already accepts this parameter" was not used as the reason**
|
||||
to place the deciding logic in the frontend. Accepting a parameter ≠ owning
|
||||
the decision of its value.
|
||||
- [ ] **The `Scope:` / effort framing is not optimizing for "least backend
|
||||
change."** "Frontend only, no Rust changes" is a description, never a goal.
|
||||
The goal is *correct layer placement*; sometimes that is more Rust work.
|
||||
- [ ] Ran the litmus test on each borderline responsibility: *would it change if
|
||||
Jellyfin's API changed?* → Rust. *Only if the UI were redesigned?* →
|
||||
frontend. Borderline defaults to Rust.
|
||||
- [ ] Single-type presentation (`itemType: "Movie"`, "this page shows albums")
|
||||
is **not** over-corrected into the backend. The rule targets category
|
||||
*taxonomy*, not every mention of a type. Don't invent a backend enum per
|
||||
list page.
|
||||
|
||||
## IPC contract
|
||||
|
||||
- [ ] Anything crossing the boundary has its wire shape specified.
|
||||
- [ ] camelCase rule accounted for: top-level params auto-convert; nested structs
|
||||
get `#[serde(rename_all = "camelCase")]`; tagged unions match tags on both
|
||||
sides; events are kebab-case. (CLAUDE.md §IPC,
|
||||
[04-type-sync-and-threading.md](../architecture/04-type-sync-and-threading.md).)
|
||||
- [ ] Any result that arrives *twice* (command return **and** a later event —
|
||||
e.g. the search cache/server merge) has **both** payloads in the new shape.
|
||||
- [ ] `bindings.ts` is regenerated from Rust, not hand-edited.
|
||||
|
||||
## Requirements & traceability
|
||||
|
||||
- [ ] Linked to existing URs, or new URs/DRs are allocated in
|
||||
[requirements.md](../requirements.md).
|
||||
- [ ] Requirement-implementing code will carry `// TRACES:` comments (CLAUDE.md).
|
||||
- [ ] Traceability coverage stays ≥ 50% (the CI gate).
|
||||
|
||||
## Conflicts & hygiene
|
||||
|
||||
- [ ] If this spec revises/supersedes another, the older spec gets a banner
|
||||
pointing here — no two specs silently contradicting.
|
||||
- [ ] Acceptance criteria include the standard gates: `bun run check`,
|
||||
`bun run test`, `bun run check:boundary`, and (if Rust changed)
|
||||
`cargo fmt`/`cargo clippy`/`bun run test:rust`.
|
||||
- [ ] Notes flag that a parallel Claude session may be active in the repo.
|
||||
|
||||
---
|
||||
|
||||
**If any Boundary box can't be ticked, the spec is not ready** — fix the layer
|
||||
assignment first. Every other section can be negotiated; that one is the whole
|
||||
reason this file exists.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Spec: <feature name>
|
||||
|
||||
<!--
|
||||
Copy this file to docs/specs/<kebab-name>.md and fill it in. Delete the HTML
|
||||
comments as you go. The section that matters most for this project is
|
||||
"Layer assignment" — read its comment before writing it.
|
||||
|
||||
Before merging a spec, run it past docs/specs/SPEC-REVIEW-CHECKLIST.md.
|
||||
-->
|
||||
|
||||
**Status:** Proposed <!-- Proposed | Accepted | Implemented | Superseded -->
|
||||
**Requirements:** <!-- UR-xxx → DR-yyy; allocate new DRs in requirements.md. -->
|
||||
**UX spec:** <!-- link to the relevant ux-flows.md section, or "n/a". -->
|
||||
**Supersedes / revises:** <!-- link any spec this changes, or delete this line. -->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- 2–4 sentences. What changes for the user, in plain terms. -->
|
||||
|
||||
## Motivation
|
||||
|
||||
<!-- Why now. The problem being solved. -->
|
||||
|
||||
## Layer assignment
|
||||
|
||||
<!--
|
||||
🔴 THIS IS THE SECTION THAT KEEPS THE ARCHITECTURE HONEST. Do not skip it, and
|
||||
do NOT reframe it as "how little backend work can we get away with."
|
||||
|
||||
The project rule (CLAUDE.md, architecture/02-svelte-frontend.md): the Rust
|
||||
backend owns ALL business logic — auth, catalog, sessions, downloads, offline,
|
||||
playback, AND domain vocabulary (e.g. what Jellyfin item types the category
|
||||
"Music" means). The Svelte frontend is PRESENTATION ONLY: rendering, layout,
|
||||
navigation, view/order preferences, input handling.
|
||||
|
||||
For each distinct piece of *logic* this feature introduces, put it in the table
|
||||
and name the layer it belongs to and WHY. "It's less work in the frontend" and
|
||||
"the backend already accepts this parameter" are NOT reasons to place logic in
|
||||
the frontend — the backend accepting a parameter does not make deciding that
|
||||
parameter's value a presentation concern.
|
||||
|
||||
Litmus test for "does this belong in Rust?": Would this logic have to change if
|
||||
Jellyfin changed its API, added an item type, or altered a business rule? If
|
||||
yes, it is domain logic → Rust. Would it change if we redesigned the UI? If
|
||||
yes (and only yes), it is presentation → frontend.
|
||||
|
||||
A past incident: scoped-search.md placed the item-type taxonomy (what "Music"
|
||||
means as a set of Jellyfin types) in the frontend because the backend already
|
||||
accepted an includeItemTypes filter. That was a boundary leak; see
|
||||
scoped-search-boundary.md. This section exists to catch that class of mistake
|
||||
at spec time, not in review three features later.
|
||||
-->
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| <!-- e.g. scope → item-types --> | Rust | <!-- domain vocabulary; changes with Jellyfin's API --> |
|
||||
| <!-- e.g. group display order --> | Frontend | <!-- pure presentation; changes only if UI is redesigned --> |
|
||||
|
||||
<!--
|
||||
If a row is genuinely borderline, say so and give the tie-breaker you used.
|
||||
Borderline defaults to Rust for anything touching domain data or vocabulary.
|
||||
-->
|
||||
|
||||
## Design
|
||||
|
||||
<!--
|
||||
How it works. Wire shapes for anything crossing the IPC boundary. Remember:
|
||||
- Command NAME must match the Rust fn name exactly.
|
||||
- Top-level params auto-convert snake_case → camelCase (Tauri v2).
|
||||
- Nested struct fields need #[serde(rename_all = "camelCase")].
|
||||
- Events are kebab-case.
|
||||
(See CLAUDE.md §IPC and architecture/04-type-sync-and-threading.md.)
|
||||
|
||||
Regenerate bindings.ts from Rust types; never hand-edit it.
|
||||
-->
|
||||
|
||||
## Out of scope
|
||||
|
||||
<!-- What this spec deliberately does NOT do. -->
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
<!-- Checkable statements. Include the standard gates: -->
|
||||
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes (if Rust changed).
|
||||
- [ ] `bun run check:boundary` passes (no taxonomy leak into the frontend).
|
||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||
- [ ] `bindings.ts` regenerated if Rust types changed.
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- Rust: cargo test. Frontend: vitest, src/lib/**/*.test.ts. What to cover. -->
|
||||
|
||||
## TRACES
|
||||
|
||||
<!-- Suggested tags per new/changed piece: UR-xxx | DR-yyy | tests. -->
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
<!--
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes (see project memory / CLAUDE.md gotchas).
|
||||
- Anything else non-obvious.
|
||||
-->
|
||||
@@ -0,0 +1,164 @@
|
||||
# Spec: Account menu and global chrome availability
|
||||
|
||||
**Status:** Implemented
|
||||
**Scope:** Frontend only. No Rust changes required.
|
||||
**Requirements:** UR-054 → DR-075, DR-076, DR-077 (see
|
||||
[requirements.md](../requirements.md)).
|
||||
**UX spec:** [ux-flows.md §1.2–1.4](../ux-flows.md).
|
||||
|
||||
## Summary
|
||||
|
||||
Account actions — Settings, Downloads, Display preferences, Sign out — are
|
||||
currently reachable **only from `/library/*`**. Move them into a single shared
|
||||
account menu anchored to the user's name, and make that menu available on every
|
||||
authenticated non-immersive screen.
|
||||
|
||||
## Motivation
|
||||
|
||||
A user sitting on the home screen cannot open Settings or sign out. The bottom
|
||||
nav offers Home / Search / Library only, and the header that hosts those actions
|
||||
belongs to the library layout. The user has to guess that account actions live
|
||||
*inside* Library — an unrelated section — and navigate there first.
|
||||
|
||||
Desktop and mobile also disagree today: desktop shows an unlabeled logout icon
|
||||
with no grouped menu, mobile shows a three-dot overflow with labelled items. The
|
||||
same two actions are found two different ways.
|
||||
|
||||
## Background: verified current state
|
||||
|
||||
1. **The header is not global.** It is defined in
|
||||
[library/+layout.svelte](../../src/routes/library/+layout.svelte). The root
|
||||
layout [+layout.svelte](../../src/routes/+layout.svelte) renders no header at
|
||||
all.
|
||||
|
||||
2. **`routeOwnsLayout`** in
|
||||
[layoutShell.ts](../../src/lib/utils/layoutShell.ts) returns true for
|
||||
`/library`, `/player/`, `/login` — those routes own their own full-height
|
||||
flex column. Everything else renders into the root scroller with the root's
|
||||
`BottomUi` below it.
|
||||
|
||||
3. **Bottom nav is Home / Search / Library only**
|
||||
([BottomNav.svelte](../../src/lib/components/BottomNav.svelte)) — no Settings
|
||||
or account entry.
|
||||
|
||||
4. **Net effect:** on `/`, `/search`, and `/downloads` there is no route to
|
||||
Settings or Sign out.
|
||||
|
||||
5. **Desktop username is inert text** — a `<span>` next to the icons, not a
|
||||
trigger.
|
||||
|
||||
6. **The mobile overflow menu already has the right contents** (Downloads,
|
||||
Settings, divider, Sign out) and the right dismissal behaviour (backdrop
|
||||
click, keyboard handler). **Extract and reuse it rather than rewriting it.**
|
||||
|
||||
7. **`viewMode` is already a persisted store** in
|
||||
[library.ts](../../src/lib/stores/library.ts) (`jellytau-view-mode`,
|
||||
`localStorage`). The Display setting is a second view onto it — **no new
|
||||
state, no migration.**
|
||||
|
||||
## Design
|
||||
|
||||
### `AccountMenu` component (DR-075)
|
||||
|
||||
One component used by both breakpoints. Contents in fixed order:
|
||||
|
||||
```
|
||||
Signed in as <name> ← identity block, not interactive
|
||||
<server host>
|
||||
────────────────────────
|
||||
Downloads
|
||||
Settings
|
||||
Display ← grid/list preference
|
||||
────────────────────────
|
||||
Sign out ← destructive, last, after a divider
|
||||
```
|
||||
|
||||
- **Trigger is the username/avatar**, not a bare three-dot icon. On mobile where
|
||||
horizontal space is tight, the avatar (or initial) alone is acceptable; the
|
||||
name shows inside the open menu regardless.
|
||||
- **Same items, same order, both platforms.**
|
||||
- Preserve the existing dismissal behaviour: click-outside backdrop, `Escape`,
|
||||
and focus return to the trigger on close.
|
||||
- Menu items are real links/buttons — keyboard reachable, correct roles,
|
||||
`aria-expanded` on the trigger.
|
||||
|
||||
"Display" may either navigate to the Settings Display section or expose the
|
||||
grid/list choice inline. Prefer navigating — it keeps one source of truth for
|
||||
preferences and avoids a nested control inside a dropdown.
|
||||
|
||||
### Global chrome (DR-076)
|
||||
|
||||
Make the header — and therefore the account menu — available on `/`, `/search`,
|
||||
and `/downloads`.
|
||||
|
||||
The cleanest route is to lift the header out of the library layout into a shared
|
||||
component rendered by the root layout, with the library layout consuming the
|
||||
same component rather than defining its own. **Do not duplicate the markup into
|
||||
each route.**
|
||||
|
||||
Constraints that must survive the change:
|
||||
|
||||
- `/player/*` and `/login` stay chrome-free.
|
||||
- `/settings` already owns its layout; it needs no account menu (the user is
|
||||
already there), but must not double up on chrome.
|
||||
- The root layout's flex/scroller structure is deliberate — the comments in
|
||||
[layoutShell.ts](../../src/lib/utils/layoutShell.ts) and
|
||||
[+layout.svelte](../../src/routes/+layout.svelte) explain why routes own their
|
||||
own column. Preserve the scroll containment; a regression here reintroduces
|
||||
the "last row hidden behind the nav" bug called out in those comments.
|
||||
- Mini-player and bottom-nav visibility rules (`showGlobalMiniPlayer`,
|
||||
`showBottomNav`) must be unchanged.
|
||||
|
||||
### Display section in Settings (DR-077)
|
||||
|
||||
Add a Display section to [settings/+page.svelte](../../src/routes/settings/+page.svelte)
|
||||
with the grid/list control bound to the existing `viewMode` store via
|
||||
`library.setViewMode(...)`. The page-header toggle in `LibraryGrid` stays — both
|
||||
controls drive the same store, so they stay in sync for free.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Redesigning the Settings page or reorganising its existing sections.
|
||||
- Multi-server / account switching (UR-047) — the identity block displays the
|
||||
active server but offers no switcher.
|
||||
- Changing the bottom nav's three destinations.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Settings and Sign out are reachable from `/`, `/search`, and `/downloads`
|
||||
without first navigating into Library.
|
||||
- [ ] Desktop and mobile show the same account menu items in the same order.
|
||||
- [ ] The username/avatar opens the menu; it is a real button with
|
||||
`aria-expanded`.
|
||||
- [ ] Sign out is last, after a divider, and still logs out + resets library
|
||||
state + redirects as it does today.
|
||||
- [ ] `/player/*` and `/login` remain chrome-free.
|
||||
- [ ] Settings has a Display section that changes grid/list, and the change is
|
||||
immediately reflected by the library page-header toggle (same store).
|
||||
- [ ] No regression in scroll containment, mini-player visibility, or bottom-nav
|
||||
visibility on any route.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
|
||||
## Testing
|
||||
|
||||
- Extend the existing `layoutShell` tests: chrome-visibility for `/`, `/search`,
|
||||
`/downloads` (now true) and `/player/*`, `/login` (still false).
|
||||
- `AccountMenu`: renders the documented items in order; trigger toggles
|
||||
`aria-expanded`; `Escape` and backdrop click close it; Sign out invokes the
|
||||
logout handler.
|
||||
- Display setting: writes through to the `viewMode` store and persists.
|
||||
|
||||
New requirement-implementing code needs `TRACES:` comments — see
|
||||
[CLAUDE.md](../../CLAUDE.md). Suggested: `AccountMenu` → `UR-054 | DR-075`,
|
||||
shell/header changes → `UR-054 | DR-076`, Settings Display section →
|
||||
`UR-054, UR-029 | DR-077`.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [ux-flows.md §1.2–1.4](../ux-flows.md) first — behavioural spec; this is
|
||||
the implementation plan.
|
||||
- The layout shell is subtle and the existing comments record real bugs that
|
||||
were fixed there. Read them before restructuring.
|
||||
- Another session may be active in this repo, including in
|
||||
`src/routes/settings/+page.svelte`. Check `git diff` before "repairing"
|
||||
unexpected changes, and expect to coordinate on that file.
|
||||
@@ -0,0 +1,166 @@
|
||||
# Spec: Downloads as a browsable offline library
|
||||
|
||||
**Status:** Draft — ready to implement
|
||||
**Scope:** Frontend-heavy; one new repository-client browse path. Minimal Rust.
|
||||
**Requirements:** UR-055 → DR-081, DR-082, DR-083, DR-084; UR-056 → DR-085
|
||||
(see [requirements.md](../requirements.md)).
|
||||
**UX spec:** [ux-flows.md §7.2–7.7](../ux-flows.md).
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the flat Active/Completed download list with two views under
|
||||
`/downloads`:
|
||||
|
||||
1. **Downloaded** (default) — the library, filtered to what's on the device,
|
||||
using the *same* browse screens as online (grids, cards, detail pages).
|
||||
2. **Transfers** — the existing progress-row list, demoted to a secondary tab,
|
||||
showing only in-flight transfers.
|
||||
|
||||
Plus per-item disk usage (UR-056) shown in familiar units on cards, detail
|
||||
pages, a device total, and the remove confirmation.
|
||||
|
||||
## Motivation
|
||||
|
||||
A user who downloaded three seasons and two albums sees ~70 individual transfer
|
||||
rows today, with no grouping and no reuse of the library UI. "What do I have
|
||||
offline" and "what is downloading" are different questions crammed into one flat
|
||||
list. Browsing offline should feel exactly like browsing online.
|
||||
|
||||
## Background: verified current state
|
||||
|
||||
1. **The offline repository is already a browsable tree.**
|
||||
[offline.rs](../../src-tauri/src/repository/offline.rs) — `get_items` returns
|
||||
downloaded items **plus** containers (MusicAlbum, Series, Season) that have at
|
||||
least one downloaded child. `get_libraries`, `get_item`, and `search` all
|
||||
filter to downloaded content via CTEs. This is the data source for
|
||||
Downloaded; **do not build a new query layer.**
|
||||
|
||||
2. **The client cannot reach it independently.**
|
||||
[repository-client.ts](../../src/lib/api/repository-client.ts) `getItems` →
|
||||
`repositoryGetItems` always goes through the **hybrid** repository
|
||||
([hybrid.rs](../../src-tauri/src/repository/hybrid.rs)), which merges cache and
|
||||
server. There is no "offline only" browse path exposed. This is the one real
|
||||
backend gap (DR-082).
|
||||
|
||||
3. **Downloads page is a flat two-tab list.**
|
||||
[downloads/+page.svelte](../../src/routes/downloads/+page.svelte) — Active /
|
||||
Completed tabs, one `DownloadItem` row per transfer, no browsing.
|
||||
|
||||
4. **Library browse components are reusable as-is.** `LibraryGrid`, `MediaCard`,
|
||||
the `/library/[id]` detail page (§5A/§5B) render whatever items they are
|
||||
given. Downloaded browse is those components with an offline-scoped source.
|
||||
|
||||
5. **A related fallthrough bug is already tracked** (DR-080, another session):
|
||||
`HybridRepository::get_items` treats an empty offline result as a cache miss
|
||||
and falls through to the server. The offline-only browse path (DR-082) must
|
||||
**not** share that behaviour — an empty result there is authoritative "nothing
|
||||
downloaded here."
|
||||
|
||||
6. **Concurrency, the 3-download cap, and the auto-pump are backend concerns.**
|
||||
Do not surface them as manual controls; do not loop `startDownload` from the
|
||||
frontend (see [CLAUDE.md](../../CLAUDE.md) gotchas).
|
||||
|
||||
## Design
|
||||
|
||||
### View split (DR-081)
|
||||
|
||||
`/downloads` renders a **Downloaded** / **Transfers** switch. Downloaded is the
|
||||
default. Transfers shows a count/badge only while transfers are active.
|
||||
Initiating downloads stays on item/album/series detail pages (§7.1) — this page
|
||||
does not start downloads.
|
||||
|
||||
### Offline-scoped browse source (DR-082, DR-083)
|
||||
|
||||
Add an explicit offline-only browse path so Downloaded never merges server
|
||||
results and never depends on reachability. Two viable shapes — pick per the
|
||||
codebase, do not do both:
|
||||
|
||||
- **(a)** A dedicated command (e.g. `repository_get_downloaded_items` /
|
||||
`_libraries`) that calls the offline repository directly, with a matching
|
||||
client method; or
|
||||
- **(b)** An explicit `offlineOnly`/scope flag on the existing get-items path
|
||||
that bypasses the hybrid merge and the empty→fallthrough behaviour.
|
||||
|
||||
Either way: an empty result is authoritative (do **not** reuse the DR-080
|
||||
fallthrough), and the path is available while the server is reachable (a user
|
||||
online still wants to browse their downloads).
|
||||
|
||||
Downloaded then reuses `LibraryGrid` / `MediaCard` / the detail page against this
|
||||
source. Omit libraries and containers with no downloaded content. Badge
|
||||
partially- vs fully-downloaded containers. Play uses the local file; remove is
|
||||
available at item / album / season / series level and removes a container from
|
||||
the browse when its last downloaded child goes.
|
||||
|
||||
### Transfers view (DR-084)
|
||||
|
||||
The existing list, filtered to in-flight rows only: downloading (with progress),
|
||||
queued, paused, failed, waiting-for-WiFi (the DR-074 state from the other
|
||||
session). Controls: Pause / Resume / Cancel / Retry. Completed transfers leave
|
||||
this view — they appear in Downloaded. Empty state points at the library.
|
||||
|
||||
### Disk usage (DR-085, UR-056)
|
||||
|
||||
- **Source the bytes from the download manager** — it writes the files and can
|
||||
stat them. Aggregate to album/season/series subtotals and a device total.
|
||||
This is display + aggregation, **not** new tracking.
|
||||
- **Format once, consistently.** One shared formatter, human units, 2–3
|
||||
significant figures (`1.2 GB`, `340 MB`). Binary vs decimal — pick one and use
|
||||
it everywhere.
|
||||
- **Surface it in familiar places:** a secondary size label on the card and
|
||||
detail page; a device total at the top of Downloaded (`3.4 GB · 12 items`)
|
||||
that reconciles with the listed sum; a reclaim figure in the remove
|
||||
confirmation ("frees 1.2 GB"). No separate "storage report" screen.
|
||||
- Sort/filter by size is a nice-to-have, not required for v1.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing download initiation, the 3-concurrent cap, or the auto-pump.
|
||||
- The catalog-browse / show-server-catalog toggle (UR-052, another session) —
|
||||
that governs the *online offline-fallback* library; this is the dedicated
|
||||
Downloads surface. They should be consistent but are separate work.
|
||||
- Fixing the DR-080 hybrid fallthrough bug (owned elsewhere) — just don't depend
|
||||
on that behaviour here.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `/downloads` opens on Downloaded and can switch to Transfers.
|
||||
- [ ] Downloaded lists only libraries/containers with downloaded content, using
|
||||
the same grids/cards/detail pages as online browsing.
|
||||
- [ ] Browsing Downloaded never shows non-downloaded server items, online or off.
|
||||
- [ ] An empty Downloaded result reads as "nothing downloaded," never falls
|
||||
through to the server.
|
||||
- [ ] Play from Downloaded plays the local file.
|
||||
- [ ] Remove works at item/album/season/series level and updates the browse.
|
||||
- [ ] Transfers shows only in-flight rows with working controls; finished
|
||||
transfers move to Downloaded.
|
||||
- [ ] Each downloaded item/container shows its on-disk size; a device total is
|
||||
shown and reconciles with the sum; remove states the reclaim amount.
|
||||
- [ ] `bun run check`, `bun run test`, and (if Rust touched) `cargo test` +
|
||||
`cargo clippy` pass.
|
||||
|
||||
## Testing
|
||||
|
||||
- Repository client: the offline-only browse path returns downloaded content and
|
||||
its containers, and an empty result does **not** trigger server fallthrough.
|
||||
- Downloaded view: libraries/containers with no downloads are omitted;
|
||||
partial/full container badging.
|
||||
- Transfers: only in-flight statuses render; a completed transfer disappears.
|
||||
- Size formatter: rounding and unit thresholds; subtotal aggregation; device
|
||||
total reconciles with listed items.
|
||||
- If a Rust command is added, add the tauri IPC param-naming coverage per
|
||||
[CLAUDE.md](../../CLAUDE.md) (camelCase rule).
|
||||
|
||||
New requirement-implementing code needs `TRACES:` comments. Suggested tags:
|
||||
view split `UR-055 | DR-081`; offline browse path `UR-055 | DR-082, DR-083`;
|
||||
Transfers `UR-055 | DR-084`; size display `UR-056 | DR-085`.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [ux-flows.md §7.2–7.7](../ux-flows.md) first — behavioural spec; this is
|
||||
the implementation plan.
|
||||
- The offline repository already does the hard part. The main work is a clean
|
||||
offline-only client path and reusing the library components — resist
|
||||
rebuilding browse UI.
|
||||
- Another session is active in downloads/offline/connectivity code (DR-074,
|
||||
DR-078–080). Coordinate on [downloads/+page.svelte](../../src/routes/downloads/+page.svelte)
|
||||
and the repository layer; check `git diff` before repairing unexpected changes.
|
||||
@@ -0,0 +1,309 @@
|
||||
# Spec: Remove Jellyfin-specific models from the frontend
|
||||
|
||||
> **Implementation status (branch `frontend-domain-model`, worktree
|
||||
> `../JellyTau-domain-model`):** Catalog surface **done**. The frontend's *item
|
||||
> classification* and *time units* no longer speak Jellyfin:
|
||||
> - `domain/` module is the single source of truth; `MediaKind` enum + isolated
|
||||
> `from_jellyfin` mapping. The model gained real distinctions the flat
|
||||
> `item_type` had hidden: `LiveChannel` / `ChannelItem` / `Channel`.
|
||||
> - Every catalog `item.type === "..."` → `item.kind` (0 remaining in `src/`).
|
||||
> - Catalog ticks → milliseconds (`durationMs`, `playbackPositionMs`);
|
||||
> `formatDuration` takes ms; progress bars are unit-consistent.
|
||||
> - User-facing type badge → `kindLabel()`.
|
||||
> - Old Jellyfin-named fields remain **dual-carried** on the wire so nothing broke.
|
||||
>
|
||||
> **Deferred (tracked, not done):**
|
||||
> - `primaryImageTag` → `imageId` rename (naming-only; ~40 sites across catalog +
|
||||
> `PlayerMediaItem`/`MergedMediaItem`, the latter needing a Rust `image_id`
|
||||
> round-trip). Catalog `MediaItem` already has `imageId`.
|
||||
> - Player/session/reporting tick math (`Queue`, `SessionCard`, `RemoteControls`,
|
||||
> `playbackReporting`, `playerEvents`) — crosses storage/Jellyfin *command
|
||||
> signatures* in ticks; needs those commands to accept ms (phase 4).
|
||||
> - `stream.type` (`mediaStreams[].type`) — Jellyfin stream vocabulary (phase 4).
|
||||
> - Delete `playbackUnits.ts` / `jellyfinFieldMapping.ts` once their last
|
||||
> consumers migrate; drop the dual-carried fields once nothing reads them.
|
||||
|
||||
**Status:** Partially implemented (catalog surface); see banner.
|
||||
**Requirements:** Architectural (boundary integrity — CLAUDE.md core principles).
|
||||
Allocate new DRs on acceptance; suggested: DR for the domain `MediaItem`/`MediaKind`
|
||||
type, DR for tick/image-tag hoisting, DR for the phased frontend migration
|
||||
(see [requirements.md](../requirements.md)). Relates to UR-007, UR-008, UR-034.
|
||||
**UX spec:** n/a — zero user-visible behaviour change. This is a pure
|
||||
architecture/boundary migration.
|
||||
**Supersedes / revises:** none. Extends the boundary work started in
|
||||
[scoped-search-boundary.md](scoped-search-boundary.md) from *taxonomy* to the
|
||||
*whole media model*.
|
||||
|
||||
## Summary
|
||||
|
||||
The frontend currently consumes Jellyfin's data model directly: `MediaItem` is a
|
||||
Jellyfin DTO (`runTimeTicks`, `primaryImageTag`, `parentIndexNumber`, a
|
||||
stringly-typed `type: string` carrying Jellyfin's item vocabulary), mirrored via
|
||||
specta into **36+ frontend files**, with **127 `item.type === "…"` string
|
||||
comparisons across 23 files** and two frontend utility modules
|
||||
(`playbackUnits.ts`, `jellyfinFieldMapping.ts`) doing Jellyfin-specific unit and
|
||||
field conversion in the presentation layer.
|
||||
|
||||
This spec defines a **provider-neutral domain model**, owned by Rust, that the
|
||||
Jellyfin repository maps *into*. The frontend consumes only that model. When done,
|
||||
no Jellyfin vocabulary — item-type strings, ticks, image tags, Jellyfin field
|
||||
names — remains in `src/`.
|
||||
|
||||
## Motivation
|
||||
|
||||
Two concrete problems, one strategic:
|
||||
|
||||
1. **Boundary violation at scale.** Per CLAUDE.md, the frontend is
|
||||
presentation-only and Rust owns the domain. Today the *domain model itself* is
|
||||
Jellyfin's wire shape, propagated unchanged across IPC. The frontend knows what
|
||||
a "tick" is, what `primaryImageTag` means, and that `"Audio"` is a track. That
|
||||
is domain knowledge in the wrong layer, 36 files deep.
|
||||
2. **Fragility.** `type: string` is unchecked: a typo (`"Epis0de"`) or a Jellyfin
|
||||
rename fails silently at runtime with no compiler help, across 127 sites. Tick
|
||||
math (`* 10_000_000`) duplicated frontend-side is a class of bug the backend
|
||||
should have already resolved.
|
||||
3. **Strategic (the reason we chose the ambitious target):** a neutral domain
|
||||
model is the precondition for **ever supporting a non-Jellyfin backend** (Plex,
|
||||
local files, Subsonic). As long as the UI speaks Jellyfin, that door is welded
|
||||
shut.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Definition of the media domain model (`MediaItem`, `MediaKind`) | **Rust** | The canonical shape the whole app reasons about; must not be a provider's wire format. |
|
||||
| Jellyfin DTO → domain mapping (ticks→ms, image tag→url/id, `"Audio"`→`Track`, `PremiereDate`→`releaseDate`) | **Rust**, in the Jellyfin repository | Provider-specific translation; changes if Jellyfin changes; is the definition of "how Jellyfin maps to our domain." |
|
||||
| Tick arithmetic (`playbackUnits.ts`) | **Rust** | A Jellyfin unit. The frontend should never see ticks; it receives `durationMs`/`positionMs`. |
|
||||
| Sort-field mapping (`jellyfinFieldMapping.ts`, `title→SortName`) | **Rust** | Maps neutral sort keys to Jellyfin query fields — provider vocabulary. Frontend sends a neutral `SortKey`. |
|
||||
| `MediaKind` classification (is this a track / album / episode?) | **Rust** | Derived from Jellyfin's `item_type`; the frontend receives the already-classified kind. |
|
||||
| Choosing which kind renders as a card vs a list row; grid/list toggle; group order | **Frontend** | Pure presentation over the neutral `kind`. Changes only if the UI is redesigned. |
|
||||
| Navigation decisions (`kind === Track && albumId` → go to album) | **Frontend** | Presentation/routing over neutral fields. |
|
||||
|
||||
**Borderline calls, resolved:**
|
||||
|
||||
- *`MergedMediaItem`* (the lightweight now-playing projection) is already
|
||||
half-neutral (`title`, `artist`, `duration`) — it becomes a straightforward
|
||||
subset of the new domain model, not a special case.
|
||||
- *Context discriminators* `"album"`, `"playlist"`, `"remote"` (in `TrackList`,
|
||||
playback context, sessions) are **already domain-neutral** — they are *our*
|
||||
vocabulary, not Jellyfin's. They stay as-is; do not confuse them with
|
||||
`item_type`. Only the Jellyfin item-type strings move.
|
||||
- *`mediaStreams[].type === "Audio"/"Subtitle"/"Video"`* (track selection in
|
||||
VideoPlayer) is Jellyfin stream vocabulary too, but is lower-risk and
|
||||
self-contained — deferred to a late phase, not phase 1.
|
||||
|
||||
## Design
|
||||
|
||||
### Single canonical model, one location, isolated mappings
|
||||
|
||||
The domain model is defined **once**, in a dedicated top-level Rust module
|
||||
`src-tauri/src/domain/`, and is the single source of truth shared across the
|
||||
whole app:
|
||||
|
||||
```
|
||||
src-tauri/src/domain/
|
||||
media.rs canonical MediaItem, MediaKind, and the other media types
|
||||
from_jellyfin.rs Jellyfin DTO -> domain mapping, ISOLATED here
|
||||
mod.rs re-exports
|
||||
| tauri-specta (export_typescript_bindings test)
|
||||
v
|
||||
src/lib/api/bindings.ts generated MediaItem/MediaKind — the frontend copy
|
||||
```
|
||||
|
||||
- **One definition.** `domain::MediaItem` is *the* model. Rust (repositories,
|
||||
player, downloads) uses it directly. The frontend uses the generated `bindings.ts`
|
||||
projection of it. There is no second hand-written copy in either language, so it
|
||||
cannot drift — "shared between frontend and backend" is realized by generation,
|
||||
not duplication.
|
||||
- **Mappings live beside the model, never in consumers.** All provider translation
|
||||
(`JellyfinItem` → `domain::MediaItem`, ticks→ms, image-tag→id, item-type→`MediaKind`)
|
||||
lives in `domain/from_jellyfin.rs`. It is the *only* place Jellyfin vocabulary
|
||||
touches the domain type. Adding a second provider later means a new
|
||||
`from_<provider>.rs` beside it — the model and every consumer stay untouched.
|
||||
- **`domain` is a top-level module** (not under `repository/`) because `MediaItem`
|
||||
is used by `player/`, `download/`, and `playback_mode/` too — it is not
|
||||
repository-specific.
|
||||
- The existing `JellyfinItem` DTO + `to_media_item()` in
|
||||
[online.rs](../../src-tauri/src/repository/online.rs) is the seam that already
|
||||
exists; it **moves** into `domain/from_jellyfin.rs` and is enriched to do real
|
||||
translation instead of copying `item_type` through.
|
||||
|
||||
### The domain model (Rust)
|
||||
|
||||
```rust
|
||||
// src-tauri/src/domain/media.rs — provider-neutral. NO Jellyfin vocabulary.
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MediaKind {
|
||||
Track, Album, Artist, Playlist, // music
|
||||
Movie, Series, Season, Episode, // video
|
||||
Person, // cast/crew
|
||||
Channel, Folder, // containers/live
|
||||
}
|
||||
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: MediaKind, // was: type: String
|
||||
pub is_folder: bool,
|
||||
pub server_id: String,
|
||||
|
||||
// Times in milliseconds — NEVER ticks.
|
||||
pub duration_ms: Option<i64>, // was: run_time_ticks
|
||||
|
||||
// Image as a resolved identifier the frontend turns into a URL via the
|
||||
// existing image command — no raw Jellyfin tag semantics leak.
|
||||
pub image_id: Option<String>, // was: primary_image_tag
|
||||
pub backdrop_image_ids: Option<Vec<String>>,
|
||||
|
||||
pub overview: Option<String>,
|
||||
pub genres: Option<Vec<String>>,
|
||||
pub production_year: Option<i32>,
|
||||
pub release_date: Option<String>, // was: premiere_date (ISO-8601)
|
||||
pub community_rating: Option<f64>,
|
||||
pub official_rating: Option<String>,
|
||||
|
||||
// Relationships — already neutral, kept.
|
||||
pub album_id: Option<String>, pub album_name: Option<String>,
|
||||
pub album_artist: Option<String>, pub artists: Option<Vec<String>>,
|
||||
pub artist_items: Option<Vec<ArtistItem>>,
|
||||
pub series_id: Option<String>, pub series_name: Option<String>,
|
||||
pub season_id: Option<String>, pub season_name: Option<String>,
|
||||
|
||||
// Ordinal position — rename off Jellyfin's index vocabulary.
|
||||
pub track_number: Option<i32>, // was: index_number
|
||||
pub disc_number: Option<i32>, // was: parent_index_number
|
||||
|
||||
pub user_data: Option<UserData>,
|
||||
pub media_streams: Option<Vec<MediaStream>>,
|
||||
pub media_sources: Option<Vec<MediaSource>>,
|
||||
pub people: Option<Vec<Person>>,
|
||||
}
|
||||
```
|
||||
|
||||
The existing `JellyfinItem` DTO (already defined in `online.rs`, deserialized
|
||||
from the Jellyfin JSON) **moves into `domain/from_jellyfin.rs`** and stays
|
||||
private to that module. Its `to_media_item()` — today a near-passthrough that
|
||||
copies `item_type` straight across — is enriched into the single, tested place
|
||||
that:
|
||||
|
||||
- classifies `item_type: String` → `MediaKind` (including the edge cases found in
|
||||
the audit: `"ChannelFolderItem"` → `Channel`/`Folder` by `is_folder`,
|
||||
`"TvChannel"` → `Channel`, `"Composer"/"Director"/"Writer"` → `Person`,
|
||||
`"Video"` → `Movie` or a video leaf). Unknown strings map to `Folder` or a new
|
||||
`Other` variant — **decide at implementation; must not panic.**
|
||||
- converts `run_time_ticks` → `duration_ms` (`ticks / 10_000`).
|
||||
- maps `PremiereDate` → `release_date`, image tags → image ids.
|
||||
|
||||
`SortKey` enum + its Jellyfin field mapping (`jellyfinFieldMapping.ts` contents)
|
||||
moves into the Jellyfin repository; the command takes a neutral `SortKey`.
|
||||
|
||||
### 🔴 The `search-event` / dual-payload rule applies again
|
||||
|
||||
Every path that returns `MediaItem` — command returns **and** the `search-event`
|
||||
and any other event payloads — emits the new domain shape. Both sides of a
|
||||
twice-delivered result must match (same rule as
|
||||
[scoped-search-boundary.md](scoped-search-boundary.md)). Grep for `MediaItem` in
|
||||
event definitions before declaring a phase done.
|
||||
|
||||
### Frontend after
|
||||
|
||||
- `MediaItem`/`MediaKind` come from generated `bindings.ts`.
|
||||
- `item.type === "Audio"` → `item.kind === "track"` (127 sites, mechanical).
|
||||
- `runTimeTicks` usages → `durationMs`; **delete `playbackUnits.ts`** (ticks no
|
||||
longer cross the boundary; keep only any purely-display seconds↔clock helpers if
|
||||
they exist, which are not Jellyfin-specific).
|
||||
- `primaryImageTag` → `imageId` through the existing image-URL command.
|
||||
- **Delete `jellyfinFieldMapping.ts`**; sort options send a neutral `SortKey`.
|
||||
- Assert with the boundary tripwire + a new grep (see acceptance).
|
||||
|
||||
## Phased migration
|
||||
|
||||
This is too large and too collision-prone for one change. Phases are independently
|
||||
shippable, each keeps all tests green, and each is a reviewable PR:
|
||||
|
||||
1. **Establish the `domain/` module + enriched mapping, tests — no frontend
|
||||
change yet.** Create `src-tauri/src/domain/{media,from_jellyfin,mod}.rs`. Move
|
||||
`JellyfinItem`/`to_media_item` in. Add `MediaKind` and the neutral fields to
|
||||
`domain::MediaItem` as *additive, defaulted* fields, and populate them in the
|
||||
mapping, while **keeping the old Jellyfin-named fields too** (dual-carry). The
|
||||
wire shape is a superset of today's, so the frontend still compiles and
|
||||
behaves identically. Lands the authority + full mapping unit coverage first,
|
||||
with zero blast radius on the 52 construction sites (they set the old fields;
|
||||
new ones default).
|
||||
2. **Flip the wire shape.** Commands + events emit the new `MediaItem`.
|
||||
Regenerate `bindings.ts`. Frontend breaks to compile errors — fix them
|
||||
mechanically (`type`→`kind`, values `"Audio"`→`"track"`, `runTimeTicks`→
|
||||
`durationMs`, `primaryImageTag`→`imageId`). This is the big mechanical PR;
|
||||
`bun run check` is the driver.
|
||||
3. **Delete the frontend conversion helpers** (`playbackUnits.ts` ticks,
|
||||
`jellyfinFieldMapping.ts`) and route sorting through the neutral `SortKey`.
|
||||
4. **Stream vocabulary** (`mediaStreams[].type`) and any remaining stragglers;
|
||||
tighten the boundary check to forbid Jellyfin item-type strings in `src/`
|
||||
outside tests.
|
||||
|
||||
Ship 1 → 2 → 3 → 4 as separate PRs. Do **not** attempt all four at once.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Actually adding a second backend (Plex/Subsonic). This spec only *unblocks* it.
|
||||
- Changing any user-visible behaviour, layout, or copy.
|
||||
- The player-internal `PlayerMediaItem` / `MediaSessionType` shapes, except where
|
||||
they carry the fields being renamed — align them in phase 2 only if the compiler
|
||||
demands it.
|
||||
- Context discriminators (`"album"`, `"playlist"`, `"remote"`) — already neutral.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] No Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, `"Series"`, …) is
|
||||
compared against `.type`/`.kind` anywhere in `src/` (outside tests). Verify:
|
||||
`grep -rIn '\.kind === "\(Audio\|MusicAlbum\|MusicArtist\|Series\|Episode\|Movie\|Playlist\)"' src/` returns nothing.
|
||||
- [ ] No `Ticks`, `runTimeTicks`, `primaryImageTag`, `PremiereDate`, or Jellyfin
|
||||
sort-field name (`SortName`, `RunTimeTicks`, …) appears in `src/` outside
|
||||
tests. `playbackUnits.ts` (ticks) and `jellyfinFieldMapping.ts` are deleted.
|
||||
- [ ] `MediaItem`/`MediaKind`/`SortKey` in the frontend come from `bindings.ts`.
|
||||
- [ ] The `From<JellyfinMediaDto>` mapping is total and never panics on an unknown
|
||||
item type (Rust test with a garbage type string).
|
||||
- [ ] Behaviour is identical: same library/search/home rendering, same sorting,
|
||||
same navigation, offline included.
|
||||
- [ ] Both command returns and event payloads carry the new shape (no flicker).
|
||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass;
|
||||
`cargo fmt`/`cargo clippy`/`bun run test:rust` pass; `bindings.ts` regenerated.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust** (`cargo test`): the `From<JellyfinMediaDto> for MediaItem` mapping is the
|
||||
critical surface —
|
||||
- every known `item_type` → correct `MediaKind` (table test over all 20 values
|
||||
found in the audit, incl. `ChannelFolderItem`, `TvChannel`, `Composer`);
|
||||
- unknown type string → safe fallback, no panic;
|
||||
- `run_time_ticks` → `duration_ms` (10_000 divisor), boundary/None cases;
|
||||
- `SortKey` → Jellyfin field mapping (port `jellyfinFieldMapping.ts`'s cases).
|
||||
|
||||
**Frontend** (vitest): update the many tests asserting `.type`/`runTimeTicks`;
|
||||
they become `.kind`/`durationMs`. `jellyfinFieldMapping`/`playbackUnits` tests are
|
||||
deleted with their modules. Add a compose/render test proving `kind`-based
|
||||
branching matches the old `type`-based branching for a representative mix.
|
||||
|
||||
## TRACES
|
||||
|
||||
Per [CLAUDE.md](../../CLAUDE.md): the domain type + mapping
|
||||
`UR-007, UR-008 | <new DR>`; the tick/field hoist `<new DR>`; frontend migration
|
||||
phases share the DRs of the capability each touches (don't invent per-file DRs).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **This is the highest-collision change in the repo's history** — it touches 36+
|
||||
frontend files and the core Rust types. A parallel Claude session in any media
|
||||
file will conflict. Strongly prefer a dedicated worktree per phase, and
|
||||
`git diff` before repairing anything (CLAUDE.md gotchas / project memory).
|
||||
- Phase 1 deliberately maps *back* to the old shape so it can land safely ahead of
|
||||
the disruptive flip. Resist the urge to skip it.
|
||||
- IPC camelCase rules apply to the new enums/structs
|
||||
([04-type-sync-and-threading.md](../architecture/04-type-sync-and-threading.md)):
|
||||
`#[serde(rename_all = "camelCase")]`; tagged-enum tag convention; regenerate
|
||||
`bindings.ts`, never hand-edit.
|
||||
- Reviewed against [SPEC-REVIEW-CHECKLIST.md](SPEC-REVIEW-CHECKLIST.md) — the
|
||||
Layer assignment table above is the load-bearing section.
|
||||
@@ -0,0 +1,235 @@
|
||||
# Spec: Offline "downloaded only" filtering (issue #10)
|
||||
|
||||
**Status:** Implemented
|
||||
**Scope:** Frontend (connectivity store) + Rust (hybrid repository). No new
|
||||
commands, no schema changes, no UI additions.
|
||||
**Requirements:** UR-052 → DR-078, DR-079, DR-080
|
||||
(see [requirements.md](../requirements.md)).
|
||||
**Tracking:** issue #10 — *"when offline the filter to show only downloaded
|
||||
media does not work."*
|
||||
|
||||
## Summary
|
||||
|
||||
Offline, a library page is supposed to show **only media on the device**, with a
|
||||
"Show all server media" toggle that additionally reveals the cached server
|
||||
catalog greyed out (queueable for download on reconnect). In practice the toggle
|
||||
does not gate the listing — every server item still appears. This spec fixes
|
||||
that with two independent changes; either one alone leaves the bug visible.
|
||||
|
||||
## Background: what already exists
|
||||
|
||||
Verified in code. **The feature is built and mostly correct — this is a
|
||||
two-point repair, not new infrastructure.** Do not rebuild the toggle, the
|
||||
command, or the SQL gate.
|
||||
|
||||
1. **The SQL gate works and is unit-tested.**
|
||||
[offline.rs](../../src-tauri/src/repository/offline.rs) — `get_items` appends
|
||||
the synced-catalog `UNION` branch only when `include_catalog_browse()` is
|
||||
true; with it false, only downloaded/local rows return. Guarded by
|
||||
`test_get_items_toggle_gates_synced_catalog` (UT-067). **Do not touch the
|
||||
query.**
|
||||
|
||||
2. **The toggle → backend path is wired.** The `showServerCatalog` store and the
|
||||
`set_show_server_catalog` command
|
||||
([catalog.rs](../../src-tauri/src/commands/catalog.rs)) drive the process-wide
|
||||
`INCLUDE_CATALOG_BROWSE` flag. `pushCatalogVisibility` in
|
||||
[offlineCatalog.ts](../../src/lib/services/offlineCatalog.ts) computes
|
||||
`include = connected || showCatalog` and pushes it on every change.
|
||||
|
||||
3. **Home-screen queries are already downloads-only.** `get_latest_items`,
|
||||
`get_resume_items`, `get_recently_played_audio`, `get_resume_movies` all
|
||||
`INNER JOIN downloads ... status = 'completed'`. They are unaffected — leave
|
||||
them.
|
||||
|
||||
4. **`MediaCard` already greys and queues.**
|
||||
[MediaCard.svelte](../../src/lib/components/library/MediaCard.svelte) —
|
||||
`isServerOnly` renders the greyed, inert card with a queue button; the queued
|
||||
row heals its `stream_url` on reconnect via the offlineCatalog service. Leave
|
||||
it.
|
||||
|
||||
## The two defects
|
||||
|
||||
### Defect A — offline is never actually entered (DR-079)
|
||||
|
||||
`pushCatalogVisibility` keys off `isConnected`, but
|
||||
[connectivity.ts](../../src/lib/stores/connectivity.ts) derives:
|
||||
|
||||
```ts
|
||||
isConnected = isOnline && isServerReachable // isOnline = navigator.onLine
|
||||
```
|
||||
|
||||
`navigator.onLine` is documented in that same file as **advisory only** — the
|
||||
Rust `ConnectivityMonitor` is the source of truth (principle: *reachability from
|
||||
real traffic*, DR-055). When the server is unreachable but the device link is
|
||||
up (server down, wrong LAN, VPN dropped), `isOnline` stays true, so `isConnected`
|
||||
stays true, so `include` stays true, so the backend keeps returning the full
|
||||
catalog. The user is "offline" in every meaningful sense but the toggle never
|
||||
gets a chance to gate anything.
|
||||
|
||||
This is the primary cause: it explains why the filter looks dead rather than
|
||||
merely inverted — the gate never closes.
|
||||
|
||||
### Defect B — an intentionally empty result falls through to the server (DR-080)
|
||||
|
||||
With the gate off and nothing downloaded in a library, offline `get_items`
|
||||
correctly returns few or zero rows. But
|
||||
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) treats a cache result as a
|
||||
hit only `if data.has_content()`. An empty offline result is indistinguishable
|
||||
from a cache miss, so `HybridRepository::get_items` (and `parallel_race`, used by
|
||||
~10 other reads) falls through to the server and returns the full server list —
|
||||
re-defeating the filter even after Defect A is fixed.
|
||||
|
||||
## Design
|
||||
|
||||
### Fix A: `isConnected` follows backend reachability alone (DR-079)
|
||||
|
||||
In [connectivity.ts](../../src/lib/stores/connectivity.ts), redefine the derived
|
||||
store:
|
||||
|
||||
```ts
|
||||
export const isConnected = derived(
|
||||
connectivity,
|
||||
($c) => $c.isServerReachable
|
||||
);
|
||||
```
|
||||
|
||||
`navigator.onLine` stays wired to what it is good for — a *trigger* for an
|
||||
immediate recheck (`online`/`offline` listeners already call
|
||||
`checkServerReachable()`); it must no longer be a *term* in the offline decision.
|
||||
Leave `isOnline` on the state object and the listeners intact.
|
||||
|
||||
Consider whether the optimistic `isServerReachable: true` startup default
|
||||
([connectivity.ts](../../src/lib/stores/connectivity.ts)) should hold until the
|
||||
first real check resolves. Keep it — flipping the app to "offline" on launch is a
|
||||
worse regression than a brief full-catalog flash before the first probe. Note the
|
||||
choice in a comment.
|
||||
|
||||
**Blast radius — this is the reason this is a spec, not a patch.** `isConnected`
|
||||
is consumed beyond this feature (banners, `MediaCard`, mini-player gating,
|
||||
anything importing it). Enumerate consumers first:
|
||||
|
||||
```
|
||||
grep -rn "isConnected" src/ | grep -v node_modules
|
||||
```
|
||||
|
||||
For each, confirm "server unreachable" (not "device link down") is the correct
|
||||
trigger. It almost always is — that is the whole point of the reachability model
|
||||
— but verify rather than assume, and call out anything that genuinely wanted the
|
||||
device link in the PR description.
|
||||
|
||||
### Fix B: an empty offline result is authoritative when the gate is off (DR-080)
|
||||
|
||||
The backend must distinguish "cache is cold, go ask the server" from "user asked
|
||||
for downloads only and there are none here." The gate flag already encodes intent
|
||||
— reuse it.
|
||||
|
||||
Add a getter beside the existing setter in
|
||||
[offline.rs](../../src-tauri/src/repository/offline.rs):
|
||||
|
||||
```rust
|
||||
pub fn include_catalog_browse() -> bool { /* pub, already exists privately */ }
|
||||
```
|
||||
|
||||
In [hybrid.rs](../../src-tauri/src/repository/hybrid.rs) `get_items`: when
|
||||
`!include_catalog_browse()`, treat the offline result as authoritative and return
|
||||
it **as-is even when empty** — do not spawn/await the server fallback for this
|
||||
call. When the flag is on (online fast-path, or offline with the toggle on),
|
||||
behaviour is unchanged: empty cache still falls through to the server.
|
||||
|
||||
Keep it surgical:
|
||||
|
||||
- Scope the change to `get_items`. The gate is a `get_items` concept; do not
|
||||
thread it into `parallel_race` or the other readers, which have no catalog
|
||||
gate and legitimately want the server on an empty cache.
|
||||
- Preserve the online path exactly: with the flag on (its default, and always so
|
||||
while reachable) the method behaves as it does today, including the background
|
||||
cache refresh on a hit.
|
||||
- The flag is process-global `Relaxed`; it is set from the frontend before the
|
||||
query. That ordering already holds for the SQL gate — no new synchronization.
|
||||
|
||||
### Why both
|
||||
|
||||
Fix A closes the gate; Fix B stops the hybrid from re-opening it. A alone: with
|
||||
downloads present the list still gets padded by the server fallback whenever a
|
||||
library's cache is thin. B alone: the gate never closes because `isConnected`
|
||||
never goes false on a live link. Ship them together.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The SQL gate, the toggle, the command, `INCLUDE_CATALOG_BROWSE` — all correct.
|
||||
- `MediaCard` greying / queue-on-reconnect — correct.
|
||||
- Home-screen and resume queries — already downloads-only.
|
||||
- The Rust `ConnectivityMonitor` reachability logic itself — unchanged; this
|
||||
spec only stops the *frontend* from diluting its verdict with `navigator.onLine`.
|
||||
- Any new IPC command, DB column, or settings entry.
|
||||
- Making the "Show all server media" toggle reachable from Settings (that is a
|
||||
UX-placement question, tracked separately under UR-051's toggle note).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [~] With the server unreachable on a live device link, a library page lists
|
||||
only downloaded media when the toggle is off (IT-016 — pending e2e; unit
|
||||
coverage via UT-069 + gate tests).
|
||||
- [x] Turning the toggle on reveals the greyed-out cached catalog; turning it off
|
||||
hides it again — without leaving/re-entering the page (SQL gate + toggle
|
||||
wiring unchanged; UT-068 confirms the flag is pushed on toggle change).
|
||||
- [x] A library with downloads and a thin cache does not get padded with
|
||||
non-downloaded server items when offline with the toggle off (Defect B —
|
||||
UT-070: gate off + empty offline result returned as-is, server not queried).
|
||||
- [x] `isConnected` is false whenever the server is unreachable, regardless of
|
||||
`navigator.onLine`; true for a reachable server even if the browser reports
|
||||
offline (UT-069).
|
||||
- [x] Every existing `isConnected` consumer still behaves correctly (banner in
|
||||
`+layout.svelte`, `MediaCard`, `favorites.ts` server-write skip — all want
|
||||
"server unreachable", which is the new semantics; `CastButton`'s local
|
||||
`isConnected` is unrelated). Full frontend suite (616 tests) green.
|
||||
- [x] Online behaviour is unchanged: with the flag on (its default, always so
|
||||
while reachable) `get_items` keeps the offline fast-path and background
|
||||
refresh (UT-067 + gate-on fall-through test).
|
||||
- [~] A download queued from a greyed offline card resolves and starts on
|
||||
reconnect (IT-017 — regression check, no code change; offlineCatalog
|
||||
resume path untouched).
|
||||
- [x] `bun run check`, `bun run test`, and `bun run test:rust` pass;
|
||||
`cd src-tauri && cargo fmt && cargo clippy` clean (no new warnings in the
|
||||
touched files).
|
||||
|
||||
## Testing
|
||||
|
||||
Rust ([offline.rs](../../src-tauri/src/repository/offline.rs) /
|
||||
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) test modules):
|
||||
|
||||
- **UT-070** — hybrid `get_items` with the gate off returns an empty offline
|
||||
result as-is and does **not** query the server. Assert via a mock online repo
|
||||
whose `get_items` bumps a call counter that must stay at zero.
|
||||
- Gate on + empty cache still falls through to the server (guard the online path).
|
||||
- UT-067 (`test_get_items_toggle_gates_synced_catalog`) must still pass untouched.
|
||||
|
||||
Frontend (vitest, `src/lib/**/*.test.ts`):
|
||||
|
||||
- **UT-069** — `isConnected` follows `isServerReachable` alone: false when
|
||||
unreachable with `navigator.onLine === true`; true when reachable with
|
||||
`navigator.onLine === false`.
|
||||
- **UT-068** — `pushCatalogVisibility` resolves `serverReachable || showCatalog`
|
||||
and pushes to the backend on a change of either input (extend the existing
|
||||
offlineCatalog tests).
|
||||
|
||||
Integration (IT-016, IT-017) are documented as pending in
|
||||
[requirements.md](../requirements.md); wire them if the e2e harness can simulate
|
||||
an unreachable-server-on-live-link state, otherwise leave them pending with a note.
|
||||
|
||||
New/changed requirement code keeps its `TRACES:` comments — see
|
||||
[CLAUDE.md](../../CLAUDE.md). The affected files already carry tags:
|
||||
`connectivity.ts` (`… | DR-079`), `hybrid.rs` (`… | DR-080`), `offline.rs`
|
||||
(`… | DR-078`). Update the getter's tag when you expose it.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [docs/architecture/07-connectivity.md](../architecture/07-connectivity.md)
|
||||
before Fix A — it is the canonical statement of the reachability model this fix
|
||||
restores fidelity to.
|
||||
- Fix B relies on the frontend having pushed the flag before the query runs; that
|
||||
ordering already holds for the SQL gate today. No new locking.
|
||||
- Another session is active in this repo (WiFi-only downloads, account menu
|
||||
landed alongside this work). Check `git diff` before "repairing" unexpected
|
||||
changes, and expect requirement IDs around UR-052 / DR-078 to be adjacent to
|
||||
other new rows.
|
||||
@@ -0,0 +1,273 @@
|
||||
# Spec: Move search scope taxonomy behind the Rust boundary
|
||||
|
||||
**Status:** Proposed
|
||||
**Scope:** Rust + Frontend. **Revises a decision in
|
||||
[scoped-search.md](scoped-search.md).**
|
||||
**Requirements:** UR-049, UR-050 (existing) → new DRs for the boundary move
|
||||
(allocate on implementation; suggested DR-063/DR-065/DR-067 revisions plus one
|
||||
new DR for the grouped result shape — see [requirements.md](../requirements.md)).
|
||||
**UX spec:** unchanged — [ux-flows.md §6](../ux-flows.md). This is a pure
|
||||
architecture/boundary change with **no user-visible behaviour difference**.
|
||||
|
||||
## Why this spec exists
|
||||
|
||||
[scoped-search.md](scoped-search.md) shipped scoped search as "frontend only, no
|
||||
Rust changes." That was the smallest wiring change, and it worked — but it left
|
||||
**Jellyfin's item-type taxonomy encoded in the presentation layer**, which
|
||||
violates the project's core boundary rule ("Svelte frontend — presentation
|
||||
only"; all business logic in Rust — see [CLAUDE.md](../../CLAUDE.md) and
|
||||
[architecture/02-svelte-frontend.md](../architecture/02-svelte-frontend.md)).
|
||||
|
||||
The offending knowledge lives in
|
||||
[searchScope.ts](../../src/lib/utils/searchScope.ts):
|
||||
|
||||
```ts
|
||||
const SCOPE_ITEM_TYPES = {
|
||||
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
|
||||
movies: ["Movie"],
|
||||
tv: ["Series", "Episode"],
|
||||
};
|
||||
const GROUP_ITEM_TYPES = {
|
||||
songs: ["Audio"], albums: ["MusicAlbum"], artists: ["MusicArtist"],
|
||||
movies: ["Movie"], tvShows: ["Series", "Episode"],
|
||||
};
|
||||
```
|
||||
|
||||
This is a **domain definition** — "what the category *Music* means in Jellyfin's
|
||||
vocabulary" — expressed twice, in the wrong layer. The concrete failure it
|
||||
creates: the day the backend starts returning a type the frontend never
|
||||
enumerated (e.g. `MusicVideo`, or Jellyfin renaming a kind), search silently
|
||||
drops it from both the query filter and the result buckets, and nothing in the
|
||||
Rust layer — the actual authority on Jellyfin's API — can correct it. Two
|
||||
sources of truth that will drift.
|
||||
|
||||
**This must be fixed while the feature is uncommitted**, before the leak ships
|
||||
baked into a released wire contract.
|
||||
|
||||
### What is *not* a leak (leave it alone)
|
||||
|
||||
Single concrete-type list pages are **not** business logic and stay as-is:
|
||||
|
||||
- `music.ts` → `["MusicAlbum"]` / `["Playlist"]`, `movies.ts` → `["Movie"]`,
|
||||
`tv.ts` → `["Series"]`
|
||||
- `GenericMediaListPage.svelte` → `[config.itemType]`
|
||||
- `ArtistDetailView`, `RelatedItemsSection`, `AddToPlaylistModal`,
|
||||
`PersonDetailView`
|
||||
|
||||
"This page shows albums" is a legitimate presentation choice expressed through a
|
||||
generic `getItems(parentId, { includeItemTypes })` API. Only the **search scope
|
||||
taxonomy** (a semantic category → many types, defined once and reused) crosses
|
||||
the line. Do **not** invent a backend enum for every list page — that is
|
||||
over-abstraction, not cleaner separation.
|
||||
|
||||
## The boundary rule after this change
|
||||
|
||||
> The frontend never names a Jellyfin item type **in connection with search.**
|
||||
> It sends an opaque `scope`, and receives results already sorted into labelled
|
||||
> groups. The frontend owns only **group order** (presentation) and
|
||||
> **rendering**.
|
||||
|
||||
## Design
|
||||
|
||||
### Rust owns scope → item-types (query side)
|
||||
|
||||
Add an opaque enum that crosses IPC, and move the expansion table into Rust:
|
||||
|
||||
```rust
|
||||
// repository/types.rs
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SearchScope { All, Music, Movies, Tv }
|
||||
|
||||
impl SearchScope {
|
||||
/// The Jellyfin item types this scope requests, or None for `All`
|
||||
/// (which must send NO includeItemTypes — see below).
|
||||
pub fn item_types(self) -> Option<Vec<String>> {
|
||||
match self {
|
||||
SearchScope::All => None,
|
||||
SearchScope::Music => Some(vec!["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
|
||||
.into_iter().map(String::from).collect()),
|
||||
SearchScope::Movies => Some(vec!["Movie".into()]),
|
||||
SearchScope::Tv => Some(vec!["Series".into(), "Episode".into()]),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`SearchOptions` gains `scope` and the search command resolves it into the
|
||||
existing `include_item_types` filter **inside Rust**, before dispatching to the
|
||||
online/offline paths (which already honour `include_item_types` — do not touch
|
||||
their filtering, per [scoped-search.md](scoped-search.md) §Background 2).
|
||||
|
||||
```rust
|
||||
pub struct SearchOptions {
|
||||
pub limit: Option<usize>,
|
||||
pub search_term: Option<String>,
|
||||
pub scope: Option<SearchScope>, // NEW
|
||||
// include_item_types stays for the single-type list-page callers,
|
||||
// but the SEARCH command derives it from `scope` when scope is set.
|
||||
}
|
||||
```
|
||||
|
||||
**Precedence:** if `scope` is set it wins; `include_item_types` remains for the
|
||||
non-search `getItems` callers. Document this so a future reader does not send
|
||||
both.
|
||||
|
||||
**`All` sends no filter.** Preserve the existing invariant: `All` must omit
|
||||
`includeItemTypes` entirely, not send the union of every enumerated type — types
|
||||
nobody listed (Person, folders) would otherwise be filtered out. This is why
|
||||
`item_types()` returns `Option`, and the command must skip the filter on `None`.
|
||||
|
||||
### Rust owns result bucketing (result side)
|
||||
|
||||
Results arrive **pre-grouped**. Rust classifies each returned `MediaItem` into a
|
||||
group by its type — the `GROUP_ITEM_TYPES` knowledge, moved to the authority:
|
||||
|
||||
```rust
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SearchGroupId { Songs, Albums, Artists, Movies, TvShows }
|
||||
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchGroup { pub id: SearchGroupId, pub items: Vec<MediaItem> }
|
||||
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GroupedSearchResult { pub groups: Vec<SearchGroup> }
|
||||
```
|
||||
|
||||
Rust emits **every** non-empty group it can classify, in a stable canonical
|
||||
order. It does **not** apply the user's ordering or drop out-of-scope groups —
|
||||
those are presentation and stay frontend-side (see below). Items whose type maps
|
||||
to no group are omitted from grouped output (same as today's frontend filter).
|
||||
|
||||
### 🔴 The `search-event` wrinkle — both payloads must change
|
||||
|
||||
Search returns results **twice**: the command resolves with instant local-cache
|
||||
results, then the merged cache+server union arrives later via the `search-event`
|
||||
listener (see [library.ts](../../src/lib/stores/library.ts) `search()` and
|
||||
[architecture/03-data-flow.md](../architecture/03-data-flow.md)). **Both** the
|
||||
command return value **and** the `search-event` payload must carry
|
||||
`GroupedSearchResult`. If only one is converted, the instant results group and
|
||||
the merged ones do not (or vice versa), and the UI flickers between shapes. This
|
||||
is the single largest part of the change and the easiest to half-do.
|
||||
|
||||
### What the frontend keeps (all pure presentation)
|
||||
|
||||
[searchScope.ts](../../src/lib/utils/searchScope.ts) **retains**:
|
||||
|
||||
- `SearchScope` type — now sourced from the generated bindings, mirroring the
|
||||
Rust enum (delete the hand-written union).
|
||||
- `SCOPE_LABELS`, `SEARCH_SCOPES` (chip labels / order).
|
||||
- `resolveSearchScope(pathname)` — route → initial scope. Pure, DOM-free,
|
||||
unit-tested. **Stays exactly as-is.**
|
||||
- `SearchGroupId` (from bindings), `GROUP_LABELS`.
|
||||
- `normalizeGroupOrder`, `groupsForScope`, `moveGroup`, `reorderGroups`,
|
||||
`DEFAULT_GROUP_ORDER` — group-order persistence and reordering, all
|
||||
presentation.
|
||||
|
||||
[searchScope.ts](../../src/lib/utils/searchScope.ts) **loses**:
|
||||
|
||||
- `SCOPE_ITEM_TYPES`, `GROUP_ITEM_TYPES` (moved to Rust).
|
||||
- `scopeItemTypes()`, `groupItemTypes()`.
|
||||
- The `.type`-inspecting body of `composeSearchGroups()`.
|
||||
|
||||
`composeSearchGroups()` shrinks to a **presentation composition over Rust's
|
||||
groups** — no `.type` inspection anywhere:
|
||||
|
||||
```ts
|
||||
// Take Rust's pre-bucketed groups; drop out-of-scope, sort by saved order,
|
||||
// attach labels, omit empties. No Jellyfin type vocabulary.
|
||||
composeSearchGroups(groups: SearchGroup[], scope, order): DisplayGroup[]
|
||||
```
|
||||
|
||||
`GROUP_SCOPE` (which group belongs to which scope) is a borderline case: it is
|
||||
"is Songs part of the Music scope," arguably taxonomy. But because Rust already
|
||||
filtered the query by scope, out-of-scope groups will simply be **empty** and
|
||||
drop out via the empty-omit rule — so the frontend does not strictly need
|
||||
`GROUP_SCOPE` for correctness once Rust filters. **Recommendation:** delete
|
||||
`GROUP_SCOPE` and rely on empty-omission; if kept for belt-and-suspenders, treat
|
||||
it as a display hint, not authority.
|
||||
|
||||
### Frontend call-site changes
|
||||
|
||||
- [library.ts](../../src/lib/stores/library.ts) `search(query, scope)` sends
|
||||
`{ scope }` in `SearchOptions` instead of computing `includeItemTypes`.
|
||||
Everything else (requestId bump, stale guard, 10s timeout, empty-query clear,
|
||||
event merge) is preserved.
|
||||
- [SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
|
||||
consumes `SearchGroup[]` from the store instead of a flat `MediaItem[]` +
|
||||
client-side `composeSearchGroups(results, …)`. The store now holds grouped
|
||||
results.
|
||||
- [search/+page.svelte](../../src/routes/search/+page.svelte) is unchanged in
|
||||
behaviour; only the type it passes to `SearchResults` changes.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any change to online/offline `include_item_types` **filtering** — it already
|
||||
works; only the *source* of the type list moves.
|
||||
- Single concrete-type list pages (see "What is not a leak").
|
||||
- Ranking within or across groups.
|
||||
- The UX / chip behaviour / persistence mechanism — all unchanged from
|
||||
[scoped-search.md](scoped-search.md).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] No Jellyfin item-type string literal (`"MusicAlbum"`, `"Audio"`, …) remains
|
||||
in `searchScope.ts` or any search call path. Verify:
|
||||
`grep -rn '"MusicAlbum"\|"MusicArtist"\|"Audio"\|"Series"\|"Episode"\|"Movie"\|"Playlist"' src/lib/utils/searchScope.ts src/lib/stores/library.ts` returns nothing.
|
||||
- [ ] `SearchScope` and `SearchGroupId` in the frontend come from the generated
|
||||
`bindings.ts`, not hand-written unions.
|
||||
- [ ] Search behaviour is **identical** to today for the user: same scoping, same
|
||||
groups, same order, same empty/out-of-scope omission, offline included.
|
||||
- [ ] Both the command return and the `search-event` payload carry the grouped
|
||||
shape; no shape flicker between instant and merged results.
|
||||
- [ ] `All` scope still sends no `includeItemTypes` (assert in a Rust test).
|
||||
- [ ] Adding a hypothetical new type to a scope requires editing **only** Rust.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check` and `bun run test` pass; `bindings.ts` regenerated and
|
||||
committed.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust** (`src-tauri`, `cargo test`):
|
||||
- `SearchScope::item_types()`: each scope's list, and `All` → `None`.
|
||||
- Search command: `scope: Music` resolves to the four music types on the query;
|
||||
`scope: All` sends no `include_item_types`.
|
||||
- Bucketing: a mixed `Vec<MediaItem>` classifies into the right `SearchGroupId`s;
|
||||
unknown types are dropped; groups come out in canonical order.
|
||||
- The `search-event` payload is the grouped shape (guard the wrinkle).
|
||||
|
||||
**Frontend** (vitest, `src/lib/**/*.test.ts`) — update existing tests:
|
||||
- `librarySearchScope.test.ts` currently asserts `includeItemTypes` on the
|
||||
outgoing options — **rewrite** to assert `scope` is sent instead.
|
||||
- `searchScope.test.ts` — drop `scopeItemTypes`/`groupItemTypes` cases; keep and
|
||||
extend `resolveSearchScope`, order normalize/move/reorder, and the new
|
||||
compose-over-groups (order + empty-omit, no type inspection).
|
||||
- `searchGroupOrder.test.ts` — unchanged.
|
||||
|
||||
## TRACES
|
||||
|
||||
Per [CLAUDE.md](../../CLAUDE.md), tag requirement-implementing code:
|
||||
- `SearchScope` enum + `item_types()` + search command scope resolution:
|
||||
`UR-049 | DR-063` (revised — resolution now Rust-side).
|
||||
- Grouped result shape + bucketing: `UR-050 | DR-067` (revised) + a new DR for
|
||||
the wire shape.
|
||||
- `library.ts` store change: `UR-049 | DR-065` (revised — sends scope not types).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- This spec **revises** [scoped-search.md](scoped-search.md) §Background 2 and
|
||||
§Design "Scope model / Threading scope through the store," which asserted no
|
||||
Rust change. Update that spec's status to note the boundary was moved, or add a
|
||||
banner pointing here — do not leave the two specs contradicting silently.
|
||||
- The IPC camelCase rule applies to the new enums and structs
|
||||
([CLAUDE.md](../../CLAUDE.md)): `#[serde(rename_all = "camelCase")]` on structs;
|
||||
the tagged-enum tag convention if any enum becomes tagged. Add/extend a
|
||||
`tauriIntegration`-style test if a new command is introduced.
|
||||
- Regenerate `bindings.ts` via the tauri-specta build step after changing Rust
|
||||
types; do not hand-edit it.
|
||||
- **Another Claude session may be active in these same files** (per project
|
||||
memory). `git diff` before repairing anything unexpected; these search files
|
||||
are exactly the ones a parallel session touched.
|
||||
@@ -0,0 +1,202 @@
|
||||
# Spec: Context-scoped search with filter chips and configurable group order
|
||||
|
||||
> ⚠️ **Superseded in part by
|
||||
> [scoped-search-boundary.md](scoped-search-boundary.md).** The "frontend only,
|
||||
> no Rust changes" decision below (§Background 2, §Design "Scope model" and
|
||||
> "Threading scope through the store") left Jellyfin's item-type taxonomy in the
|
||||
> presentation layer, which violates the backend/frontend boundary. The taxonomy
|
||||
> is being moved into Rust. The **user-facing behaviour and UX in this spec are
|
||||
> unchanged**; only where the scope→item-type mapping and result bucketing live
|
||||
> changes. Read the boundary spec before touching search code.
|
||||
|
||||
**Status:** Implemented (boundary revision pending — see banner above)
|
||||
**Scope:** Frontend only. No Rust changes required. *(Revised — see banner.)*
|
||||
**Requirements:** UR-049 → DR-063, DR-064, DR-065; UR-050 → DR-066, DR-067
|
||||
(see [requirements.md](../requirements.md)).
|
||||
**UX spec:** [ux-flows.md §6](../ux-flows.md) — §6.1 scope, §6.2 layout,
|
||||
§6.3 group order, §6.4 current deviations.
|
||||
|
||||
## Summary
|
||||
|
||||
Two related changes to search:
|
||||
|
||||
1. **Scope** — a search started inside a library searches *that* library.
|
||||
Started from Home, `/library`, or the search tab, it searches everything.
|
||||
The active scope shows as a chip row under the search bar, preselected from
|
||||
context and freely changeable without retyping.
|
||||
2. **Group order** — the order result groups appear in (Songs, Albums, Artists,
|
||||
Movies, TV Shows) becomes a drag-and-drop setting instead of being hardcoded.
|
||||
|
||||
## Motivation
|
||||
|
||||
Searching "office" while browsing TV currently returns music albums, because
|
||||
both search entry points call the same unscoped query. The user has already
|
||||
told us what they're looking at; ignoring that makes search feel indiscriminate
|
||||
and pushes the relevant result below unrelated media.
|
||||
|
||||
## Background: what already exists
|
||||
|
||||
Verified in code — **most of the plumbing is already there.** This is
|
||||
substantially a wiring task, not new infrastructure.
|
||||
|
||||
1. **`SearchOptions` already carries the filter.**
|
||||
[bindings.ts](../../src/lib/api/bindings.ts) —
|
||||
`SearchOptions = { limit?, includeItemTypes?, searchTerm? }`.
|
||||
|
||||
2. **Rust already honours `include_item_types` on both paths** — online
|
||||
([online.rs](../../src-tauri/src/repository/online.rs), in the `get_items`
|
||||
options mapping) and offline
|
||||
([offline.rs](../../src-tauri/src/repository/offline.rs), which builds a SQL
|
||||
type filter from it). **Do not add Rust code for filtering.**
|
||||
|
||||
3. **Per-page list search already does this correctly.**
|
||||
[GenericMediaListPage.svelte](../../src/lib/components/library/GenericMediaListPage.svelte)
|
||||
passes `includeItemTypes: [config.itemType]` to `repo.search(...)`. Use it as
|
||||
the reference for the call shape, including the `requestId` handling.
|
||||
|
||||
4. **The gap is exactly one function.**
|
||||
[library.ts](../../src/lib/stores/library.ts) — `search(query)` takes only a
|
||||
query and calls `repo.search(query, { limit: 10000 }, requestId)`, dropping
|
||||
any scope. Both callers
|
||||
([search/+page.svelte](../../src/routes/search/+page.svelte) and
|
||||
[library/+layout.svelte](../../src/routes/library/+layout.svelte)) go through
|
||||
it.
|
||||
|
||||
5. **Group order is hardcoded in markup.**
|
||||
[SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
|
||||
categorizes into `music{tracks,albums,artists} / movies / tvShows` and
|
||||
renders three fixed sections in source order.
|
||||
|
||||
6. **Frontend preferences persist via `localStorage`**, per the existing
|
||||
`viewMode` precedent in [library.ts](../../src/lib/stores/library.ts)
|
||||
(`jellytau-view-mode`). Follow that pattern — **do not** add a Rust settings
|
||||
command for this.
|
||||
|
||||
## Design
|
||||
|
||||
### Scope model
|
||||
|
||||
One `SearchScope` type, defined once and shared:
|
||||
|
||||
| Scope | `includeItemTypes` | Chip label |
|
||||
|-------|--------------------|------------|
|
||||
| `all` | *unset* | All |
|
||||
| `music` | `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist` | Music |
|
||||
| `movies` | `Movie` | Movies |
|
||||
| `tv` | `Series`, `Episode` | TV |
|
||||
|
||||
`all` must send **no** `includeItemTypes` key rather than a list of every type —
|
||||
the two are not equivalent for item types not enumerated here (Person, folders).
|
||||
|
||||
### Route → scope resolution (DR-063)
|
||||
|
||||
A pure function, unit-testable without a DOM:
|
||||
|
||||
```ts
|
||||
resolveSearchScope(pathname: string): SearchScope
|
||||
```
|
||||
|
||||
- `/library/music*` → `music`
|
||||
- `/library/movies*` → `movies`
|
||||
- `/library/tv*` → `tv`
|
||||
- `/`, `/library`, `/search`, anything else → `all`
|
||||
|
||||
Note `/library/shows/genres` exists as a route; treat `shows` as `tv`. Check the
|
||||
current route list before finalising — do not assume this table is exhaustive.
|
||||
|
||||
### Scope is a starting point, not a lock (DR-064)
|
||||
|
||||
The resolved scope sets the **initial** chip only. Once the user taps a chip,
|
||||
their choice governs until they leave the search surface. Concretely: derive the
|
||||
initial value from the route, hold it in component state, and do not re-derive
|
||||
it on every navigation — otherwise a user who widens to All snaps back to TV.
|
||||
|
||||
Changing a chip re-runs the current query at the new scope. Changing the query
|
||||
keeps the current scope.
|
||||
|
||||
### Threading scope through the store (DR-065)
|
||||
|
||||
Extend the store's search signature to accept an optional scope and pass
|
||||
`includeItemTypes` down to `repo.search`. Preserve the existing behaviour
|
||||
exactly: the `requestId` bump, the stale-response guard, the `search-event`
|
||||
listener merge, the 10s timeout, and the empty-query clear path. This is an
|
||||
additive parameter — no caller should break.
|
||||
|
||||
### Group order (DR-066, DR-067)
|
||||
|
||||
Persist an ordered array of group ids:
|
||||
|
||||
```
|
||||
["songs", "albums", "artists", "movies", "tvShows"] // shipped default
|
||||
```
|
||||
|
||||
Rendering composes scope and order as **two independent axes**, in this order:
|
||||
|
||||
1. drop groups outside the active scope,
|
||||
2. sort the remainder by the user's saved order,
|
||||
3. omit groups that came back empty.
|
||||
|
||||
Scope never rewrites the saved order — narrowing to Music and back to All must
|
||||
restore the user's full arrangement. See [ux-flows.md §6.3](../ux-flows.md) for
|
||||
the worked example.
|
||||
|
||||
Settings gets a reorderable list. **Dragging alone is not sufficient**: provide
|
||||
keyboard-operable move up/down controls with proper labels, or the setting is
|
||||
unusable with a screen reader and on any pointerless input.
|
||||
|
||||
Unknown or missing ids in the stored array must not crash rendering — treat the
|
||||
stored order as a hint, append any group it doesn't mention, and ignore ids that
|
||||
no longer exist. A user upgrading from a build with fewer groups must not lose
|
||||
the new ones.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Ranking *within* a group. Order is presentation-only.
|
||||
- Server-side search ranking or the Jellyfin query itself.
|
||||
- Scope chips on the per-page list search in `GenericMediaListPage` — that page
|
||||
is already implicitly scoped by its own `itemType`.
|
||||
- Any Rust change.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Searching from inside Music returns no movies or TV; from inside TV, no music.
|
||||
- [ ] Searching from Home, `/library`, or the search tab returns all types.
|
||||
- [ ] The chip row renders under the search bar on both the search page and the
|
||||
in-library header search, with the context-derived chip preselected.
|
||||
- [ ] Tapping a chip re-runs the search with the query preserved; editing the
|
||||
query preserves the selected chip.
|
||||
- [ ] Tapping "All" from a context-scoped search widens results without retyping.
|
||||
- [ ] Result groups render in the user's configured order, with out-of-scope and
|
||||
empty groups omitted and relative order preserved.
|
||||
- [ ] Group order is reorderable by drag **and** by keyboard, persists across
|
||||
restarts, and ships with the documented default.
|
||||
- [ ] Offline search respects scope (the offline path already filters — verify,
|
||||
don't reimplement).
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
|
||||
## Testing
|
||||
|
||||
Follow the existing frontend test conventions (vitest, `src/lib/**/*.test.ts`).
|
||||
|
||||
- `resolveSearchScope` — pure unit tests over the route table, including the
|
||||
`/library/shows/genres` case and unknown routes falling back to `all`.
|
||||
- Scope → `includeItemTypes` mapping, asserting `all` omits the key entirely.
|
||||
- The compose step: scope filter + user order + empty-group omission, including
|
||||
the "narrow then widen restores order" case and a stored order containing an
|
||||
unknown id.
|
||||
- Store-level: scoped search forwards `includeItemTypes` to the repository, and
|
||||
the existing stale-`requestId` guard still discards superseded responses.
|
||||
|
||||
New requirement-implementing code needs `TRACES:` comments — see
|
||||
[CLAUDE.md](../../CLAUDE.md). Suggested tags: the scope resolver and chip row
|
||||
`UR-049 | DR-063, DR-064`, the store change `UR-049 | DR-065`, the settings list
|
||||
and ordered rendering `UR-050 | DR-066, DR-067`.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [ux-flows.md §6](../ux-flows.md) first — it is the behavioural spec; this
|
||||
document is the implementation plan.
|
||||
- The IPC camelCase rule applies to anything new that crosses the boundary
|
||||
([CLAUDE.md](../../CLAUDE.md)) — though this change should not add commands.
|
||||
- Another session may be active in this repo. Check `git diff` before
|
||||
"repairing" unexpected changes.
|
||||
@@ -12,22 +12,22 @@ The CI/CD pipeline automatically validates that code changes are properly traced
|
||||
|
||||
## Gitea Actions Workflows
|
||||
|
||||
Two workflows are configured in `.gitea/workflows/`:
|
||||
Traceability validation lives in `.gitea/workflows/traceability-check.yml`:
|
||||
|
||||
### 1. `traceability-check.yml` (Primary - Recommended)
|
||||
Gitea-native workflow with:
|
||||
- ✅ Automatic trace extraction
|
||||
- ✅ Coverage validation against minimum threshold (50%)
|
||||
- ✅ Modified file checking
|
||||
- ✅ Artifact preservation
|
||||
- ✅ Summary reports
|
||||
|
||||
**Runs on:** Every push and pull request
|
||||
**Runs on:** Every push and pull request to `master`/`main`/`develop`
|
||||
|
||||
### 2. `traceability.yml` (Alternative)
|
||||
GitHub-compatible workflow with additional features:
|
||||
- Pull request comments with coverage stats
|
||||
- GitHub-specific integrations
|
||||
A second workflow, `traceability.yml`, previously duplicated this one as a
|
||||
"GitHub-compatible alternative". It was removed: CI here is Gitea Actions, and
|
||||
its only unique step (PR comments via `actions/github-script`) depended on the
|
||||
GitHub REST client, which Gitea does not provide. To add PR comments, post to
|
||||
Gitea's `/api/v1/repos/{owner}/{repo}/issues/{index}/comments` from
|
||||
`traceability-check.yml` rather than reviving the old file.
|
||||
|
||||
## What Gets Validated
|
||||
|
||||
|
||||
+1393
-504
File diff suppressed because it is too large
Load Diff
+545
-89
@@ -36,21 +36,74 @@ On desktop (md breakpoint and above), the header contains:
|
||||
- Logo (links to `/library`)
|
||||
- Navigation links: Home, Library, Downloads, Settings
|
||||
- Search bar (inline)
|
||||
- User menu: Username, Downloads icon, Logout button
|
||||
- Account menu (see §1.2)
|
||||
|
||||
**Mobile Navigation:**
|
||||
|
||||
On mobile, the header contains:
|
||||
- Logo
|
||||
- Three-dot overflow menu button (Android-style)
|
||||
- Overflow menu includes:
|
||||
- Downloads
|
||||
- Settings
|
||||
- Sign out
|
||||
- Account menu button (see §1.2)
|
||||
|
||||
### 1.2 Account Menu
|
||||
|
||||
Account-level destinations — the ones that are *about the user* rather than
|
||||
about media — live behind a single **account menu**, anchored to the user's
|
||||
name/avatar at the right of the header.
|
||||
|
||||
**Contents, in order:**
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ Signed in as <name> │ ← identity, not a menu item
|
||||
│ <server host> │
|
||||
├──────────────────────────┤
|
||||
│ ⬇ Downloads │
|
||||
│ ⚙ Settings │
|
||||
│ ▦ Display │ ← grid/list preference (§5A.2)
|
||||
├──────────────────────────┤
|
||||
│ ⇥ Sign out │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **One menu, both platforms.** Desktop and mobile show the same items in the
|
||||
same order. A user who learns where Settings lives on one form factor finds
|
||||
it in the same place on the other.
|
||||
- **Anchored to identity.** The trigger is the username/avatar, because that is
|
||||
where users look for account actions. A bare three-dot icon does not signal
|
||||
"your account".
|
||||
- **Sign out is separated** by a divider and placed last — it is destructive and
|
||||
must not sit adjacent to routine navigation.
|
||||
- **The menu is reachable from every authenticated screen**, not only from
|
||||
library routes. See §1.3.
|
||||
|
||||
**Access Points Summary:**
|
||||
- **Downloads** → Desktop: nav link + icon; Mobile: overflow menu
|
||||
- **Settings** → Desktop: nav link; Mobile: overflow menu
|
||||
- **Downloads** → header icon (desktop) + account menu (both)
|
||||
- **Settings** → header nav link (desktop) + account menu (both)
|
||||
- **Sign out** → account menu only
|
||||
|
||||
### 1.3 Chrome availability
|
||||
|
||||
The header is shared across chrome-bearing routes. Routes fall into three groups:
|
||||
|
||||
| Route group | Header | Bottom nav | Account menu reachable? |
|
||||
|-------------|--------|------------|-------------------------|
|
||||
| `/library/*` | Yes (own layout, shared `AppHeader`) | Yes | Yes |
|
||||
| `/`, `/search`, `/downloads` | Yes (root-owned `AppHeader`) | Yes | Yes |
|
||||
| `/settings` | Own layout | No | n/a — already there |
|
||||
| `/player/*`, `/login` | No | No | No (by design) |
|
||||
|
||||
The rule the app honours: every authenticated, non-immersive screen exposes the
|
||||
account menu. Only the full-screen player and the login screen are chrome-free.
|
||||
|
||||
### 1.4 Known deviations
|
||||
|
||||
*(None — the account-menu and chrome-availability defects tracked here under
|
||||
UR-054 were resolved. Settings, Downloads, Display, and Sign out are now reachable
|
||||
from every authenticated non-immersive screen via the shared `AccountMenu`, the
|
||||
username/avatar is the menu trigger, desktop and mobile share one menu, and the
|
||||
Display preference has a Settings entry — UR-029, §5A.4.)*
|
||||
|
||||
---
|
||||
|
||||
@@ -386,9 +439,9 @@ flowchart TB
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
AlbumsGrid[Albums Grid<br/>FORCED Grid View] --> UserAction{User Action}
|
||||
AlbumsGrid[Albums Grid<br/>grid/list per §5A] --> UserAction{User Action}
|
||||
|
||||
UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[albumId]]
|
||||
UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[id]]
|
||||
UserAction -->|Click Play on Card| PlayAlbum[Play Album Immediately]
|
||||
|
||||
AlbumDetail --> ShowAlbum[Show Album:<br/>- Album Art<br/>- Title, Artist<br/>- Track List<br/>- Download Button<br/>- Favorite Button]
|
||||
@@ -445,54 +498,352 @@ flowchart TB
|
||||
|
||||
---
|
||||
|
||||
## 6. Search Flow
|
||||
## 5A. Library Page Layouts
|
||||
|
||||
### 6.1 Search Page Navigation
|
||||
Every browse page is one of two shapes: a **card grid** or a **row list**. This
|
||||
section is the rule for which shape a page takes, what a card looks like, and
|
||||
what the user is allowed to change.
|
||||
|
||||
### 5A.1 Card shape follows the media, not the page
|
||||
|
||||
Card aspect ratio is a property of *what the item is*, and is never overridden
|
||||
per-page. This is the single most important layout rule: a user scanning a grid
|
||||
recognises content type by silhouette before reading a word.
|
||||
|
||||
| Item type | Aspect | Rationale |
|
||||
|-----------|--------|-----------|
|
||||
| Album, Artist, Track, Playlist | **1:1 square** | Matches album art; the universal music convention (Spotify) |
|
||||
| Movie, Series, Season | **2:3 poster** | Matches printed poster art; the universal video convention (Netflix) |
|
||||
| Episode | **16:9 thumbnail** | A frame from the episode, not cover art — signals "a thing you watch next" |
|
||||
| Library / collection folder | **16:9** | Reads as a container, distinct from the items inside it |
|
||||
|
||||
Artist cards are square but rendered **circular-masked**, so artists are
|
||||
distinguishable from albums at a glance within the same music grid.
|
||||
|
||||
### 5A.2 Grid vs. list
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
BottomNav[Bottom Nav] --> ClickSearch[Click Search Tab]
|
||||
Page[Library browse page] --> Kind{Content kind}
|
||||
|
||||
ClickSearch --> SearchPage[Search Page<br/>/search]
|
||||
Kind -->|Visual-first<br/>albums, artists, movies,<br/>shows, playlists| Grid[Card grid<br/>user may switch to list]
|
||||
Kind -->|Ordinal<br/>tracks in an album,<br/>episodes in a season| List[Row list<br/>always; no toggle]
|
||||
|
||||
SearchPage --> EmptyState{Has Query?}
|
||||
|
||||
EmptyState -->|No| ShowPrompt[Show Empty State:<br/>Search for music,<br/>movies, shows...]
|
||||
EmptyState -->|Yes| ShowResults[Show Results Grouped:<br/>- Songs<br/>- Albums<br/>- Artists<br/>- Movies<br/>- Episodes]
|
||||
|
||||
ShowPrompt --> UserTypes[User Types in Search]
|
||||
UserTypes --> LiveSearch[Live Search<br/>Debounced 300ms]
|
||||
LiveSearch --> ShowResults
|
||||
|
||||
ShowResults --> UserClick{User Clicks Result}
|
||||
|
||||
UserClick -->|Song| PlaySong[Play Song + Queue Results]
|
||||
UserClick -->|Album| NavAlbum[Navigate to Album Detail]
|
||||
UserClick -->|Artist| NavArtist[Navigate to Artist Page]
|
||||
UserClick -->|Movie| NavMovie[Navigate to Movie Detail]
|
||||
Grid --> Toggle[View toggle in page header]
|
||||
Toggle --> Persist[Choice persists globally<br/>across all grid pages]
|
||||
```
|
||||
|
||||
**Search Page Layout:**
|
||||
- **Grids are the default** for anything with cover art worth scanning.
|
||||
- **Lists are mandatory, not optional**, where position carries meaning —
|
||||
a track's number within an album, an episode's number within a season.
|
||||
A grid destroys that ordering cue, so these pages expose **no toggle**.
|
||||
- **The toggle is global, not per-page.** A user who prefers dense lists
|
||||
prefers them everywhere; making them re-set it on each page is friction.
|
||||
The choice persists across launches.
|
||||
|
||||
**Responsive columns** (grid mode), tuned so cards stay large enough to read
|
||||
cover art on a phone and don't become postage stamps on a desktop:
|
||||
|
||||
| Breakpoint | Columns |
|
||||
|------------|---------|
|
||||
| base (phone) | 2 |
|
||||
| sm | 3 |
|
||||
| md | 4 |
|
||||
| lg | 5 |
|
||||
| xl | 6 |
|
||||
|
||||
### 5A.3 What a card shows
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ │ ← cover art (aspect per §5A.1)
|
||||
│ artwork │ • progress bar overlay if partially played
|
||||
│ │ • watched/played check if complete
|
||||
│ [▶] │ • play affordance on hover/focus
|
||||
└─────────────┘
|
||||
Primary line ← title, truncated to one line
|
||||
Secondary line ← artist / year+rating / SxEy — one line, dimmed
|
||||
```
|
||||
|
||||
- **Two lines of text maximum.** Titles truncate rather than wrap; a card that
|
||||
grows to fit its title breaks grid alignment and makes scanning harder.
|
||||
- **Progress and watched state live on the artwork**, not in the text — they
|
||||
must be readable while scanning, without reading.
|
||||
- **Hover/focus reveals play**, so a card is both a navigation target and a
|
||||
playback target without a second control competing for space at rest.
|
||||
|
||||
### 5A.4 Known deviations
|
||||
|
||||
These are places the implementation currently diverges from the rules above.
|
||||
They are recorded here so the gap is explicit rather than mistaken for intent.
|
||||
|
||||
- **The view toggle is discoverable only on a browse page.** The preference is
|
||||
already global and persisted, but the only control that sets it is the pair
|
||||
of icon buttons in a library page header. Settings has no display section, so
|
||||
there is nowhere to look for it. *(UR-029)*
|
||||
|
||||
---
|
||||
|
||||
## 5B. Video Detail Page Composition
|
||||
|
||||
Movie, Series, and Episode detail pages all live at `/library/[id]`. Which
|
||||
surface renders is decided by item type plus the `?episode=` query param, and
|
||||
**section order is part of the spec** — it is what makes "keep watching this
|
||||
show" the path of least resistance.
|
||||
|
||||
### 5B.1 Which surface renders
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Nav[Navigate to /library/[id]] --> Type{Item type}
|
||||
|
||||
Type -->|Person| Person[PersonDetailView]
|
||||
Type -->|Movie| Movie[Movie detail<br/>§5B.3]
|
||||
Type -->|Series| Ep{?episode= param<br/>present?}
|
||||
|
||||
Ep -->|Yes| Focus[Episode Focus View<br/>§5B.2]
|
||||
Ep -->|No| Series[Series detail<br/>§5B.4]
|
||||
|
||||
Focus -->|Back to series| Series
|
||||
Series -->|Click episode| Focus
|
||||
```
|
||||
|
||||
An episode is **never** browsed as a bare `Episode` item page. Clicking an
|
||||
episode anywhere navigates to `/library/<seriesId>?episode=<episodeId>`, so the
|
||||
episode is always shown in the context of its series and the series' full
|
||||
episode list is already loaded.
|
||||
|
||||
### 5B.2 Episode Focus View — section order
|
||||
|
||||
**The next episodes appear directly below the current episode, above cast and
|
||||
similar shows.** Nothing may be inserted between the episode hero and the
|
||||
episode strip.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ [←] │
|
||||
│ ┌───────────────────────────────────────────┐ │
|
||||
│ │ episode backdrop │ │
|
||||
│ │ Series Name │ │ ← 1. HERO
|
||||
│ │ Episode Title │ │
|
||||
│ │ S2E4 • 48m • ★8.1 │ │
|
||||
│ │ Overview… │ │
|
||||
│ │ ▓▓▓▓▓░░░░░ 32m left │ │
|
||||
│ │ [▶ Play] │ │
|
||||
│ └───────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ More Episodes │ ← 2. EPISODE STRIP
|
||||
│ ┌──────┐┌──────┐┌──────┐┌──────┐ │ (immediately below hero)
|
||||
│ │ E3 ││▓E4▓ ││ E5 ││ E6 │ → scroll │
|
||||
│ │ ││NOW ││ ││ │ │
|
||||
│ └──────┘└──────┘└──────┘└──────┘ │
|
||||
│ │
|
||||
│ Cast │ ← 3. CAST
|
||||
│ ( ○ )( ○ )( ○ )( ○ ) │
|
||||
│ │
|
||||
│ More Like This │ ← 4. SIMILAR
|
||||
│ ┌────┐┌────┐┌────┐┌────┐ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Rules for the episode strip:**
|
||||
|
||||
- **Position is fixed.** Hero → episode strip → cast → similar. The strip sits
|
||||
between the current episode and every other section; cast and related
|
||||
content are *below* it, never above.
|
||||
- **Window, not full list.** The strip shows a window around the current
|
||||
episode — roughly 3 before and 6 after — so the immediate next episodes are
|
||||
visible without scrolling, and earlier ones remain reachable by scrolling
|
||||
left. It is horizontally scrollable, not a wrapped grid.
|
||||
- **Forward bias.** More episodes are shown *after* the current one than
|
||||
before it: the dominant intent on this screen is "watch the next one."
|
||||
- **The current episode is present and marked.** It renders in-strip with a
|
||||
"NOW" badge and a highlight ring, and is not clickable. It anchors the
|
||||
user's position in the season rather than being hidden.
|
||||
- **Cross-season continuity.** The window spans the whole series in episode
|
||||
order, so the strip runs past a season boundary into the next season's first
|
||||
episodes rather than dead-ending at the end of a season.
|
||||
- **Per-episode state.** Each card shows a thumbnail, `SxEy` + title, a resume
|
||||
progress bar when partially watched, and a watched checkmark when complete.
|
||||
- **Clicking an episode swaps focus in place** (`?episode=` changes); it does
|
||||
not start playback. Playback starts only from the hero's Play button.
|
||||
|
||||
### 5B.3 Movie detail — section order
|
||||
|
||||
```
|
||||
Hero (poster, title, metadata, Play / Download / Favorite)
|
||||
→ Crew links (Directed by / Written by / Music by)
|
||||
→ Genre tags
|
||||
→ Cast
|
||||
→ More Like This
|
||||
```
|
||||
|
||||
A movie has no continuation set, so cast follows the hero directly.
|
||||
|
||||
### 5B.4 Series detail — section order
|
||||
|
||||
```
|
||||
Hero (poster, title, metadata, Play / Download)
|
||||
→ Crew links
|
||||
→ Genre tags
|
||||
→ Seasons + episodes (per-season sections)
|
||||
→ Cast
|
||||
→ More Like This
|
||||
```
|
||||
|
||||
The same principle as §5B.2: **episodes come before cast and similar shows.**
|
||||
The reason a user opens a series page is to pick an episode; discovery content
|
||||
is secondary and sits underneath.
|
||||
|
||||
---
|
||||
|
||||
## 6. Search Flow
|
||||
|
||||
Search is **context-scoped**: what you are looking at when you start a search
|
||||
determines what the search covers. A search begun inside the Music library
|
||||
searches music. A search begun from Home or the top-level library page searches
|
||||
everything. The scope is always shown, and always overridable.
|
||||
|
||||
### 6.1 Scope is inherited from context
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Start[User starts a search] --> Where{Where from?}
|
||||
|
||||
Where -->|Home (/)| All[Scope: All]
|
||||
Where -->|Library root (/library)| All
|
||||
Where -->|Search tab| All
|
||||
Where -->|Inside Music| Music[Scope: Music]
|
||||
Where -->|Inside Movies| Movies[Scope: Movies]
|
||||
Where -->|Inside TV| TV[Scope: TV]
|
||||
|
||||
All --> Chips[Filter chips shown<br/>All chip selected]
|
||||
Music --> Chips2[Filter chips shown<br/>Music chip preselected]
|
||||
Movies --> Chips2
|
||||
TV --> Chips2
|
||||
|
||||
Chips --> Results[Results, grouped by type]
|
||||
Chips2 --> Results
|
||||
|
||||
Results --> Change{User taps a chip}
|
||||
Change --> Rescope[Re-run search at new scope<br/>query preserved]
|
||||
Rescope --> Results
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Context sets the *initial* chip, never a locked filter.** Entering search
|
||||
from TV preselects the TV chip; the user can tap "All" to widen without
|
||||
retyping the query. Scope is a starting point, not a cage.
|
||||
- **Home, `/library`, and the search tab all start at "All".** These are the
|
||||
places a user has expressed no narrower intent.
|
||||
- **Changing scope preserves the query** and re-runs the search. Changing the
|
||||
query preserves the scope.
|
||||
- **Scope maps to item types**, resolved at the point of search:
|
||||
|
||||
| Chip | `includeItemTypes` |
|
||||
|------|--------------------|
|
||||
| All | *(unset — every type)* |
|
||||
| Music | `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist` |
|
||||
| Movies | `Movie` |
|
||||
| TV | `Series`, `Episode` |
|
||||
|
||||
- **Chips render under the search bar**, on both the dedicated search page and
|
||||
the in-library header search. They are horizontally scrollable if they
|
||||
overflow, never wrapped onto a second row.
|
||||
|
||||
### 6.2 Search page layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [🔍 Search...] [✕] │
|
||||
│ [🔍 Search...] [✕] │
|
||||
│ │
|
||||
│ ( All ) (•Music•) ( Movies ) ( TV ) │ ← scope chips
|
||||
│ │
|
||||
│ Songs ──────────────────────────── │
|
||||
│ ♪ Song Title - Artist 3:45 │
|
||||
│ ♪ Song Title - Artist 4:12 │
|
||||
│ See all (23) │
|
||||
│ ♪ Song Title - Artist 3:45 │
|
||||
│ ♪ Song Title - Artist 4:12 │
|
||||
│ See all (23) │
|
||||
│ │
|
||||
│ Albums ─────────────────────────── │
|
||||
│ [Album Cover] Album Title │
|
||||
│ [Album Cover] Album Title │
|
||||
│ See all (8) │
|
||||
│ [Cover] Album Title │
|
||||
│ See all (8) │
|
||||
│ │
|
||||
│ Artists ────────────────────────── │
|
||||
│ [Photo] Artist Name │
|
||||
│ See all (5) │
|
||||
│ ( Photo ) Artist Name │
|
||||
│ See all (5) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Results stay **grouped by type** even when a scope is selected — a Music
|
||||
search still separates Songs / Albums / Artists.
|
||||
- Each group shows a bounded preview with a **See all (n)** affordance rather
|
||||
than an unbounded list, so no single type can bury the others.
|
||||
- Live search is **debounced** as the user types; a query that becomes empty
|
||||
clears results rather than searching for the empty string.
|
||||
|
||||
### 6.3 Result group order is user-configurable
|
||||
|
||||
Which *kind* of thing a user is usually searching for is personal: a
|
||||
music-first user wants Songs at the top, a TV-first user wants Shows. Rather
|
||||
than guessing, the group order is a setting.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Settings[Settings → Search] --> List[Draggable list of result groups]
|
||||
|
||||
List --> Drag[User drags a group up or down]
|
||||
Drag --> Persist[Order persisted]
|
||||
|
||||
Persist --> Render[Rendering a result set]
|
||||
Scope[Active scope chip §6.1] --> Render
|
||||
|
||||
Render --> Filter[1 - Drop groups outside the active scope]
|
||||
Filter --> Sort[2 - Sort remaining groups by user order]
|
||||
Sort --> Prune[3 - Omit groups with no results]
|
||||
Prune --> Show[Render]
|
||||
```
|
||||
|
||||
**Scope and order compose — they are two independent axes.** The scope chip
|
||||
decides *which* groups are eligible; the settings list decides *what sequence*
|
||||
the eligible ones appear in. Order is preserved as a relative ranking, never
|
||||
renumbered per scope:
|
||||
|
||||
- Scope **Music** with order `Movies → Songs → Albums → Artists → TV` renders
|
||||
`Songs → Albums → Artists`. Movies and TV are filtered out; the surviving
|
||||
groups keep their relative order.
|
||||
- Scope **All** with the same setting renders all five in exactly that order.
|
||||
- **Changing scope never rewrites the saved order.** A user who narrows to
|
||||
Music and back to All sees their original arrangement intact.
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Drag and drop to reorder**, in a settings list showing every result group
|
||||
(Songs, Albums, Artists, Movies, TV Shows).
|
||||
- **The order applies to grouped results everywhere** — the search page and
|
||||
the in-library header search alike.
|
||||
- **Order is presentation-only.** It never changes which results are returned
|
||||
or how they are ranked *within* a group, only the sequence groups appear in.
|
||||
- **Empty groups are skipped, not gapped.** A group with no results is omitted
|
||||
entirely; it does not reserve space or leave a stray heading.
|
||||
- **A sensible default ships** (Songs → Albums → Artists → Movies → TV Shows)
|
||||
so the setting is an adjustment, never a prerequisite.
|
||||
- **Keyboard/accessible reordering must exist** alongside dragging — a
|
||||
drag-only control is unusable with a screen reader or without a pointer.
|
||||
|
||||
### 6.4 Known deviations
|
||||
|
||||
Recorded so the gap between this spec and the build is explicit.
|
||||
|
||||
- **Scope is not implemented.** The in-library header search calls the same
|
||||
unscoped query as the global search page, so searching inside TV returns
|
||||
music. The backend already accepts `includeItemTypes` on both the online and
|
||||
offline paths, and the per-page list search already uses it — only the global
|
||||
path ignores it. *(UR-049)*
|
||||
- **Filter chips do not exist** on either search surface. *(UR-049)*
|
||||
- **Group order is hardcoded** to Music → Movies → TV in the results markup,
|
||||
with no setting. *(UR-050)*
|
||||
|
||||
---
|
||||
|
||||
## 7. Download Flows
|
||||
@@ -532,68 +883,166 @@ States:
|
||||
5. [⏸] Paused - Yellow pause icon
|
||||
```
|
||||
|
||||
### 7.2 Managing Downloads Page
|
||||
### 7.2 Downloads = a browsable offline library, not a flat list
|
||||
|
||||
**The central idea:** "my downloads" is not a list of file-transfer rows — it is
|
||||
*the library, filtered to what's on the device*. A user who has downloaded three
|
||||
seasons of a show and two albums thinks in terms of shows and albums, not
|
||||
seventy-odd individual episode/track transfers. So the primary Downloads surface
|
||||
**reuses the library browse screens**, scoped to downloaded content, and keeps
|
||||
the transfer-progress list as a secondary "Transfers" view for the *act* of
|
||||
downloading.
|
||||
|
||||
This splits one overloaded page into two clear jobs:
|
||||
|
||||
| Surface | Answers | Reuses |
|
||||
|---------|---------|--------|
|
||||
| **Downloaded** (browse) | "What do I have offline, and let me play it" | Library grids, detail pages, cards (§5A) |
|
||||
| **Transfers** (activity) | "What is downloading right now, and control it" | The existing progress-row list |
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
User[User] --> NavChoice{Navigation Path}
|
||||
Nav[Open Downloads] --> Downloads[/downloads]
|
||||
|
||||
NavChoice -->|Desktop| HeaderNav[Header: Click Downloads Link]
|
||||
NavChoice -->|Mobile| HeaderIcon[Header: Click Downloads Icon]
|
||||
NavChoice -->|Direct| TypeURL[Type /downloads]
|
||||
Downloads --> View{View}
|
||||
View -->|Downloaded (default)| Browse[Offline library browse]
|
||||
View -->|Transfers| Activity[Transfer activity list]
|
||||
|
||||
HeaderNav --> DownloadsPage[Downloads Page<br/>/downloads]
|
||||
HeaderIcon --> DownloadsPage
|
||||
TypeURL --> DownloadsPage
|
||||
Browse --> Libs[Libraries — only those with<br/>downloaded content]
|
||||
Libs --> Grid[Library grid, offline-scoped<br/>same cards/layout as online §5A]
|
||||
Grid --> Detail[Detail page<br/>same as online]
|
||||
Detail --> Play[Play from local file]
|
||||
Detail --> Remove[Remove download<br/>frees space, keeps browsable? — see rules]
|
||||
|
||||
DownloadsPage --> ShowTabs[Show Tabs:<br/>Active | Completed]
|
||||
|
||||
ShowTabs --> ActiveTab{Active Tab}
|
||||
|
||||
ActiveTab -->|Active| ShowActive[Show Active Downloads:<br/>- Download progress bars<br/>- Pause/Resume buttons<br/>- Cancel buttons]
|
||||
ActiveTab -->|Completed| ShowCompleted[Show Completed:<br/>- Downloaded items list<br/>- Delete buttons<br/>- Play buttons]
|
||||
|
||||
ShowActive --> UserAction1{User Action}
|
||||
UserAction1 -->|Pause| PauseDownload[Pause Download]
|
||||
UserAction1 -->|Cancel| CancelDialog[Show Confirm Dialog]
|
||||
|
||||
ShowCompleted --> UserAction2{User Action}
|
||||
UserAction2 -->|Play| PlayOffline[Play from Local File]
|
||||
UserAction2 -->|Delete| DeleteDialog[Show Confirm Dialog]
|
||||
Activity --> Rows[Per-transfer rows:<br/>downloading / queued / paused / failed /<br/>waiting-for-WiFi]
|
||||
Rows --> Ctl[Pause / Resume / Cancel / Retry]
|
||||
```
|
||||
|
||||
**Navigation to Downloads:**
|
||||
- **Desktop:** Click "Downloads" link in header navigation
|
||||
- **All screen sizes:** Click download icon (⬇) button in header user menu
|
||||
- **Direct:** Navigate to `/downloads` route
|
||||
**Why reuse the library screens (not a bespoke list):**
|
||||
|
||||
- **One mental model.** Browsing offline should feel identical to browsing
|
||||
online — same grids, same card shapes, same detail pages, same play action.
|
||||
The only difference is *what's present*, not *how it looks*.
|
||||
- **It already works in the backend.** The offline repository's `get_items`
|
||||
already returns downloaded items **plus** their containers (an album with any
|
||||
downloaded track, a series/season with any downloaded episode). That is a
|
||||
browsable tree today — see §7.4.
|
||||
- **It scales.** A flat completed-list becomes unusable at a few dozen items; a
|
||||
browsable library does not.
|
||||
|
||||
### 7.3 The Downloaded browse surface
|
||||
|
||||
**Downloads Page Layout:**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [←] Downloads │
|
||||
│ │
|
||||
│ [Active (3)] [Completed (12)] │
|
||||
│ │
|
||||
│ ─ Downloading ──────────────────── │
|
||||
│ │
|
||||
│ Album Cover Album Title │
|
||||
│ Artist Name │
|
||||
│ [████████░░] 80% │
|
||||
│ [⏸ Pause] [✕ Cancel] │
|
||||
│ │
|
||||
│ Album Cover Album Title │
|
||||
│ Artist Name │
|
||||
│ [██░░░░░░░░] 20% │
|
||||
│ [⏸ Pause] [✕ Cancel] │
|
||||
│ │
|
||||
│ ─ Queued ───────────────────────── │
|
||||
│ │
|
||||
│ Album Cover Album Title │
|
||||
│ Artist Name │
|
||||
│ Waiting... │
|
||||
└─────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Downloads │
|
||||
│ ( Downloaded ) ( Transfers ) ← view switch
|
||||
│ │
|
||||
│ [~ 3.4 GB on device · 12 items] Manage ▸ │ ← storage summary
|
||||
│ │
|
||||
│ Music │ ← only libraries that
|
||||
│ ┌────┐┌────┐┌────┐ │ have downloaded content
|
||||
│ │alb ││alb ││art │ │
|
||||
│ └────┘└────┘└────┘ │
|
||||
│ │
|
||||
│ TV │
|
||||
│ ┌────┐┌────┐ │
|
||||
│ │show││show│ │
|
||||
│ └────┘└────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Libraries with nothing downloaded are omitted**, not shown empty. If only
|
||||
music is downloaded, only Music appears.
|
||||
- **Cards, grids, and detail pages are the library's own** (§5A) — offline
|
||||
browse is the same components with an offline-scoped data source, never a
|
||||
parallel re-implementation.
|
||||
- **A downloaded badge / "on device" affordance** distinguishes fully-downloaded
|
||||
from partially-downloaded containers (e.g. a season with 6 of 10 episodes).
|
||||
- **Disk usage is shown where the user already looks**, in familiar units — see
|
||||
§7.3.1.
|
||||
- **Play always plays the local file** here; nothing on this surface streams.
|
||||
- **Remove is available at every level** — item, album/season, series — and
|
||||
states clearly what it frees. Removing the last downloaded child of a
|
||||
container removes the container from the browse.
|
||||
- **This surface works identically online and offline.** It is "what's on the
|
||||
device," a question whose answer does not depend on connectivity. It must not
|
||||
wait for, or be emptied by, server reachability.
|
||||
|
||||
#### 7.3.1 Disk usage — familiar, in place, not a separate audit
|
||||
|
||||
Users want to know what each thing costs on disk, but that information has to
|
||||
feel like the storage views they already know (phone Settings → Storage, a
|
||||
file browser), not a developer's byte dump.
|
||||
|
||||
- **Size rides along with the item, on the card and the detail page** — a small
|
||||
secondary label (`1.2 GB`, `340 MB`, `48 MB`), never a separate "storage
|
||||
report" screen the user has to go find.
|
||||
- **Containers show their total.** A series shows the sum of its downloaded
|
||||
episodes; an album the sum of its tracks; a season its own subtotal. The
|
||||
number a user sees on the "Breaking Bad" card is what removing it frees.
|
||||
- **Human units, rounded, consistent.** Binary or decimal is a choice — pick one
|
||||
and use it everywhere. Show 2–3 significant figures (`1.2 GB`, not
|
||||
`1,283,048,192 bytes` and not `1.28394 GB`).
|
||||
- **A single device total sits at the top** of the Downloaded surface
|
||||
(`3.4 GB on device · 12 items`) so the headline number is answered before the
|
||||
user scans. It reconciles with the sum of what's listed.
|
||||
- **Remove restates the reclaim** in the same units at the point of action
|
||||
("Remove download · frees 1.2 GB"), so the cost of keeping vs. freeing is
|
||||
legible exactly when the user decides.
|
||||
- **Sort/filter by size is a reasonable enhancement** ("biggest first" to find
|
||||
what to clear) but is not required for v1.
|
||||
|
||||
The bytes-on-disk per item are a backend fact (the download manager writes the
|
||||
files and can stat them); this is a display and aggregation task, not new
|
||||
tracking. See §7.7 deviations for what's missing today.
|
||||
|
||||
### 7.4 Transfers (activity) view
|
||||
|
||||
The existing progress-row list, unchanged in spirit, demoted to a secondary tab.
|
||||
It is about *transfers in flight*, so it shows only rows that are doing or
|
||||
waiting to do something:
|
||||
|
||||
- **States:** downloading (with progress), queued, paused, failed,
|
||||
waiting-for-WiFi (§7.5).
|
||||
- **Controls:** Pause / Resume / Cancel / Retry per row; the 3-concurrent cap
|
||||
and auto-pump are backend concerns and are not surfaced as manual controls.
|
||||
- **Completed transfers fall off this view** once done — the finished item lives
|
||||
in Downloaded, not here. A transient "just finished" confirmation is fine; a
|
||||
permanent completed-list is not (that's what Downloaded is for).
|
||||
- **Empty state** points at the library: "Nothing downloading. Browse your
|
||||
library and tap download to save media for offline."
|
||||
|
||||
### 7.5 Navigation & entry points
|
||||
|
||||
- Reached via the account menu (§1.2) and, on desktop, the header Downloads
|
||||
link/icon → `/downloads`.
|
||||
- `/downloads` opens on **Downloaded** by default; **Transfers** is one tap away
|
||||
and should draw attention (badge/count) only while transfers are active.
|
||||
- Initiating a download is unchanged (§7.1): the download button lives on
|
||||
item/album/series detail pages. The Downloads page manages and browses; it is
|
||||
not where you start a download.
|
||||
|
||||
### 7.7 Known deviations
|
||||
|
||||
Recorded so the gap between this spec and the build is explicit.
|
||||
|
||||
- **Downloads is a flat two-tab list today** (Active / Completed), rendering one
|
||||
row per individual transfer with no browsing, grouping, or reuse of the
|
||||
library screens. Completed downloads never collapse into their album/series.
|
||||
*(UR-055)*
|
||||
- **No offline-scoped browse entry point exists in the client.** All browsing
|
||||
goes through the hybrid repository, which merges cache **and** server; there is
|
||||
no way to ask for "downloaded content only" as a browse surface. The offline
|
||||
repository supports it (§7.2) but is not reachable independently. *(UR-055,
|
||||
DR-082)*
|
||||
- **The "on device" storage summary and per-container remove** are absent from
|
||||
the completed list. *(UR-055, UR-056)*
|
||||
- **Per-item disk usage is not displayed anywhere.** Cards and detail pages show
|
||||
no size; there is no device total, no container subtotal, and Remove does not
|
||||
state what it frees. *(UR-056)*
|
||||
|
||||
---
|
||||
|
||||
## 8. Settings & Account Flows
|
||||
@@ -627,6 +1076,13 @@ flowchart TB
|
||||
- **Mobile:** Click three-dot overflow menu → Select "Settings"
|
||||
- **Direct:** Navigate to `/settings` route
|
||||
|
||||
**Settings apply instantly.** Every control on the Settings page persists the
|
||||
moment the user changes it — toggling a switch, picking a level, or releasing a
|
||||
slider writes that setting immediately. There is **no "Save" button** and no
|
||||
save/dirty state to reason about; leaving the page never risks losing a change.
|
||||
Sliders update their live readout while dragging but only persist on release
|
||||
(`change`, not each `input` tick) to avoid flooding the backend.
|
||||
|
||||
### 8.2 Logout Flow
|
||||
|
||||
```mermaid
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.0.16",
|
||||
"version": "0.0.18",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.5",
|
||||
@@ -17,6 +17,7 @@
|
||||
"test:e2e:dev": "wdio run ./wdio.conf.ts --watch",
|
||||
"test:all": "./scripts/test-all.sh",
|
||||
"test:rust": "./scripts/test-rust.sh",
|
||||
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
||||
"android:build": "./scripts/build-android.sh",
|
||||
"android:build:release": "./scripts/build-android.sh release",
|
||||
"android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && bun install && bun run build",
|
||||
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# Boundary tripwire: flag domain-taxonomy leaks in the Svelte frontend.
|
||||
#
|
||||
# The project rule (CLAUDE.md, docs/architecture/02-svelte-frontend.md) is that
|
||||
# the frontend is presentation-only and the Rust backend owns domain logic —
|
||||
# including Jellyfin's item-type *taxonomy* (what the category "Music" means as a
|
||||
# set of item types). See docs/specs/scoped-search-boundary.md for the incident
|
||||
# that motivated this check.
|
||||
#
|
||||
# ⚠️ This is a TRIPWIRE, NOT A PROOF. A grep cannot distinguish taxonomy-as-policy
|
||||
# (a leak) from taxonomy-as-display (legitimate: "is this a music card?"). It
|
||||
# targets the one machine-detectable signature of the leak class — a *query* that
|
||||
# names a multi-type category — and defers everything subtler to the human
|
||||
# spec-review checklist (docs/specs/SPEC-REVIEW-CHECKLIST.md). A clean run here
|
||||
# does not mean the boundary is respected; it means the crudest violation isn't
|
||||
# present.
|
||||
#
|
||||
# What it flags: an `includeItemTypes: [ ... , ... ]` array literal with two or
|
||||
# more types — i.e. the frontend deciding that a *category* maps to a *set* of
|
||||
# Jellyfin types, which is domain knowledge the backend should own. Single-type
|
||||
# query arrays (`includeItemTypes: ["Movie"]`) are a page saying "I show movies"
|
||||
# and are allowed. Type *inspection* (`item.type === "Audio"`) is display logic
|
||||
# and is not matched.
|
||||
#
|
||||
# Escaping a genuine exception: add the file+reason to the ALLOWLIST below.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Files permitted to contain a multi-type includeItemTypes query, with the reason.
|
||||
# Keep this SHORT. A growing allowlist means the boundary is eroding — that is a
|
||||
# signal to push taxonomy into Rust, not to keep appending here.
|
||||
ALLOWLIST=(
|
||||
# "Things a person appeared in" is arguably taxonomy, but it is a fixed
|
||||
# two-type filmography query with no category-configuration behind it. Tracked
|
||||
# as acceptable pending any person-scope work; revisit if it grows.
|
||||
"src/lib/components/library/PersonDetailView.svelte"
|
||||
)
|
||||
|
||||
is_allowed() {
|
||||
local file="$1"
|
||||
for allowed in "${ALLOWLIST[@]}"; do
|
||||
[[ "$file" == "$allowed" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Multi-element includeItemTypes array: `includeItemTypes: [ <x> , <y> ... ]`.
|
||||
# The comma inside the brackets is what makes it multi-type.
|
||||
PATTERN='includeItemTypes:[[:space:]]*\[[^]]*,[^]]*\]'
|
||||
|
||||
echo "🔎 Checking frontend for domain-taxonomy leaks (multi-type query arrays)…"
|
||||
|
||||
# Collect hits, excluding tests and the allowlist.
|
||||
violations=""
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
file="${line%%:*}"
|
||||
case "$file" in
|
||||
*.test.*) continue ;;
|
||||
esac
|
||||
if is_allowed "$file"; then
|
||||
echo " ⏭️ allowlisted: $line"
|
||||
continue
|
||||
fi
|
||||
violations+="$line"$'\n'
|
||||
done < <(grep -rInE "$PATTERN" src/ 2>/dev/null || true)
|
||||
|
||||
if [[ -n "$violations" ]]; then
|
||||
echo ""
|
||||
echo "❌ Frontend boundary violation: a multi-type includeItemTypes query defines"
|
||||
echo " a category in the presentation layer. That taxonomy belongs in Rust —"
|
||||
echo " send an opaque scope and let the backend expand it to item types."
|
||||
echo " See docs/specs/scoped-search-boundary.md and CLAUDE.md."
|
||||
echo ""
|
||||
echo "$violations" | sed 's/^/ /'
|
||||
echo " If this is a genuine exception, add the file + reason to ALLOWLIST in"
|
||||
echo " scripts/check-frontend-boundary.sh — but prefer moving it to Rust."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ No multi-type taxonomy queries in the frontend."
|
||||
echo " (Reminder: this is a tripwire, not a proof — the spec-review checklist is"
|
||||
echo " the real gate for subtler leaks.)"
|
||||
@@ -14,6 +14,8 @@
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Required to read NetworkCapabilities for the WiFi-only download gate (UR-053) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
@@ -125,6 +125,11 @@ class MainActivity : TauriActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
NetworkTypeMonitor.stopWatching(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(
|
||||
isInPictureInPictureMode: Boolean,
|
||||
newConfig: android.content.res.Configuration
|
||||
@@ -214,6 +219,35 @@ class MainActivity : TauriActivity() {
|
||||
}, "AndroidBackgroundAudio")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
|
||||
|
||||
// Network transport reporting for the WiFi-only download gate (UR-053).
|
||||
// The frontend polls these on demand and re-pumps the download queue when
|
||||
// the 'jellytau-network-changed' event fires.
|
||||
webView.addJavascriptInterface(object : Any() {
|
||||
/** Active transport: wifi | ethernet | cellular | other | none | unknown. */
|
||||
@JavascriptInterface
|
||||
fun currentType(): String = NetworkTypeMonitor.currentType(this@MainActivity)
|
||||
|
||||
/** Whether the active network is unmetered. */
|
||||
@JavascriptInterface
|
||||
fun isUnmetered(): Boolean = NetworkTypeMonitor.isUnmetered(this@MainActivity)
|
||||
|
||||
/** Whether downloads may run given the wifi-only preference. */
|
||||
@JavascriptInterface
|
||||
fun isAcceptable(wifiOnly: Boolean): Boolean =
|
||||
NetworkTypeMonitor.isAcceptable(this@MainActivity, wifiOnly)
|
||||
|
||||
/** Whether native network detection is available at all (false on non-Android). */
|
||||
@JavascriptInterface
|
||||
fun isSupported(): Boolean = true
|
||||
}, "AndroidNetworkType")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidNetworkType' added")
|
||||
|
||||
// Push network changes into the WebView so a queue blocked on "waiting for
|
||||
// WiFi" resumes the moment an acceptable network appears.
|
||||
NetworkTypeMonitor.startWatching(this) {
|
||||
dispatchWebEvent("jellytau-network-changed")
|
||||
}
|
||||
|
||||
// Set WebChromeClient to handle video playback and audio focus
|
||||
webView.webChromeClient = object : WebChromeClient() {
|
||||
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
|
||||
/**
|
||||
* Reports the *kind* of network the device is on, so downloads can be gated on
|
||||
* "unmetered only" (the WiFi-only setting).
|
||||
*
|
||||
* This is deliberately separate from the Rust-side ConnectivityMonitor, which
|
||||
* answers a different question: whether the Jellyfin *server* is reachable,
|
||||
* derived from real request outcomes. Reachability and transport type are
|
||||
* orthogonal — you can be on WiFi with a dead server, or on cellular with a
|
||||
* perfectly reachable one.
|
||||
*
|
||||
* Requires ACCESS_NETWORK_STATE; without it getNetworkCapabilities returns null
|
||||
* and we report UNKNOWN (which the gate treats as "not acceptable" when
|
||||
* wifi-only is on, failing closed rather than burning mobile data).
|
||||
*
|
||||
* TRACES: UR-053 | DR-074
|
||||
*/
|
||||
object NetworkTypeMonitor {
|
||||
private const val TAG = "NetworkTypeMonitor"
|
||||
|
||||
/** Transport classification, mirrored by the Rust `NetworkType` enum. */
|
||||
const val TYPE_NONE = "none"
|
||||
const val TYPE_WIFI = "wifi"
|
||||
const val TYPE_ETHERNET = "ethernet"
|
||||
const val TYPE_CELLULAR = "cellular"
|
||||
const val TYPE_OTHER = "other"
|
||||
const val TYPE_UNKNOWN = "unknown"
|
||||
|
||||
private var callback: ConnectivityManager.NetworkCallback? = null
|
||||
|
||||
/** Invoked on any network change; set by [startWatching]. */
|
||||
@Volatile
|
||||
private var onChange: (() -> Unit)? = null
|
||||
|
||||
private fun connectivityManager(context: Context): ConnectivityManager? =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
|
||||
/**
|
||||
* Current transport type of the active network.
|
||||
*
|
||||
* Returns UNKNOWN (not NONE) when capabilities can't be read, so callers can
|
||||
* distinguish "definitely offline" from "couldn't tell".
|
||||
*/
|
||||
fun currentType(context: Context): String {
|
||||
val cm = connectivityManager(context) ?: return TYPE_UNKNOWN
|
||||
val network = cm.activeNetwork ?: return TYPE_NONE
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return TYPE_UNKNOWN
|
||||
|
||||
return when {
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> TYPE_WIFI
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> TYPE_ETHERNET
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> TYPE_CELLULAR
|
||||
else -> TYPE_OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the active network is unmetered.
|
||||
*
|
||||
* This is the bit that actually matters for the WiFi-only gate: a phone
|
||||
* hotspot reports TRANSPORT_WIFI but is metered, and is backed by exactly the
|
||||
* cellular data the setting exists to protect. Checking NOT_METERED rather
|
||||
* than the transport alone means tethering doesn't quietly burn a data plan.
|
||||
*/
|
||||
fun isUnmetered(context: Context): Boolean {
|
||||
val cm = connectivityManager(context) ?: return false
|
||||
val network = cm.activeNetwork ?: return false
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether downloads may run right now given the wifi-only preference.
|
||||
*
|
||||
* Ethernet counts as acceptable (it is unmetered in practice and is what
|
||||
* Android TV devices use). Cellular never does. When wifi-only is off this is
|
||||
* always true — the gate simply isn't engaged.
|
||||
*/
|
||||
fun isAcceptable(context: Context, wifiOnly: Boolean): Boolean {
|
||||
if (!wifiOnly) return true
|
||||
val type = currentType(context)
|
||||
if (type == TYPE_CELLULAR || type == TYPE_NONE || type == TYPE_UNKNOWN) return false
|
||||
// WiFi/Ethernet/other: require unmetered so metered hotspots are excluded.
|
||||
return isUnmetered(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback that fires whenever the network changes, so a blocked
|
||||
* download queue can be re-pumped the moment an acceptable network appears.
|
||||
* Without this the queue would stall until some unrelated event pumped it.
|
||||
*
|
||||
* Idempotent: a second call replaces the previous callback.
|
||||
*/
|
||||
fun startWatching(context: Context, onNetworkChanged: () -> Unit) {
|
||||
val cm = connectivityManager(context) ?: run {
|
||||
android.util.Log.w(TAG, "No ConnectivityManager; network changes won't be observed")
|
||||
return
|
||||
}
|
||||
|
||||
stopWatching(context)
|
||||
onChange = onNetworkChanged
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
val cb = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
android.util.Log.d(TAG, "Network available")
|
||||
onChange?.invoke()
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
android.util.Log.d(TAG, "Network lost")
|
||||
onChange?.invoke()
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
|
||||
// Fires when e.g. metered-ness flips without the network itself changing.
|
||||
onChange?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
cm.registerNetworkCallback(request, cb)
|
||||
callback = cb
|
||||
android.util.Log.d(TAG, "Network callback registered")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Failed to register network callback", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Unregister the network callback, if one is active. */
|
||||
fun stopWatching(context: Context) {
|
||||
val cb = callback ?: return
|
||||
val cm = connectivityManager(context)
|
||||
try {
|
||||
cm?.unregisterNetworkCallback(cb)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Failed to unregister network callback", e)
|
||||
}
|
||||
callback = null
|
||||
onChange = null
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex};
|
||||
use tauri::{Manager, State};
|
||||
|
||||
use super::{DatabaseWrapper, SmartCacheWrapper};
|
||||
use crate::download::network::{NetworkState, NetworkStateHandle, NetworkType};
|
||||
use crate::download::{DownloadInfo, DownloadManager};
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
|
||||
@@ -21,6 +22,80 @@ pub use smart_cache::*;
|
||||
/// Wrapper for DownloadManager to be used as Tauri state
|
||||
pub struct DownloadManagerWrapper(pub Mutex<DownloadManager>);
|
||||
|
||||
/// Wrapper for the current network transport, used by the WiFi-only gate.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
pub struct NetworkStateWrapper(pub NetworkStateHandle);
|
||||
|
||||
/// Report the device's current network transport (Android → Rust).
|
||||
///
|
||||
/// The frontend calls this on startup and whenever the native network callback
|
||||
/// fires. Updating to an acceptable network re-pumps the download queue, so a
|
||||
/// queue parked on "waiting for WiFi" drains itself without user action.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn set_network_state(
|
||||
app: tauri::AppHandle,
|
||||
network: NetworkStateWrapperArg,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
download_manager: State<'_, DownloadManagerWrapper>,
|
||||
) -> Result<(), String> {
|
||||
let new_state = NetworkState {
|
||||
network_type: network.network_type,
|
||||
unmetered: network.unmetered,
|
||||
};
|
||||
|
||||
let handle = app.state::<NetworkStateWrapper>().0.clone();
|
||||
let previous = handle.get().await;
|
||||
handle.set(new_state).await;
|
||||
|
||||
if previous != new_state {
|
||||
info!(
|
||||
"[network] Transport changed: {:?} (unmetered={}) -> {:?} (unmetered={})",
|
||||
previous.network_type, previous.unmetered, new_state.network_type, new_state.unmetered
|
||||
);
|
||||
}
|
||||
|
||||
// If the new network unblocks the gate, drain whatever was waiting.
|
||||
if downloads_allowed_on_current_network(&app).await {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
let active = {
|
||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||
manager.get_active_downloads()
|
||||
};
|
||||
pump_download_queue(app.clone(), db_service, active).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Argument struct for [`set_network_state`].
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NetworkStateWrapperArg {
|
||||
pub network_type: NetworkType,
|
||||
pub unmetered: bool,
|
||||
}
|
||||
|
||||
/// Whether downloads are currently permitted by the WiFi-only gate.
|
||||
///
|
||||
/// The downloads UI uses this to render "Waiting for WiFi" on pending rows
|
||||
/// rather than leaving them looking silently stuck.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_downloads_allowed(app: tauri::AppHandle) -> Result<bool, String> {
|
||||
Ok(downloads_allowed_on_current_network(&app).await)
|
||||
}
|
||||
|
||||
/// Download statistics computed server-side
|
||||
#[allow(dead_code)]
|
||||
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
@@ -1213,6 +1288,37 @@ pub async fn enqueue_video_downloads(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the current network permits downloads, given the user's WiFi-only
|
||||
/// preference.
|
||||
///
|
||||
/// Reads `wifi_only` from the SmartCache config (the single home of the
|
||||
/// setting) and checks it against the transport reported by the platform. On
|
||||
/// desktop the transport defaults to unmetered ethernet, so this is always
|
||||
/// true there.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
pub(crate) async fn downloads_allowed_on_current_network(app: &tauri::AppHandle) -> bool {
|
||||
let wifi_only = {
|
||||
let smart_cache = app.state::<SmartCacheWrapper>();
|
||||
let cache = match smart_cache.0.lock() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("[pump] Failed to lock smart cache: {}", e);
|
||||
// Fail open: a lock problem must not silently wedge downloads.
|
||||
return true;
|
||||
}
|
||||
};
|
||||
cache.get_config().map(|c| c.wifi_only).unwrap_or(false)
|
||||
};
|
||||
|
||||
if !wifi_only {
|
||||
return true;
|
||||
}
|
||||
|
||||
let network = app.state::<NetworkStateWrapper>();
|
||||
network.0.allows_download(true).await
|
||||
}
|
||||
|
||||
/// Start as many pending downloads as there are free concurrency slots.
|
||||
///
|
||||
/// Picks the highest-priority `pending` rows that have a persisted `stream_url`
|
||||
@@ -1227,6 +1333,16 @@ pub(crate) async fn pump_download_queue(
|
||||
use crate::download::events::DownloadEvent;
|
||||
use tauri::Emitter;
|
||||
|
||||
// WiFi-only gate (UR-053): when the user has restricted downloads to
|
||||
// unmetered networks and we're on cellular (or can't tell), leave every
|
||||
// pending row exactly as it is. They stay 'pending' and the Android
|
||||
// network callback re-pumps us as soon as an acceptable network appears.
|
||||
if !downloads_allowed_on_current_network(&app).await {
|
||||
info!("[pump] Downloads paused: waiting for an unmetered network (WiFi-only enabled)");
|
||||
let _ = app.emit("download-event", DownloadEvent::WaitingForNetwork);
|
||||
return;
|
||||
}
|
||||
|
||||
let max_concurrent = {
|
||||
let manager = app.state::<DownloadManagerWrapper>();
|
||||
let manager = match manager.0.lock() {
|
||||
@@ -1799,6 +1915,76 @@ pub async fn delete_album_downloads(
|
||||
Ok(deleted_count as i64)
|
||||
}
|
||||
|
||||
/// Remove every completed download at or under a container item.
|
||||
///
|
||||
/// Works at any level of the Downloaded browse: a leaf (removes just that
|
||||
/// download), an album/season/series (removes all downloaded descendants linked
|
||||
/// via album_id/season_id/series_id/parent_id). Deletes the DB rows and the
|
||||
/// on-disk files. Returns the number of downloads removed. Idempotent.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-083
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn delete_downloads_under(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
item_id: String,
|
||||
user_id: String,
|
||||
) -> Result<i64, String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
// The item itself, or any child linked to it by container id.
|
||||
const SCOPE: &str = "d.user_id = ? AND d.status = 'completed'
|
||||
AND (
|
||||
d.item_id = ?
|
||||
OR d.item_id IN (
|
||||
SELECT c.id FROM items c
|
||||
WHERE c.album_id = ? OR c.season_id = ? OR c.series_id = ? OR c.parent_id = ?
|
||||
)
|
||||
)";
|
||||
|
||||
let file_query = Query::with_params(
|
||||
&format!("SELECT d.file_path FROM downloads d WHERE {SCOPE}"),
|
||||
vec![
|
||||
QueryParam::String(user_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
],
|
||||
);
|
||||
let file_paths: Vec<String> = db_service
|
||||
.query_many(file_query, |row| row.get(0))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let delete_query = Query::with_params(
|
||||
&format!("DELETE FROM downloads WHERE id IN (SELECT d.id FROM downloads d WHERE {SCOPE})"),
|
||||
vec![
|
||||
QueryParam::String(user_id),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id),
|
||||
],
|
||||
);
|
||||
let deleted_count = db_service
|
||||
.execute(delete_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for path in file_paths {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.part", path));
|
||||
}
|
||||
|
||||
Ok(deleted_count as i64)
|
||||
}
|
||||
|
||||
/// Download manager statistics
|
||||
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
|
||||
pub struct DownloadManagerStats {
|
||||
|
||||
@@ -106,6 +106,8 @@ pub struct MergedMediaItem {
|
||||
pub album_id: Option<String>,
|
||||
pub duration: Option<f64>,
|
||||
pub primary_image_tag: Option<String>,
|
||||
/// Neutral image identifier — replaces `primary_image_tag` (same value).
|
||||
pub image_id: Option<String>,
|
||||
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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -195,6 +195,56 @@ pub async fn repository_get_item(
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Downloaded-only browse: libraries that contain downloaded content.
|
||||
///
|
||||
/// Backs the Downloads "Downloaded" surface. Never merges server results and is
|
||||
/// authoritative — an empty list means nothing is downloaded.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_downloaded_libraries(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
) -> Result<Vec<Library>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.get_downloaded_libraries()
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Downloaded-only browse: items under a container that are on the device.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082, DR-083
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_downloaded_items(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
parent_id: String,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.get_downloaded_items(&parent_id, options)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// On-disk usage of downloaded content (device total, per-item/container bytes).
|
||||
///
|
||||
/// TRACES: UR-056 | DR-085
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_download_disk_usage(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
) -> Result<DownloadDiskUsage, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.get_download_disk_usage()
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Query the optional JRay plugin for the actors on screen at time `t`
|
||||
/// (seconds) in an item. Returns an empty list when JRay isn't installed or
|
||||
/// has no data for the item, so the caller can render nothing without error.
|
||||
@@ -529,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)
|
||||
@@ -545,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)
|
||||
@@ -561,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)
|
||||
|
||||
@@ -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<String>,
|
||||
context_id: Option<String>,
|
||||
) -> 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"));
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Jellyfin → domain translation.
|
||||
//!
|
||||
//! The ONLY place Jellyfin's vocabulary touches the domain model. Adding a
|
||||
//! second provider later means a sibling `from_<provider>.rs`; the domain types
|
||||
//! and every consumer stay untouched.
|
||||
//!
|
||||
//! Spec: docs/specs/frontend-domain-model.md
|
||||
|
||||
use super::media::{MediaKind, StreamKind};
|
||||
|
||||
/// Classify a Jellyfin media-stream `Type` string into a neutral [`StreamKind`].
|
||||
/// Total and panic-free.
|
||||
pub fn stream_kind_from_jellyfin(stream_type: &str) -> StreamKind {
|
||||
match stream_type {
|
||||
"Audio" => StreamKind::Audio,
|
||||
"Video" => StreamKind::Video,
|
||||
"Subtitle" => StreamKind::Subtitle,
|
||||
_ => StreamKind::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Jellyfin ticks per second (10 million). A tick is 100 ns.
|
||||
/// The frontend must never see ticks — this is where they die.
|
||||
const TICKS_PER_MILLISECOND: i64 = 10_000;
|
||||
|
||||
/// Convert a Jellyfin `RunTimeTicks` value to milliseconds.
|
||||
///
|
||||
/// Domain durations are milliseconds; ticks are a Jellyfin unit and stop here.
|
||||
pub fn ticks_to_ms(ticks: i64) -> i64 {
|
||||
ticks / TICKS_PER_MILLISECOND
|
||||
}
|
||||
|
||||
/// Classify a Jellyfin `Type` string into a neutral [`MediaKind`].
|
||||
///
|
||||
/// **Total and panic-free**: any unrecognised string maps to [`MediaKind::Other`]
|
||||
/// rather than failing. `is_folder` disambiguates the one Jellyfin type
|
||||
/// (`ChannelFolderItem`) whose kind depends on whether it is a container.
|
||||
///
|
||||
/// The recognised set is every `item_type` the frontend audit found in use
|
||||
/// (docs/specs/frontend-domain-model.md), plus the common cast/crew person
|
||||
/// subtypes Jellyfin returns in `People[].Type`.
|
||||
pub fn kind_from_jellyfin(item_type: &str, is_folder: bool) -> MediaKind {
|
||||
match item_type {
|
||||
// Music
|
||||
"Audio" | "MusicVideo" => MediaKind::Track,
|
||||
"MusicAlbum" => MediaKind::Album,
|
||||
"MusicArtist" | "AlbumArtist" => MediaKind::Artist,
|
||||
"Playlist" => MediaKind::Playlist,
|
||||
|
||||
// Video
|
||||
"Movie" => MediaKind::Movie,
|
||||
"Series" => MediaKind::Series,
|
||||
"Season" => MediaKind::Season,
|
||||
"Episode" => MediaKind::Episode,
|
||||
// A bare video leaf with no richer classification.
|
||||
"Video" => MediaKind::Movie,
|
||||
|
||||
// Cast / crew — Jellyfin uses both a "Person" item type and role-typed
|
||||
// people (Actor/Director/Writer/Composer/…) in People[].Type.
|
||||
"Person" | "Actor" | "Director" | "Writer" | "Composer" | "GuestStar" | "Producer" => {
|
||||
MediaKind::Person
|
||||
}
|
||||
|
||||
// A live TV channel: playable, but a non-seekable live stream.
|
||||
"TvChannel" | "LiveTvChannel" => MediaKind::LiveChannel,
|
||||
// A bare channel is a container the user drills into.
|
||||
"Channel" => MediaKind::Channel,
|
||||
|
||||
// Containers
|
||||
"Folder" | "CollectionFolder" | "UserView" | "BoxSet" => MediaKind::Folder,
|
||||
// ChannelFolderItem is a container when it is a folder, else a playable
|
||||
// channel leaf (distinct kind so the UI can route it to playback).
|
||||
"ChannelFolderItem" => {
|
||||
if is_folder {
|
||||
MediaKind::Folder
|
||||
} else {
|
||||
MediaKind::ChannelItem
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown → safe sink. Never panics.
|
||||
_ => {
|
||||
if is_folder {
|
||||
MediaKind::Folder
|
||||
} else {
|
||||
MediaKind::Other
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ticks_convert_to_milliseconds() {
|
||||
// 1 second = 10,000,000 ticks = 1000 ms
|
||||
assert_eq!(ticks_to_ms(10_000_000), 1000);
|
||||
// 90.5 s
|
||||
assert_eq!(ticks_to_ms(905_000_000), 90_500);
|
||||
assert_eq!(ticks_to_ms(0), 0);
|
||||
// Sub-millisecond truncates toward zero, not panics.
|
||||
assert_eq!(ticks_to_ms(9_999), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn music_types_map() {
|
||||
assert_eq!(kind_from_jellyfin("Audio", false), MediaKind::Track);
|
||||
assert_eq!(kind_from_jellyfin("MusicAlbum", true), MediaKind::Album);
|
||||
assert_eq!(kind_from_jellyfin("MusicArtist", true), MediaKind::Artist);
|
||||
assert_eq!(kind_from_jellyfin("Playlist", true), MediaKind::Playlist);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_types_map() {
|
||||
assert_eq!(kind_from_jellyfin("Movie", false), MediaKind::Movie);
|
||||
assert_eq!(kind_from_jellyfin("Series", true), MediaKind::Series);
|
||||
assert_eq!(kind_from_jellyfin("Season", true), MediaKind::Season);
|
||||
assert_eq!(kind_from_jellyfin("Episode", false), MediaKind::Episode);
|
||||
assert_eq!(kind_from_jellyfin("Video", false), MediaKind::Movie);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn person_and_role_types_map_to_person() {
|
||||
for t in ["Person", "Actor", "Director", "Writer", "Composer"] {
|
||||
assert_eq!(kind_from_jellyfin(t, false), MediaKind::Person, "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_and_container_types_map() {
|
||||
assert_eq!(
|
||||
kind_from_jellyfin("TvChannel", false),
|
||||
MediaKind::LiveChannel
|
||||
);
|
||||
assert_eq!(kind_from_jellyfin("Channel", false), MediaKind::Channel);
|
||||
assert_eq!(
|
||||
kind_from_jellyfin("CollectionFolder", true),
|
||||
MediaKind::Folder
|
||||
);
|
||||
assert_eq!(kind_from_jellyfin("BoxSet", true), MediaKind::Folder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_folder_item_disambiguates_on_is_folder() {
|
||||
assert_eq!(
|
||||
kind_from_jellyfin("ChannelFolderItem", true),
|
||||
MediaKind::Folder
|
||||
);
|
||||
assert_eq!(
|
||||
kind_from_jellyfin("ChannelFolderItem", false),
|
||||
MediaKind::ChannelItem
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_kinds_map() {
|
||||
assert_eq!(stream_kind_from_jellyfin("Audio"), StreamKind::Audio);
|
||||
assert_eq!(stream_kind_from_jellyfin("Video"), StreamKind::Video);
|
||||
assert_eq!(stream_kind_from_jellyfin("Subtitle"), StreamKind::Subtitle);
|
||||
assert_eq!(
|
||||
stream_kind_from_jellyfin("EmbeddedImage"),
|
||||
StreamKind::Other
|
||||
);
|
||||
assert_eq!(stream_kind_from_jellyfin(""), StreamKind::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_type_never_panics_and_falls_back() {
|
||||
// The whole point: garbage in, safe kind out, no panic.
|
||||
assert_eq!(kind_from_jellyfin("Epis0de", false), MediaKind::Other);
|
||||
assert_eq!(kind_from_jellyfin("", false), MediaKind::Other);
|
||||
assert_eq!(
|
||||
kind_from_jellyfin("SomeFutureType", true),
|
||||
MediaKind::Folder
|
||||
);
|
||||
assert_eq!(kind_from_jellyfin("🎵unicode", false), MediaKind::Other);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -64,11 +64,20 @@ impl SmartCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if should pre-cache queue items
|
||||
/// Check if should pre-cache queue items.
|
||||
///
|
||||
/// Note this deliberately does NOT consult `wifi_only`. It used to return
|
||||
/// `queue_precache_enabled && !wifi_only`, which disabled precaching
|
||||
/// outright whenever the user enabled WiFi-only — regardless of the network
|
||||
/// actually in use. The network check now lives in the download queue pump
|
||||
/// (`downloads_allowed_on_current_network`), which is the single gate for
|
||||
/// all download traffic, so this only answers "is precaching enabled?".
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
pub fn should_precache_queue(&self) -> bool {
|
||||
self.config
|
||||
.lock()
|
||||
.map(|cfg| cfg.queue_precache_enabled && !cfg.wifi_only)
|
||||
.map(|cfg| cfg.queue_precache_enabled)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -282,6 +291,19 @@ mod tests {
|
||||
assert!(cache.should_precache_queue());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wifi_only_does_not_disable_precaching() {
|
||||
// wifi_only must not short-circuit precaching: the network gate lives in
|
||||
// the download pump, which checks the *actual* transport. Enabling
|
||||
// WiFi-only while on WiFi should still precache.
|
||||
let mut config = CacheConfig::default();
|
||||
config.queue_precache_enabled = true;
|
||||
config.wifi_only = true;
|
||||
|
||||
let cache = SmartCache::new(config);
|
||||
assert!(cache.should_precache_queue());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_storage_limit_check() {
|
||||
use crate::storage::db_service::RusqliteService;
|
||||
|
||||
@@ -41,6 +41,11 @@ pub enum DownloadEvent {
|
||||
/// Download cancelled
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Cancelled { download_id: i64, item_id: String },
|
||||
/// The queue is holding: WiFi-only is enabled and the current network is
|
||||
/// metered/cellular. Pending rows stay pending and resume on network change.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
WaitingForNetwork,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
pub mod cache;
|
||||
pub mod events;
|
||||
pub mod network;
|
||||
pub mod worker;
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Network transport classification for the WiFi-only download gate.
|
||||
//!
|
||||
//! This answers "what kind of connection are we on?", which is orthogonal to
|
||||
//! the `ConnectivityMonitor`'s "is the server reachable?". The download queue
|
||||
//! pump consults this before starting pending rows when the user has enabled
|
||||
//! WiFi-only downloads.
|
||||
//!
|
||||
//! On Android the real transport is read from `NetworkCapabilities` in
|
||||
//! `NetworkTypeMonitor.kt` and pushed in from the frontend. On desktop there is
|
||||
//! no metered-connection concept worth enforcing, so we report `Ethernet`,
|
||||
//! which is always acceptable — gating desktop downloads would be a regression.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Kind of network transport currently active.
|
||||
///
|
||||
/// Mirrors the string constants in `NetworkTypeMonitor.kt`; the two must stay
|
||||
/// in sync (the serde rename below is what the frontend sends).
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NetworkType {
|
||||
/// No active network.
|
||||
None,
|
||||
/// WiFi (may still be metered — check `unmetered`).
|
||||
Wifi,
|
||||
/// Wired ethernet, typical on Android TV and desktop.
|
||||
Ethernet,
|
||||
/// Mobile data — never acceptable when wifi-only is enabled.
|
||||
Cellular,
|
||||
/// Some other transport (VPN over unknown carrier, Bluetooth tethering, …).
|
||||
Other,
|
||||
/// Could not determine the transport.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Current network transport plus whether it is metered.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NetworkState {
|
||||
pub network_type: NetworkType,
|
||||
/// Whether the active network is unmetered (Android `NET_CAPABILITY_NOT_METERED`).
|
||||
pub unmetered: bool,
|
||||
}
|
||||
|
||||
impl Default for NetworkState {
|
||||
fn default() -> Self {
|
||||
// Desktop default: wired and unmetered, so the gate never blocks there.
|
||||
// Android overwrites this as soon as the frontend reports the real state.
|
||||
Self {
|
||||
network_type: NetworkType::Ethernet,
|
||||
unmetered: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkState {
|
||||
/// Whether downloads may run right now given the wifi-only preference.
|
||||
///
|
||||
/// Ethernet counts as acceptable — it is unmetered in practice and is what
|
||||
/// Android TV devices use. Cellular never does. `None`/`Unknown` fail
|
||||
/// closed: if we cannot tell what we are on, we do not spend the user's
|
||||
/// mobile data to find out.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
pub fn allows_download(&self, wifi_only: bool) -> bool {
|
||||
if !wifi_only {
|
||||
return true;
|
||||
}
|
||||
match self.network_type {
|
||||
NetworkType::Cellular | NetworkType::None | NetworkType::Unknown => false,
|
||||
// Require unmetered so metered WiFi hotspots (backed by the very
|
||||
// cellular data this setting protects) are excluded too.
|
||||
NetworkType::Wifi | NetworkType::Ethernet | NetworkType::Other => self.unmetered,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared, mutable view of the current network transport.
|
||||
///
|
||||
/// Cheap to clone; the frontend updates it via `set_network_state` whenever
|
||||
/// Android reports a network change.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct NetworkStateHandle {
|
||||
state: Arc<RwLock<NetworkState>>,
|
||||
}
|
||||
|
||||
impl NetworkStateHandle {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(NetworkState::default())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(&self) -> NetworkState {
|
||||
*self.state.read().await
|
||||
}
|
||||
|
||||
pub async fn set(&self, new_state: NetworkState) {
|
||||
*self.state.write().await = new_state;
|
||||
}
|
||||
|
||||
/// Whether downloads may run right now given the wifi-only preference.
|
||||
pub async fn allows_download(&self, wifi_only: bool) -> bool {
|
||||
self.state.read().await.allows_download(wifi_only)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn state(network_type: NetworkType, unmetered: bool) -> NetworkState {
|
||||
NetworkState {
|
||||
network_type,
|
||||
unmetered,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wifi_only_off_allows_every_transport() {
|
||||
for t in [
|
||||
NetworkType::None,
|
||||
NetworkType::Wifi,
|
||||
NetworkType::Ethernet,
|
||||
NetworkType::Cellular,
|
||||
NetworkType::Other,
|
||||
NetworkType::Unknown,
|
||||
] {
|
||||
assert!(
|
||||
state(t, false).allows_download(false),
|
||||
"{t:?} should be allowed when wifi_only is off"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cellular_is_blocked_when_wifi_only() {
|
||||
// Even if somehow flagged unmetered, cellular is never acceptable.
|
||||
assert!(!state(NetworkType::Cellular, true).allows_download(true));
|
||||
assert!(!state(NetworkType::Cellular, false).allows_download(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmetered_wifi_and_ethernet_are_allowed() {
|
||||
assert!(state(NetworkType::Wifi, true).allows_download(true));
|
||||
assert!(state(NetworkType::Ethernet, true).allows_download(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metered_wifi_is_blocked() {
|
||||
// A phone hotspot reports as WiFi but is metered — blocking it is the
|
||||
// whole point of checking NOT_METERED rather than the transport alone.
|
||||
assert!(!state(NetworkType::Wifi, false).allows_download(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_none_fail_closed() {
|
||||
assert!(!state(NetworkType::Unknown, true).allows_download(true));
|
||||
assert!(!state(NetworkType::None, true).allows_download(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_default_is_never_gated() {
|
||||
assert!(NetworkState::default().allows_download(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_roundtrips_state() {
|
||||
let handle = NetworkStateHandle::new();
|
||||
assert!(handle.allows_download(true).await);
|
||||
|
||||
handle.set(state(NetworkType::Cellular, false)).await;
|
||||
assert!(!handle.allows_download(true).await);
|
||||
assert!(handle.allows_download(false).await);
|
||||
assert_eq!(handle.get().await.network_type, NetworkType::Cellular);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_type_serializes_lowercase() {
|
||||
// Must match the string constants in NetworkTypeMonitor.kt.
|
||||
assert_eq!(
|
||||
serde_json::to_string(&NetworkType::Wifi).unwrap(),
|
||||
"\"wifi\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&NetworkType::Cellular).unwrap(),
|
||||
"\"cellular\""
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod auth;
|
||||
mod commands;
|
||||
mod connectivity;
|
||||
mod credentials;
|
||||
mod domain;
|
||||
mod download;
|
||||
mod jellyfin;
|
||||
mod playback_mode;
|
||||
@@ -52,6 +53,7 @@ use commands::{
|
||||
delete_album_downloads,
|
||||
delete_all_downloads,
|
||||
delete_download,
|
||||
delete_downloads_under,
|
||||
// Device commands
|
||||
device_get_id,
|
||||
device_set_id,
|
||||
@@ -71,6 +73,7 @@ use commands::{
|
||||
get_download_manager_stats,
|
||||
get_download_storage_stats,
|
||||
get_downloads,
|
||||
get_downloads_allowed,
|
||||
get_smart_cache_config,
|
||||
get_smart_cache_stats,
|
||||
image_get_url,
|
||||
@@ -179,6 +182,9 @@ use commands::{
|
||||
repository_get_audio_only_stream_url_for_video,
|
||||
repository_get_audio_stream_url,
|
||||
repository_get_channels,
|
||||
repository_get_download_disk_usage,
|
||||
repository_get_downloaded_items,
|
||||
repository_get_downloaded_libraries,
|
||||
repository_get_genres,
|
||||
repository_get_image_url,
|
||||
repository_get_item,
|
||||
@@ -212,6 +218,7 @@ use commands::{
|
||||
// Session polling commands
|
||||
sessions_set_polling_hint,
|
||||
set_max_concurrent_downloads,
|
||||
set_network_state,
|
||||
set_show_server_catalog,
|
||||
start_download,
|
||||
// Storage commands
|
||||
@@ -770,6 +777,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
delete_download,
|
||||
delete_all_downloads,
|
||||
delete_album_downloads,
|
||||
delete_downloads_under,
|
||||
clear_stale_downloads,
|
||||
get_download_storage_stats,
|
||||
mark_download_completed,
|
||||
@@ -786,6 +794,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
get_smart_cache_stats,
|
||||
update_smart_cache_config,
|
||||
get_smart_cache_config,
|
||||
// WiFi-only download gate (UR-053)
|
||||
set_network_state,
|
||||
get_downloads_allowed,
|
||||
get_album_recommendations,
|
||||
get_album_affinity_status,
|
||||
// Pinning commands
|
||||
@@ -835,6 +846,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
repository_get_libraries,
|
||||
repository_get_items,
|
||||
repository_get_item,
|
||||
repository_get_downloaded_libraries,
|
||||
repository_get_downloaded_items,
|
||||
repository_get_download_disk_usage,
|
||||
repository_jray_actors_at,
|
||||
repository_get_latest_items,
|
||||
repository_get_resume_items,
|
||||
@@ -1196,6 +1210,13 @@ pub fn run() {
|
||||
let download_manager_wrapper = DownloadManagerWrapper(Mutex::new(download_manager));
|
||||
app.manage(download_manager_wrapper);
|
||||
|
||||
// Current network transport, for the WiFi-only download gate (UR-053).
|
||||
// Defaults to unmetered ethernet so desktop is never gated; Android
|
||||
// overwrites it via set_network_state as soon as the UI starts.
|
||||
app.manage(commands::download::NetworkStateWrapper(
|
||||
download::network::NetworkStateHandle::new(),
|
||||
));
|
||||
|
||||
// Initialize connectivity monitor
|
||||
info!("[INIT] Initializing connectivity monitor...");
|
||||
let http_config = HttpConfig::default();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<Vec<String>>,
|
||||
/// Primary image tag for artwork
|
||||
/// Primary image tag for artwork.
|
||||
///
|
||||
/// Legacy Jellyfin name; being replaced by `image_id` (same value). Dual-carried
|
||||
/// while the frontend migrates (docs/specs/frontend-domain-model.md).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub primary_image_tag: Option<String>,
|
||||
/// Neutral image identifier the frontend resolves to a URL — replaces
|
||||
/// `primary_image_tag`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image_id: Option<String>,
|
||||
/// Item type (Audio, Movie, Episode, etc.)
|
||||
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
|
||||
pub item_type: Option<String>,
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// @req: IR-013 - SQLite integration for local database
|
||||
// @req: DR-012 - Local database for media metadata cache
|
||||
// @req: DR-013 - Repository pattern for online/offline data access
|
||||
//
|
||||
// TRACES: UR-002, UR-052 | IR-013 | DR-012, DR-013, DR-080
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::utils::lock::MutexSafe;
|
||||
@@ -131,6 +133,36 @@ impl HybridRepository {
|
||||
Ok(result.items)
|
||||
}
|
||||
|
||||
/// Browse downloaded content only — the dedicated Downloads surface.
|
||||
///
|
||||
/// Bypasses the cache/server merge entirely and reads the offline repository
|
||||
/// directly, so an empty result is authoritative ("nothing downloaded here")
|
||||
/// and never falls through to the server (DR-080). Available online too — a
|
||||
/// user who is reachable still wants to browse what's on the device.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082, DR-083
|
||||
pub async fn get_downloaded_items(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
self.offline.get_downloaded_items(parent_id, options).await
|
||||
}
|
||||
|
||||
/// Libraries that contain downloaded content (offline-only, authoritative).
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082
|
||||
pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError> {
|
||||
self.offline.get_downloaded_libraries().await
|
||||
}
|
||||
|
||||
/// On-disk usage of downloaded content, for the disk-usage display.
|
||||
///
|
||||
/// TRACES: UR-056 | DR-085
|
||||
pub async fn get_download_disk_usage(&self) -> Result<DownloadDiskUsage, RepoError> {
|
||||
self.offline.get_download_disk_usage().await
|
||||
}
|
||||
|
||||
/// Search only the live Jellyfin server (full library).
|
||||
pub async fn search_server_only(
|
||||
&self,
|
||||
@@ -316,6 +348,26 @@ impl MediaRepository for HybridRepository {
|
||||
.cache_with_timeout(async move { offline.get_items(&parent_id, opts_clone).await })
|
||||
.await;
|
||||
|
||||
// Downloads-only gate: when the "Show all server media" toggle is off
|
||||
// (offline), an empty offline result is authoritative — the user asked
|
||||
// for downloaded media only and this library has none. Return it as-is
|
||||
// rather than falling through to the server, which would re-pad the page
|
||||
// with the full catalog and re-defeat the filter (DR-080). When the flag
|
||||
// is on (the default, and always so while reachable) behaviour below is
|
||||
// unchanged, including the background cache refresh on a hit.
|
||||
if !crate::repository::offline::include_catalog_browse() {
|
||||
if let Ok(data) = &cache_result {
|
||||
debug!(
|
||||
"[HybridRepo] Downloads-only gate: returning offline result ({} items) as authoritative for parent {}",
|
||||
data.items.len(),
|
||||
&parent_id_for_save[..8.min(parent_id_for_save.len())]
|
||||
);
|
||||
// Abort the in-flight server request; we won't use it.
|
||||
server_handle.abort();
|
||||
return Ok(data.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Cache hit: return immediately, update cache in background
|
||||
if let Ok(data) = &cache_result {
|
||||
if data.has_content() {
|
||||
@@ -1370,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()),
|
||||
@@ -1377,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,
|
||||
@@ -1457,6 +1512,81 @@ mod tests {
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Test version mirroring the real `HybridRepository::get_items`
|
||||
/// downloads-only gate: when `include_catalog_browse()` is false, the
|
||||
/// offline result is authoritative and the server is NOT queried, even
|
||||
/// when the cache is empty. Otherwise falls through to the normal
|
||||
/// cache-first logic in `get_items`.
|
||||
async fn get_items_gated(&self, parent_id: &str) -> Result<SearchResult, RepoError> {
|
||||
if !crate::repository::offline::include_catalog_browse() {
|
||||
let items = self.offline.get_items(parent_id, None).await?;
|
||||
// Authoritative: return as-is, never touch the server.
|
||||
return Ok(items);
|
||||
}
|
||||
self.get_items(parent_id).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize tests that mutate the process-global INCLUDE_CATALOG_BROWSE
|
||||
/// flag, and always restore it to the default (true) afterwards.
|
||||
static GATE_TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// UT-070: with the downloads-only gate off, an empty offline result is
|
||||
/// returned as-is and the server is NOT queried.
|
||||
///
|
||||
/// @req-test: UR-052 - Offline "downloaded only" filtering
|
||||
/// @req-test: DR-080 - Empty offline result is authoritative when gate off
|
||||
#[tokio::test]
|
||||
async fn test_get_items_gate_off_empty_does_not_query_server() {
|
||||
let _guard = GATE_TEST_LOCK.lock_safe();
|
||||
crate::repository::offline::set_include_catalog_browse(false);
|
||||
|
||||
// Server has items, cache is empty. Gate off ⇒ the server must be ignored.
|
||||
let repo = TestHybridRepo::new(vec![
|
||||
create_test_item("s-1", "Server 1"),
|
||||
create_test_item("s-2", "Server 2"),
|
||||
]);
|
||||
|
||||
let result = repo.get_items_gated("parent-123").await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result.items.len(),
|
||||
0,
|
||||
"empty offline result is authoritative when the gate is off"
|
||||
);
|
||||
assert_eq!(
|
||||
repo.online.get_query_count(),
|
||||
0,
|
||||
"server must NOT be queried when the gate is off"
|
||||
);
|
||||
|
||||
crate::repository::offline::set_include_catalog_browse(true);
|
||||
}
|
||||
|
||||
/// Guard the online path: with the gate ON and an empty cache, get_items
|
||||
/// still falls through to the server (unchanged behaviour).
|
||||
///
|
||||
/// @req-test: UR-052 - Offline "downloaded only" filtering
|
||||
/// @req-test: DR-080 - Gate on ⇒ empty cache still queries the server
|
||||
#[tokio::test]
|
||||
async fn test_get_items_gate_on_empty_falls_through_to_server() {
|
||||
let _guard = GATE_TEST_LOCK.lock_safe();
|
||||
crate::repository::offline::set_include_catalog_browse(true);
|
||||
|
||||
let repo = TestHybridRepo::new(vec![
|
||||
create_test_item("s-1", "Server 1"),
|
||||
create_test_item("s-2", "Server 2"),
|
||||
]);
|
||||
|
||||
let result = repo.get_items_gated("parent-123").await.unwrap();
|
||||
|
||||
assert_eq!(result.items.len(), 2, "server result used on empty cache");
|
||||
assert_eq!(
|
||||
repo.online.get_query_count(),
|
||||
1,
|
||||
"server IS queried when the gate is on and the cache is empty"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test cache miss saves server data to cache for next time
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// Offline repository - queries SQLite database for cached data
|
||||
//
|
||||
// TRACES: UR-002, UR-052 | DR-012, DR-013, DR-078
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -18,6 +20,8 @@ use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteSer
|
||||
/// the full greyed-out catalog. See `set_include_catalog_browse` and the
|
||||
/// `showServerCatalog` UI flag. Fixes the bug where offline library pages showed
|
||||
/// every server item regardless of the toggle.
|
||||
///
|
||||
/// TRACES: UR-052 | DR-078
|
||||
static INCLUDE_CATALOG_BROWSE: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
/// Set whether offline `get_items` includes non-downloaded (synced-only) catalog
|
||||
@@ -28,7 +32,16 @@ pub fn set_include_catalog_browse(include: bool) {
|
||||
INCLUDE_CATALOG_BROWSE.store(include, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn include_catalog_browse() -> bool {
|
||||
/// Whether offline `get_items` currently includes the synced-but-not-downloaded
|
||||
/// catalog (the greyed-out browse view). Mirrors `set_include_catalog_browse`.
|
||||
///
|
||||
/// Exposed so the hybrid repo can tell "cache is cold, ask the server" from
|
||||
/// "user asked for downloads only and there are none here": when this is false,
|
||||
/// an empty offline `get_items` is authoritative and must not fall through to
|
||||
/// the server. See hybrid.rs `get_items`.
|
||||
///
|
||||
/// TRACES: UR-052 | DR-078, DR-080
|
||||
pub fn include_catalog_browse() -> bool {
|
||||
INCLUDE_CATALOG_BROWSE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
@@ -55,10 +68,13 @@ impl OfflineRepository {
|
||||
.and_then(|s| serde_json::from_str::<Vec<String>>(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,
|
||||
@@ -69,11 +85,13 @@ impl OfflineRepository {
|
||||
.as_ref()
|
||||
.and_then(|s| serde_json::from_str::<Vec<String>>(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,
|
||||
@@ -107,8 +125,10 @@ impl OfflineRepository {
|
||||
|
||||
self.db_service
|
||||
.query_optional(query, |row| {
|
||||
let playback_position_ticks: Option<i64> = 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<i32>>(1).ok().flatten().map(|v| v != 0),
|
||||
is_favorite: row.get::<_, Option<i32>>(2).ok().flatten().map(|v| v != 0),
|
||||
play_count: row.get(3).ok(),
|
||||
@@ -509,6 +529,266 @@ impl OfflineRepository {
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
/// SQL fragment: the set of item ids that are "on the device" — playable
|
||||
/// items with a completed download, plus containers (album/series/season)
|
||||
/// that have at least one downloaded child. This is the `get_items` CTE with
|
||||
/// the synced-but-not-downloaded catalog branch deliberately excluded, so it
|
||||
/// is authoritative regardless of the process-wide catalog-browse flag.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082, DR-083
|
||||
const DOWNLOADED_ITEMS_CTE: &'static str = "
|
||||
WITH downloaded_items AS (
|
||||
SELECT DISTINCT i.id
|
||||
FROM items i
|
||||
INNER JOIN downloads d ON i.id = d.item_id
|
||||
WHERE d.status = 'completed'
|
||||
AND i.item_type IN ('Audio', 'Movie', 'Episode')
|
||||
|
||||
UNION
|
||||
|
||||
SELECT DISTINCT i.id
|
||||
FROM items i
|
||||
INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
|
||||
INNER JOIN downloads d ON children.id = d.item_id
|
||||
WHERE d.status = 'completed'
|
||||
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
|
||||
)";
|
||||
|
||||
/// Downloaded-only browse: items under `parent_id` that are on the device.
|
||||
///
|
||||
/// Unlike [`MediaRepository::get_items`], this never includes the
|
||||
/// synced-but-not-downloaded catalog and never consults the process-wide
|
||||
/// `INCLUDE_CATALOG_BROWSE` flag — it is the dedicated Downloads surface.
|
||||
/// An empty result is authoritative ("nothing downloaded here"), so the
|
||||
/// hybrid repo must call this directly rather than racing the server.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082, DR-083
|
||||
pub async fn get_downloaded_items(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
let opts = options.unwrap_or_default();
|
||||
let limit = opts.limit.unwrap_or(10000);
|
||||
let start_index = opts.start_index.unwrap_or(0);
|
||||
|
||||
let type_filter = if let Some(include_item_types) = &opts.include_item_types {
|
||||
if !include_item_types.is_empty() {
|
||||
let types = include_item_types
|
||||
.iter()
|
||||
.map(|t| format!("'{}'", t.replace('\'', "''")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!(" AND i.item_type IN ({})", types)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"{cte}
|
||||
SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
|
||||
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
|
||||
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
|
||||
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
|
||||
i.parent_index_number, i.is_folder, i.premiere_date
|
||||
FROM items i
|
||||
INNER JOIN downloaded_items di ON i.id = di.id
|
||||
WHERE i.server_id = ?
|
||||
AND (
|
||||
i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM libraries l
|
||||
WHERE l.id = ? AND l.server_id = i.server_id
|
||||
)
|
||||
){type_filter}
|
||||
ORDER BY i.sort_name ASC, i.name ASC
|
||||
LIMIT {limit} OFFSET {start_index}",
|
||||
cte = Self::DOWNLOADED_ITEMS_CTE,
|
||||
);
|
||||
|
||||
let query = Query::with_params(
|
||||
sql,
|
||||
vec![
|
||||
QueryParam::String(self.server_id.clone()),
|
||||
QueryParam::String(parent_id.to_string()),
|
||||
QueryParam::String(parent_id.to_string()),
|
||||
QueryParam::String(parent_id.to_string()),
|
||||
QueryParam::String(parent_id.to_string()),
|
||||
QueryParam::String(parent_id.to_string()),
|
||||
],
|
||||
);
|
||||
|
||||
let cached_items: Vec<CachedItem> = self
|
||||
.db_service
|
||||
.query_many(query, row_to_cached_item)
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?;
|
||||
|
||||
let mut items = Vec::new();
|
||||
for cached in cached_items {
|
||||
let user_data = self.get_user_data(&cached.id).await;
|
||||
items.push(Self::cached_item_to_media_item(cached, user_data));
|
||||
}
|
||||
|
||||
let total_record_count = items.len();
|
||||
Ok(SearchResult {
|
||||
items,
|
||||
total_record_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Libraries that contain at least one downloaded item. Libraries with
|
||||
/// nothing on the device are omitted, so the Downloaded surface only lists
|
||||
/// libraries the user actually has offline content in.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082
|
||||
pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError> {
|
||||
// A downloaded item links back to its library only indirectly (the
|
||||
// cache leaves library_id NULL — see [[offline-libraries-never-cached]]).
|
||||
// We match a library by collection_type ↔ item_type instead: any
|
||||
// completed download of a given media kind qualifies that library.
|
||||
let query = Query::with_params(
|
||||
&format!(
|
||||
"{cte}
|
||||
SELECT l.id, l.name, l.collection_type, l.image_tag
|
||||
FROM libraries l
|
||||
WHERE l.server_id = ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM items i
|
||||
INNER JOIN downloaded_items di ON i.id = di.id
|
||||
WHERE i.server_id = l.server_id
|
||||
AND (
|
||||
(l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
|
||||
OR (l.collection_type = 'movies' AND i.item_type = 'Movie')
|
||||
OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
|
||||
OR (l.collection_type NOT IN ('music', 'movies', 'tvshows'))
|
||||
)
|
||||
)
|
||||
ORDER BY l.sort_order ASC, l.name ASC",
|
||||
cte = Self::DOWNLOADED_ITEMS_CTE,
|
||||
),
|
||||
vec![QueryParam::String(self.server_id.clone())],
|
||||
);
|
||||
|
||||
self.db_service
|
||||
.query_many(query, |row| {
|
||||
Ok(Library {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
collection_type: row
|
||||
.get::<_, Option<String>>(2)?
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
image_tag: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })
|
||||
}
|
||||
|
||||
/// On-disk bytes for downloaded content, for the disk-usage display.
|
||||
///
|
||||
/// Returns one entry per *container or leaf* that appears in the Downloaded
|
||||
/// browse: a leaf's own `file_size`, a container's summed downloaded
|
||||
/// descendants — plus the device total and item (leaf) count. This is pure
|
||||
/// aggregation over `downloads.file_size`, not new tracking.
|
||||
///
|
||||
/// TRACES: UR-056 | DR-085
|
||||
pub async fn get_download_disk_usage(&self) -> Result<DownloadDiskUsage, RepoError> {
|
||||
// Per-leaf sizes (completed playable downloads only).
|
||||
let leaf_query = Query::with_params(
|
||||
"SELECT d.item_id, COALESCE(d.file_size, 0)
|
||||
FROM downloads d
|
||||
INNER JOIN items i ON i.id = d.item_id
|
||||
WHERE d.status = 'completed'
|
||||
AND i.server_id = ?
|
||||
AND i.item_type IN ('Audio', 'Movie', 'Episode')",
|
||||
vec![QueryParam::String(self.server_id.clone())],
|
||||
);
|
||||
let leaves: Vec<(String, i64)> = self
|
||||
.db_service
|
||||
.query_many(leaf_query, |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?;
|
||||
|
||||
// Container subtotals: sum each container's downloaded descendants.
|
||||
let container_query = Query::with_params(
|
||||
"SELECT c.id, COALESCE(SUM(d.file_size), 0)
|
||||
FROM items c
|
||||
INNER JOIN items children
|
||||
ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
|
||||
INNER JOIN downloads d ON children.id = d.item_id
|
||||
WHERE d.status = 'completed'
|
||||
AND c.server_id = ?
|
||||
AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
|
||||
GROUP BY c.id",
|
||||
vec![QueryParam::String(self.server_id.clone())],
|
||||
);
|
||||
let containers: Vec<(String, i64)> = self
|
||||
.db_service
|
||||
.query_many(container_query, |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?;
|
||||
|
||||
// Partiality per container: a container is "partial" when it has cached
|
||||
// descendants that are NOT downloaded. We compare downloaded-descendant
|
||||
// count against total-cached-descendant count (the offline cache holds
|
||||
// the synced full catalog, so this is meaningful).
|
||||
let partial_query = Query::with_params(
|
||||
"SELECT c.id,
|
||||
COUNT(children.id) AS total_children,
|
||||
SUM(CASE WHEN d.status = 'completed' THEN 1 ELSE 0 END) AS downloaded_children
|
||||
FROM items c
|
||||
INNER JOIN items children
|
||||
ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
|
||||
LEFT JOIN downloads d ON children.id = d.item_id AND d.status = 'completed'
|
||||
WHERE c.server_id = ?
|
||||
AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
|
||||
AND children.item_type IN ('Audio', 'Movie', 'Episode', 'Season')
|
||||
GROUP BY c.id",
|
||||
vec![QueryParam::String(self.server_id.clone())],
|
||||
);
|
||||
let partial_rows: Vec<(String, i64, i64)> = self
|
||||
.db_service
|
||||
.query_many(partial_query, |row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get::<_, Option<i64>>(2)?.unwrap_or(0),
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?;
|
||||
|
||||
let mut partial_containers = std::collections::HashMap::new();
|
||||
for (id, total, downloaded) in partial_rows {
|
||||
// Only record containers that actually have a download (they appear
|
||||
// in the browse); mark partial when some cached child is missing.
|
||||
if downloaded > 0 && downloaded < total {
|
||||
partial_containers.insert(id, true);
|
||||
}
|
||||
}
|
||||
|
||||
let item_count = leaves.len() as u32;
|
||||
let device_total_bytes: i64 = leaves.iter().map(|(_, b)| *b).sum();
|
||||
|
||||
let mut sizes = std::collections::HashMap::new();
|
||||
for (id, bytes) in leaves.into_iter().chain(containers.into_iter()) {
|
||||
// A container id can never collide with a leaf id, so a plain insert
|
||||
// is fine; use entry to be defensive against duplicate rows.
|
||||
*sizes.entry(id).or_insert(0) += bytes;
|
||||
}
|
||||
|
||||
Ok(DownloadDiskUsage {
|
||||
sizes,
|
||||
partial_containers,
|
||||
device_total_bytes,
|
||||
item_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Cache playlist items from server into local database
|
||||
/// Called by HybridRepository after fetching from online
|
||||
pub async fn save_playlist_items_to_cache(
|
||||
@@ -1326,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,
|
||||
@@ -1333,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,
|
||||
@@ -1785,7 +2068,8 @@ mod tests {
|
||||
CREATE TABLE downloads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL
|
||||
status TEXT NOT NULL,
|
||||
file_size INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE libraries (
|
||||
@@ -1826,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()),
|
||||
@@ -1833,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,
|
||||
@@ -2052,6 +2339,8 @@ mod tests {
|
||||
/// downloaded media — not the whole synced catalog. With it on, the full
|
||||
/// (synced-but-not-downloaded) catalog is revealed. Fixes the bug where
|
||||
/// offline library pages showed every server item regardless of the toggle.
|
||||
///
|
||||
/// TRACES: UR-052 | DR-078 | UT-067
|
||||
#[tokio::test]
|
||||
async fn test_get_items_toggle_gates_synced_catalog() {
|
||||
use crate::storage::db_service::DatabaseService;
|
||||
@@ -2289,6 +2578,189 @@ mod tests {
|
||||
repo.save_to_cache("library-1", &items).await.unwrap();
|
||||
}
|
||||
|
||||
/// Insert a fully-formed item row of a given type (bypasses save_to_cache's
|
||||
/// stub-parent machinery so containers/leaves can be linked precisely).
|
||||
async fn insert_item(
|
||||
db: &Arc<RusqliteService>,
|
||||
id: &str,
|
||||
item_type: &str,
|
||||
album_id: Option<&str>,
|
||||
series_id: Option<&str>,
|
||||
season_id: Option<&str>,
|
||||
) {
|
||||
db.execute(Query::with_params(
|
||||
"INSERT INTO items (id, server_id, name, item_type, album_id, series_id, season_id, synced_at)
|
||||
VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, ?6, '2024-01-01')",
|
||||
vec![
|
||||
QueryParam::String(id.to_string()),
|
||||
QueryParam::String(format!("Name {id}")),
|
||||
QueryParam::String(item_type.to_string()),
|
||||
album_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
|
||||
series_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
|
||||
season_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn seed_completed_download(db: &Arc<RusqliteService>, item_id: &str, file_size: i64) {
|
||||
db.execute(Query::with_params(
|
||||
"INSERT INTO downloads (item_id, status, file_size) VALUES (?1, 'completed', ?2)",
|
||||
vec![
|
||||
QueryParam::String(item_id.to_string()),
|
||||
QueryParam::Int64(file_size),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn seed_library(db: &Arc<RusqliteService>, id: &str, collection_type: &str) {
|
||||
db.execute(Query::with_params(
|
||||
"INSERT INTO libraries (id, server_id, name, collection_type, sort_order)
|
||||
VALUES (?1, 'test-server', ?2, ?3, 0)",
|
||||
vec![
|
||||
QueryParam::String(id.to_string()),
|
||||
QueryParam::String(format!("Lib {id}")),
|
||||
QueryParam::String(collection_type.to_string()),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn make_repo(db: &Arc<RusqliteService>) -> OfflineRepository {
|
||||
OfflineRepository::new(
|
||||
db.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// UT: downloaded-only browse returns a downloaded leaf AND its container,
|
||||
/// filtered to the requested album parent. A non-downloaded sibling is omitted.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082, DR-083 | UT-046
|
||||
#[tokio::test]
|
||||
async fn test_get_downloaded_items_returns_leaf_and_container() {
|
||||
let db = create_test_db();
|
||||
insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
|
||||
insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
|
||||
insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
|
||||
// track-1 downloaded; track-2 is NOT downloaded.
|
||||
seed_completed_download(&db, "track-1", 1000).await;
|
||||
|
||||
let repo = make_repo(&db);
|
||||
|
||||
// Browsing the album shows only the downloaded track.
|
||||
let in_album = repo.get_downloaded_items("album-1", None).await.unwrap();
|
||||
let ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["track-1"], "only the downloaded track is listed");
|
||||
}
|
||||
|
||||
/// UT: an empty downloaded-only browse is authoritative — no rows, no error,
|
||||
/// regardless of the catalog-browse flag (which the DR-080 fallthrough uses).
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082 | UT-047
|
||||
#[tokio::test]
|
||||
async fn test_get_downloaded_items_empty_is_authoritative() {
|
||||
let db = create_test_db();
|
||||
insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
|
||||
insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
|
||||
// Nothing downloaded, and the catalog-browse flag is ON (online default).
|
||||
set_include_catalog_browse(true);
|
||||
let repo = make_repo(&db);
|
||||
|
||||
let result = repo.get_downloaded_items("album-1", None).await.unwrap();
|
||||
assert!(
|
||||
result.items.is_empty(),
|
||||
"empty downloaded browse returns no items even with catalog-browse on"
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: only libraries with downloaded content are listed; an empty one is omitted.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082 | UT-048
|
||||
#[tokio::test]
|
||||
async fn test_get_downloaded_libraries_omits_empty() {
|
||||
let db = create_test_db();
|
||||
seed_library(&db, "music-lib", "music").await;
|
||||
seed_library(&db, "movie-lib", "movies").await;
|
||||
insert_item(&db, "track-1", "Audio", None, None, None).await;
|
||||
seed_completed_download(&db, "track-1", 500).await;
|
||||
|
||||
let repo = make_repo(&db);
|
||||
let libs = repo.get_downloaded_libraries().await.unwrap();
|
||||
let ids: Vec<&str> = libs.iter().map(|l| l.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec!["music-lib"],
|
||||
"movie library with no downloads omitted"
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: disk usage reports a leaf's own size, a container's summed descendants,
|
||||
/// and reconciles the device total with the sum of leaves.
|
||||
///
|
||||
/// TRACES: UR-056 | DR-085 | UT-049
|
||||
#[tokio::test]
|
||||
async fn test_download_disk_usage_aggregates_containers() {
|
||||
let db = create_test_db();
|
||||
insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
|
||||
insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
|
||||
insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
|
||||
seed_completed_download(&db, "track-1", 1000).await;
|
||||
seed_completed_download(&db, "track-2", 2000).await;
|
||||
|
||||
let repo = make_repo(&db);
|
||||
let usage = repo.get_download_disk_usage().await.unwrap();
|
||||
|
||||
assert_eq!(usage.item_count, 2, "two leaf downloads");
|
||||
assert_eq!(
|
||||
usage.device_total_bytes, 3000,
|
||||
"device total is the leaf sum"
|
||||
);
|
||||
assert_eq!(usage.sizes.get("track-1"), Some(&1000));
|
||||
assert_eq!(
|
||||
usage.sizes.get("album-1"),
|
||||
Some(&3000),
|
||||
"container = sum of children"
|
||||
);
|
||||
// Both children downloaded ⇒ album is NOT partial.
|
||||
assert_eq!(
|
||||
usage.partial_containers.get("album-1"),
|
||||
None,
|
||||
"fully downloaded album is not partial"
|
||||
);
|
||||
// Device total reconciles with the sum of the listed leaves.
|
||||
let leaf_sum: i64 = ["track-1", "track-2"]
|
||||
.iter()
|
||||
.map(|id| usage.sizes[*id])
|
||||
.sum();
|
||||
assert_eq!(leaf_sum, usage.device_total_bytes);
|
||||
}
|
||||
|
||||
/// UT: a container with a downloaded child AND a non-downloaded cached child
|
||||
/// is flagged partial. TRACES: UR-055 | DR-083 | UT-051
|
||||
#[tokio::test]
|
||||
async fn test_download_disk_usage_flags_partial_container() {
|
||||
let db = create_test_db();
|
||||
insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
|
||||
insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
|
||||
insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
|
||||
// Only track-1 downloaded; track-2 is cached but not downloaded.
|
||||
seed_completed_download(&db, "track-1", 1000).await;
|
||||
|
||||
let repo = make_repo(&db);
|
||||
let usage = repo.get_download_disk_usage().await.unwrap();
|
||||
assert_eq!(
|
||||
usage.partial_containers.get("album-1"),
|
||||
Some(&true),
|
||||
"album with a missing child is partial"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_playlist_create_empty() {
|
||||
let db_service = create_test_db();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<i64>,
|
||||
/// Resume position in milliseconds — the neutral replacement for
|
||||
/// `playback_position_ticks`. Populated from ticks by the mapping; the
|
||||
/// frontend never divides ticks itself.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub playback_position_ms: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_played: Option<bool>,
|
||||
#[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<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub official_rating: Option<String>,
|
||||
/// Legacy Jellyfin duration in ticks (100 ns units). Being replaced by
|
||||
/// `duration_ms`; dual-carried while the frontend migrates
|
||||
/// (docs/specs/frontend-domain-model.md). New code should read `duration_ms`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "runTimeTicks")]
|
||||
pub runtime_ticks: Option<i64>,
|
||||
/// Duration in milliseconds — the neutral replacement for `runtime_ticks`.
|
||||
/// Ticks never reach the frontend; this does.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub duration_ms: Option<i64>,
|
||||
/// Legacy Jellyfin primary image tag. Being replaced by `image_id`;
|
||||
/// dual-carried while the frontend migrates. New code should read `image_id`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub primary_image_tag: Option<String>,
|
||||
/// Neutral image identifier the frontend resolves to a URL via the image
|
||||
/// command — the replacement for `primary_image_tag`. Same value today
|
||||
/// (Jellyfin's tag is the id); the rename removes the provider term.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub backdrop_image_tags: Option<Vec<String>>,
|
||||
#[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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -212,6 +250,28 @@ pub struct SearchResult {
|
||||
pub total_record_count: usize,
|
||||
}
|
||||
|
||||
/// On-disk usage of downloaded content, for the Downloads surface.
|
||||
///
|
||||
/// `sizes` maps an item id (leaf *or* container) to its bytes on disk: a leaf's
|
||||
/// own file size, a container's summed downloaded descendants. `device_total_bytes`
|
||||
/// and `item_count` are the headline figures for the Downloaded surface top bar.
|
||||
///
|
||||
/// TRACES: UR-056 | DR-085
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadDiskUsage {
|
||||
/// item id → bytes on disk (leaf's own size, or a container's subtotal).
|
||||
pub sizes: std::collections::HashMap<String, i64>,
|
||||
/// Container id → true when it is only *partially* downloaded (has cached
|
||||
/// children that are not downloaded). Absent/false ⇒ fully downloaded. Lets
|
||||
/// the Downloaded surface badge partial vs. full containers.
|
||||
pub partial_containers: std::collections::HashMap<String, bool>,
|
||||
/// Sum of all downloaded leaf sizes — the device total.
|
||||
pub device_total_bytes: i64,
|
||||
/// Number of downloaded leaf items (not containers).
|
||||
pub item_count: u32,
|
||||
}
|
||||
|
||||
/// Options for querying items
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -503,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,
|
||||
@@ -514,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,
|
||||
@@ -653,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,
|
||||
@@ -664,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,
|
||||
@@ -714,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,
|
||||
@@ -725,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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jellytau",
|
||||
"version": "0.0.16",
|
||||
"version": "0.0.18",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
+304
-19
@@ -670,15 +670,15 @@ async storageDeleteUser(userId: string) : Promise<null> {
|
||||
* 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<null> {
|
||||
return await TAURI_INVOKE("storage_update_playback_progress", { userId, itemId, positionTicks });
|
||||
async storageUpdatePlaybackProgress(userId: string, itemId: string, positionMs: number) : Promise<null> {
|
||||
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<null> {
|
||||
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<null> {
|
||||
return await TAURI_INVOKE("storage_update_playback_context", { userId, itemId, positionMs, contextType, contextId });
|
||||
},
|
||||
/**
|
||||
* Mark item as played in local database
|
||||
@@ -784,6 +784,19 @@ async deleteAllDownloads(userId: string) : Promise<number> {
|
||||
async deleteAlbumDownloads(albumId: string, userId: string) : Promise<number> {
|
||||
return await TAURI_INVOKE("delete_album_downloads", { albumId, userId });
|
||||
},
|
||||
/**
|
||||
* Remove every completed download at or under a container item.
|
||||
*
|
||||
* Works at any level of the Downloaded browse: a leaf (removes just that
|
||||
* download), an album/season/series (removes all downloaded descendants linked
|
||||
* via album_id/season_id/series_id/parent_id). Deletes the DB rows and the
|
||||
* on-disk files. Returns the number of downloads removed. Idempotent.
|
||||
*
|
||||
* TRACES: UR-055 | DR-083
|
||||
*/
|
||||
async deleteDownloadsUnder(itemId: string, userId: string) : Promise<number> {
|
||||
return await TAURI_INVOKE("delete_downloads_under", { itemId, userId });
|
||||
},
|
||||
/**
|
||||
* Clear all stale pending/failed/paused downloads
|
||||
*/
|
||||
@@ -915,6 +928,29 @@ async updateSmartCacheConfig(config: CacheConfig) : Promise<null> {
|
||||
async getSmartCacheConfig() : Promise<CacheConfig> {
|
||||
return await TAURI_INVOKE("get_smart_cache_config");
|
||||
},
|
||||
/**
|
||||
* Report the device's current network transport (Android → Rust).
|
||||
*
|
||||
* The frontend calls this on startup and whenever the native network callback
|
||||
* fires. Updating to an acceptable network re-pumps the download queue, so a
|
||||
* queue parked on "waiting for WiFi" drains itself without user action.
|
||||
*
|
||||
* TRACES: UR-053 | DR-074
|
||||
*/
|
||||
async setNetworkState(network: NetworkStateWrapperArg) : Promise<null> {
|
||||
return await TAURI_INVOKE("set_network_state", { network });
|
||||
},
|
||||
/**
|
||||
* Whether downloads are currently permitted by the WiFi-only gate.
|
||||
*
|
||||
* The downloads UI uses this to render "Waiting for WiFi" on pending rows
|
||||
* rather than leaving them looking silently stuck.
|
||||
*
|
||||
* TRACES: UR-053 | DR-074
|
||||
*/
|
||||
async getDownloadsAllowed() : Promise<boolean> {
|
||||
return await TAURI_INVOKE("get_downloads_allowed");
|
||||
},
|
||||
/**
|
||||
* Get album recommendations based on play history
|
||||
*/
|
||||
@@ -1166,6 +1202,33 @@ async repositoryGetItems(handle: string, parentId: string, options: GetItemsOpti
|
||||
async repositoryGetItem(handle: string, itemId: string) : Promise<MediaItem> {
|
||||
return await TAURI_INVOKE("repository_get_item", { handle, itemId });
|
||||
},
|
||||
/**
|
||||
* Downloaded-only browse: libraries that contain downloaded content.
|
||||
*
|
||||
* Backs the Downloads "Downloaded" surface. Never merges server results and is
|
||||
* authoritative — an empty list means nothing is downloaded.
|
||||
*
|
||||
* TRACES: UR-055 | DR-082
|
||||
*/
|
||||
async repositoryGetDownloadedLibraries(handle: string) : Promise<Library[]> {
|
||||
return await TAURI_INVOKE("repository_get_downloaded_libraries", { handle });
|
||||
},
|
||||
/**
|
||||
* Downloaded-only browse: items under a container that are on the device.
|
||||
*
|
||||
* TRACES: UR-055 | DR-082, DR-083
|
||||
*/
|
||||
async repositoryGetDownloadedItems(handle: string, parentId: string, options: GetItemsOptions | null) : Promise<SearchResult> {
|
||||
return await TAURI_INVOKE("repository_get_downloaded_items", { handle, parentId, options });
|
||||
},
|
||||
/**
|
||||
* On-disk usage of downloaded content (device total, per-item/container bytes).
|
||||
*
|
||||
* TRACES: UR-056 | DR-085
|
||||
*/
|
||||
async repositoryGetDownloadDiskUsage(handle: string) : Promise<DownloadDiskUsage> {
|
||||
return await TAURI_INVOKE("repository_get_download_disk_usage", { handle });
|
||||
},
|
||||
/**
|
||||
* Query the optional JRay plugin for the actors on screen at time `t`
|
||||
* (seconds) in an item. Returns an empty list when JRay isn't installed or
|
||||
@@ -1269,20 +1332,20 @@ async repositoryOpenLiveStream(handle: string, itemId: string) : Promise<LiveStr
|
||||
/**
|
||||
* Report playback start
|
||||
*/
|
||||
async repositoryReportPlaybackStart(handle: string, itemId: string, positionTicks: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_report_playback_start", { handle, itemId, positionTicks });
|
||||
async repositoryReportPlaybackStart(handle: string, itemId: string, positionMs: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_report_playback_start", { handle, itemId, positionMs });
|
||||
},
|
||||
/**
|
||||
* Report playback progress
|
||||
*/
|
||||
async repositoryReportPlaybackProgress(handle: string, itemId: string, positionTicks: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_report_playback_progress", { handle, itemId, positionTicks });
|
||||
async repositoryReportPlaybackProgress(handle: string, itemId: string, positionMs: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_report_playback_progress", { handle, itemId, positionMs });
|
||||
},
|
||||
/**
|
||||
* Report playback stopped
|
||||
*/
|
||||
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionTicks: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionTicks });
|
||||
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionMs: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionMs });
|
||||
},
|
||||
/**
|
||||
* Get image URL for an item
|
||||
@@ -1626,6 +1689,34 @@ connectionError: string | null;
|
||||
* Whether we're currently checking connectivity
|
||||
*/
|
||||
isChecking: boolean }
|
||||
/**
|
||||
* On-disk usage of downloaded content, for the Downloads surface.
|
||||
*
|
||||
* `sizes` maps an item id (leaf *or* container) to its bytes on disk: a leaf's
|
||||
* own file size, a container's summed downloaded descendants. `device_total_bytes`
|
||||
* and `item_count` are the headline figures for the Downloaded surface top bar.
|
||||
*
|
||||
* TRACES: UR-056 | DR-085
|
||||
*/
|
||||
export type DownloadDiskUsage = {
|
||||
/**
|
||||
* item id → bytes on disk (leaf's own size, or a container's subtotal).
|
||||
*/
|
||||
sizes: Partial<{ [key in string]: number }>;
|
||||
/**
|
||||
* Container id → true when it is only *partially* downloaded (has cached
|
||||
* children that are not downloaded). Absent/false ⇒ fully downloaded. Lets
|
||||
* the Downloaded surface badge partial vs. full containers.
|
||||
*/
|
||||
partialContainers: Partial<{ [key in string]: boolean }>;
|
||||
/**
|
||||
* Sum of all downloaded leaf sizes — the device total.
|
||||
*/
|
||||
deviceTotalBytes: number;
|
||||
/**
|
||||
* Number of downloaded leaf items (not containers).
|
||||
*/
|
||||
itemCount: number }
|
||||
/**
|
||||
* Information about a download
|
||||
*/
|
||||
@@ -1711,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.
|
||||
@@ -1721,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
|
||||
*/
|
||||
@@ -1750,13 +1912,65 @@ 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`].
|
||||
*
|
||||
* TRACES: UR-053 | DR-074
|
||||
*/
|
||||
export type NetworkStateWrapperArg = { networkType: NetworkType; unmetered: boolean }
|
||||
/**
|
||||
* Kind of network transport currently active.
|
||||
*
|
||||
* Mirrors the string constants in `NetworkTypeMonitor.kt`; the two must stay
|
||||
* in sync (the serde rename below is what the frontend sends).
|
||||
*
|
||||
* TRACES: UR-053 | DR-074
|
||||
*/
|
||||
export type NetworkType =
|
||||
/**
|
||||
* No active network.
|
||||
*/
|
||||
"none" |
|
||||
/**
|
||||
* WiFi (may still be metered — check `unmetered`).
|
||||
*/
|
||||
"wifi" |
|
||||
/**
|
||||
* Wired ethernet, typical on Android TV and desktop.
|
||||
*/
|
||||
"ethernet" |
|
||||
/**
|
||||
* Mobile data — never acceptable when wifi-only is enabled.
|
||||
*/
|
||||
"cellular" |
|
||||
/**
|
||||
* Some other transport (VPN over unknown carrier, Bluetooth tethering, …).
|
||||
*/
|
||||
"other" |
|
||||
/**
|
||||
* Could not determine the transport.
|
||||
*/
|
||||
"unknown"
|
||||
export type NowPlayingItem = { id: string | null; name: string | null; runTimeTicks: number | null; album: string | null; albumId: string | null; albumArtist: string | null; artists: string[] | null; imageTags: Partial<{ [key in string]: string }> | null; primaryImageTag: string | null; albumPrimaryImageTag: string | null; Type: string | null }
|
||||
export type OfflineItem = { id: string; name: string; itemType: string; albumId: string | null; albumName: string | null; artists: string | null; runtimeTicks: number | null; primaryImageTag: string | null }
|
||||
/**
|
||||
@@ -1865,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
|
||||
*
|
||||
@@ -1909,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.)
|
||||
*/
|
||||
@@ -2146,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.
|
||||
@@ -2156,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)
|
||||
*/
|
||||
@@ -2261,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
|
||||
*/
|
||||
@@ -2300,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
|
||||
*/
|
||||
|
||||
@@ -337,6 +337,46 @@ describe("RepositoryClient", () => {
|
||||
requestId: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Downloaded-only browse path (UR-055 | DR-082) — verifies command names and
|
||||
// camelCase params per the Tauri v2 rule (CLAUDE.md).
|
||||
it("should get downloaded libraries from backend", async () => {
|
||||
const mockLibraries = [{ id: "lib1", name: "Music", collectionType: "music" }];
|
||||
(invoke as any).mockResolvedValueOnce(mockLibraries);
|
||||
|
||||
const libraries = await client.getDownloadedLibraries();
|
||||
|
||||
expect(libraries).toEqual(mockLibraries);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_downloaded_libraries", {
|
||||
handle: "test-handle-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should get downloaded items with camelCase params", async () => {
|
||||
const mockResult = { items: [{ id: "t1", name: "Track", type: "Audio" }], totalRecordCount: 1 };
|
||||
(invoke as any).mockResolvedValueOnce(mockResult);
|
||||
|
||||
const result = await client.getDownloadedItems("album1", { limit: 50 });
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_downloaded_items", {
|
||||
handle: "test-handle-123",
|
||||
parentId: "album1",
|
||||
options: { limit: 50 },
|
||||
});
|
||||
});
|
||||
|
||||
it("should get download disk usage from backend", async () => {
|
||||
const mockUsage = { sizes: { t1: 1000 }, partialContainers: {}, deviceTotalBytes: 1000, itemCount: 1 };
|
||||
(invoke as any).mockResolvedValueOnce(mockUsage);
|
||||
|
||||
const usage = await client.getDownloadDiskUsage();
|
||||
|
||||
expect(usage).toEqual(mockUsage);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_download_disk_usage", {
|
||||
handle: "test-handle-123",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Playback Methods", () => {
|
||||
@@ -430,7 +470,7 @@ describe("RepositoryClient", () => {
|
||||
expect(invoke).toHaveBeenCalledWith("repository_report_playback_progress", {
|
||||
handle: "test-handle-123",
|
||||
itemId: "item123",
|
||||
positionTicks: 5000000,
|
||||
positionMs: 5000000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// NO direct HTTP calls - everything routes through Rust backend
|
||||
|
||||
import { commands } from "./bindings";
|
||||
import type { JRayActor } from "./bindings";
|
||||
import type { JRayActor, DownloadDiskUsage } from "./bindings";
|
||||
import type { QualityPreset } from "./quality-presets";
|
||||
import type {
|
||||
Library,
|
||||
@@ -91,6 +91,31 @@ export class RepositoryClient {
|
||||
return commands.repositoryGetItem(this.ensureHandle(), itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloaded-only browse: libraries that contain downloaded content.
|
||||
* Never merges server results; an empty list is authoritative.
|
||||
* TRACES: UR-055 | DR-082
|
||||
*/
|
||||
async getDownloadedLibraries(): Promise<Library[]> {
|
||||
return commands.repositoryGetDownloadedLibraries(this.ensureHandle());
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloaded-only browse: items under a container that are on the device.
|
||||
* TRACES: UR-055 | DR-082, DR-083
|
||||
*/
|
||||
async getDownloadedItems(parentId: string, options?: GetItemsOptions): Promise<SearchResult> {
|
||||
return commands.repositoryGetDownloadedItems(this.ensureHandle(), parentId, options ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* On-disk usage of downloaded content (device total + per-item/container bytes).
|
||||
* TRACES: UR-056 | DR-085
|
||||
*/
|
||||
async getDownloadDiskUsage(): Promise<DownloadDiskUsage> {
|
||||
return commands.repositoryGetDownloadDiskUsage(this.ensureHandle());
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the optional JRay plugin for the actors on screen at time `t`
|
||||
* (seconds) in an item. Resolves to an empty array when JRay isn't installed
|
||||
@@ -139,16 +164,16 @@ export class RepositoryClient {
|
||||
return commands.repositoryGetPlaybackInfo(this.ensureHandle(), itemId);
|
||||
}
|
||||
|
||||
async reportPlaybackStart(itemId: string, positionTicks: number): Promise<void> {
|
||||
await commands.repositoryReportPlaybackStart(this.ensureHandle(), itemId, positionTicks);
|
||||
async reportPlaybackStart(itemId: string, positionMs: number): Promise<void> {
|
||||
await commands.repositoryReportPlaybackStart(this.ensureHandle(), itemId, positionMs);
|
||||
}
|
||||
|
||||
async reportPlaybackProgress(itemId: string, positionTicks: number): Promise<void> {
|
||||
await commands.repositoryReportPlaybackProgress(this.ensureHandle(), itemId, positionTicks);
|
||||
async reportPlaybackProgress(itemId: string, positionMs: number): Promise<void> {
|
||||
await commands.repositoryReportPlaybackProgress(this.ensureHandle(), itemId, positionMs);
|
||||
}
|
||||
|
||||
async reportPlaybackStopped(itemId: string, positionTicks: number): Promise<void> {
|
||||
await commands.repositoryReportPlaybackStopped(this.ensureHandle(), itemId, positionTicks);
|
||||
async reportPlaybackStopped(itemId: string, positionMs: number): Promise<void> {
|
||||
await commands.repositoryReportPlaybackStopped(this.ensureHandle(), itemId, positionMs);
|
||||
}
|
||||
|
||||
// ===== Stream URL Methods (via Rust) =====
|
||||
|
||||
@@ -14,6 +14,7 @@ export type {
|
||||
Library,
|
||||
LiveStreamInfo,
|
||||
MediaItem,
|
||||
MediaKind,
|
||||
MediaSource,
|
||||
MediaStream,
|
||||
Person,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<!--
|
||||
Shared application header. Lifted out of the library layout so the account
|
||||
menu (and desktop nav) are available on every authenticated, non-immersive
|
||||
screen, not only under /library. Routes that need in-header search (the
|
||||
library layout) pass it in via the `search` snippet; other routes omit it.
|
||||
|
||||
TRACES: UR-054 | DR-076
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import AccountMenu from "$lib/components/account/AccountMenu.svelte";
|
||||
|
||||
let { search }: { search?: Snippet } = $props();
|
||||
|
||||
const pathname = $derived($page.url.pathname);
|
||||
</script>
|
||||
|
||||
<header class="sticky top-0 z-50 bg-[var(--color-background)]/95 backdrop-blur border-b border-gray-800 flex-shrink-0">
|
||||
<div class="px-4 py-3 flex items-center gap-4">
|
||||
<!-- Logo -->
|
||||
<a href="/library" class="text-xl font-bold text-[var(--color-jellyfin)]">
|
||||
JellyTau
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<nav class="hidden md:flex items-center gap-1">
|
||||
<a
|
||||
href="/"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname === '/' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
||||
>
|
||||
Home
|
||||
</a>
|
||||
<a
|
||||
href="/library"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname.startsWith('/library') ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
||||
>
|
||||
Library
|
||||
</a>
|
||||
<a
|
||||
href="/downloads"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname === '/downloads' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
||||
>
|
||||
Downloads
|
||||
</a>
|
||||
<a
|
||||
href="/settings"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname === '/settings' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- Optional in-header search (library layout supplies it). -->
|
||||
{#if search}
|
||||
<div class="flex-1 max-w-md hidden md:block space-y-2">
|
||||
{@render search()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Account menu, anchored to the user's identity. -->
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<!-- Desktop: Downloads quick icon (kept per UX spec §1.2). -->
|
||||
<a
|
||||
href="/downloads"
|
||||
class="hidden md:block text-gray-400 hover:text-white transition-colors"
|
||||
title="Downloads"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<AccountMenu />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -1,91 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
type?: "card" | "text" | "circle" | "banner" | "row";
|
||||
count?: number;
|
||||
width?: string;
|
||||
height?: string;
|
||||
aspectRatio?: "square" | "video" | "portrait";
|
||||
}
|
||||
|
||||
let {
|
||||
type = "card",
|
||||
count = 1,
|
||||
width = "100%",
|
||||
height = "auto",
|
||||
aspectRatio = "square",
|
||||
}: Props = $props();
|
||||
|
||||
const aspectClasses = {
|
||||
square: "aspect-square",
|
||||
video: "aspect-video",
|
||||
portrait: "aspect-[2/3]",
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if type === "card"}
|
||||
<div class="flex gap-4 overflow-hidden">
|
||||
{#each Array(count) as _, i (i)}
|
||||
<div class="flex-shrink-0 w-36 animate-pulse">
|
||||
<div class="w-full {aspectClasses[aspectRatio]} bg-[var(--color-surface)] rounded-lg shimmer"></div>
|
||||
<div class="mt-2 space-y-2">
|
||||
<div class="h-4 bg-[var(--color-surface)] rounded shimmer" style="width: 80%"></div>
|
||||
<div class="h-3 bg-[var(--color-surface)] rounded shimmer" style="width: 60%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if type === "banner"}
|
||||
<div class="animate-pulse">
|
||||
<div class="h-[500px] bg-[var(--color-surface)] rounded-xl shimmer"></div>
|
||||
</div>
|
||||
{:else if type === "circle"}
|
||||
<div class="flex gap-4">
|
||||
{#each Array(count) as _, i (i)}
|
||||
<div class="flex flex-col items-center animate-pulse">
|
||||
<div class="w-20 h-20 rounded-full bg-[var(--color-surface)] shimmer"></div>
|
||||
<div class="mt-2 h-3 w-16 bg-[var(--color-surface)] rounded shimmer"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if type === "row"}
|
||||
<div class="space-y-4">
|
||||
{#each Array(count) as _, i (i)}
|
||||
<div class="flex gap-4 animate-pulse">
|
||||
<div class="w-16 h-16 rounded bg-[var(--color-surface)] shimmer flex-shrink-0"></div>
|
||||
<div class="flex-1 space-y-2 py-2">
|
||||
<div class="h-4 bg-[var(--color-surface)] rounded shimmer" style="width: 70%"></div>
|
||||
<div class="h-3 bg-[var(--color-surface)] rounded shimmer" style="width: 50%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if type === "text"}
|
||||
<div class="space-y-2 animate-pulse">
|
||||
{#each Array(count) as _, i (i)}
|
||||
<div class="h-4 bg-[var(--color-surface)] rounded shimmer" style="width: {width}; height: {height}"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
animation: shimmer 2s infinite linear;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--color-surface) 0%,
|
||||
rgba(255, 255, 255, 0.05) 20%,
|
||||
var(--color-surface) 40%,
|
||||
var(--color-surface) 100%
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<!--
|
||||
Shared account menu — one component for both breakpoints. Anchored to the
|
||||
user's name/avatar, it groups the account-level destinations (Downloads,
|
||||
Settings, Display) and Sign out. Available on every authenticated,
|
||||
non-immersive screen via the shared AppHeader.
|
||||
|
||||
TRACES: UR-054 | DR-075
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { auth, currentUser, serverName, serverUrl } from "$lib/stores/auth";
|
||||
import { library } from "$lib/stores/library";
|
||||
|
||||
let open = $state(false);
|
||||
let triggerEl = $state<HTMLButtonElement | null>(null);
|
||||
|
||||
// Prefer the human server name; fall back to the bare host of the URL so the
|
||||
// identity block always shows *something* server-identifying.
|
||||
const serverHost = $derived.by(() => {
|
||||
if ($serverName) return $serverName;
|
||||
if (!$serverUrl) return "";
|
||||
try {
|
||||
return new URL($serverUrl).host;
|
||||
} catch {
|
||||
return $serverUrl;
|
||||
}
|
||||
});
|
||||
|
||||
const displayName = $derived($currentUser?.name ?? "Account");
|
||||
const initial = $derived((displayName[0] ?? "?").toUpperCase());
|
||||
|
||||
async function close(returnFocus = true) {
|
||||
open = false;
|
||||
if (returnFocus) {
|
||||
await tick();
|
||||
triggerEl?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
open = !open;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && open) {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await close(false);
|
||||
await auth.logout();
|
||||
library.reset();
|
||||
goto("/");
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
bind:this={triggerEl}
|
||||
onclick={toggle}
|
||||
class="flex items-center gap-2 rounded-full py-1 pl-1 pr-1 md:pr-3 text-gray-300 hover:text-white hover:bg-[var(--color-surface)] transition-colors"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label="Account menu"
|
||||
>
|
||||
<span
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--color-jellyfin)] text-sm font-semibold text-white"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
<span class="hidden md:inline text-sm">{displayName}</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<!-- Backdrop closes the menu on any outside click. -->
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onclick={() => close()}
|
||||
onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") close(); }}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label="Close account menu"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="absolute right-0 top-full mt-2 w-56 bg-[var(--color-surface)] rounded-lg shadow-lg border border-gray-700 py-1 z-50"
|
||||
role="menu"
|
||||
>
|
||||
<!-- Identity block — not interactive. -->
|
||||
<div class="px-4 py-3">
|
||||
<p class="text-xs text-gray-500">Signed in as</p>
|
||||
<p class="text-sm font-semibold text-white truncate">{displayName}</p>
|
||||
{#if serverHost}
|
||||
<p class="text-xs text-gray-400 truncate">{serverHost}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-700 my-1"></div>
|
||||
|
||||
<a
|
||||
href="/downloads"
|
||||
role="menuitem"
|
||||
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
|
||||
onclick={() => close(false)}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Downloads
|
||||
</a>
|
||||
<a
|
||||
href="/settings"
|
||||
role="menuitem"
|
||||
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
|
||||
onclick={() => close(false)}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Settings
|
||||
</a>
|
||||
<a
|
||||
href="/settings#display"
|
||||
role="menuitem"
|
||||
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
|
||||
onclick={() => close(false)}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v10a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM8 20h8" />
|
||||
</svg>
|
||||
Display
|
||||
</a>
|
||||
|
||||
<div class="border-t border-gray-700 my-1"></div>
|
||||
|
||||
<button
|
||||
onclick={handleLogout}
|
||||
role="menuitem"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/svelte";
|
||||
|
||||
// Controllable store shims + spies, declared via vi.hoisted so they exist when
|
||||
// the hoisted vi.mock factories run. A tiny writable shim avoids importing
|
||||
// svelte inside the hoisted block.
|
||||
const h = vi.hoisted(() => {
|
||||
function shim<T>(initial: T) {
|
||||
let value = initial;
|
||||
const subs = new Set<(v: T) => void>();
|
||||
return {
|
||||
set(v: T) {
|
||||
value = v;
|
||||
subs.forEach((fn) => fn(value));
|
||||
},
|
||||
subscribe(fn: (v: T) => void) {
|
||||
subs.add(fn);
|
||||
fn(value);
|
||||
return () => subs.delete(fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
currentUserStore: shim<{ name: string } | null>({ name: "Ada" }),
|
||||
serverNameStore: shim<string | null>("Home Server"),
|
||||
serverUrlStore: shim<string | null>("https://media.example.com"),
|
||||
logout: vi.fn(async () => {}),
|
||||
reset: vi.fn(),
|
||||
goto: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
auth: { logout: h.logout },
|
||||
currentUser: { subscribe: h.currentUserStore.subscribe },
|
||||
serverName: { subscribe: h.serverNameStore.subscribe },
|
||||
serverUrl: { subscribe: h.serverUrlStore.subscribe },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/library", () => ({
|
||||
library: { reset: h.reset },
|
||||
}));
|
||||
|
||||
vi.mock("$app/navigation", () => ({ goto: h.goto }));
|
||||
|
||||
import AccountMenu from "./AccountMenu.svelte";
|
||||
|
||||
function openMenu() {
|
||||
const trigger = screen.getByRole("button", { name: "Account menu" });
|
||||
fireEvent.click(trigger);
|
||||
return trigger;
|
||||
}
|
||||
|
||||
describe("AccountMenu", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
h.currentUserStore.set({ name: "Ada" });
|
||||
h.serverNameStore.set("Home Server");
|
||||
h.serverUrlStore.set("https://media.example.com");
|
||||
});
|
||||
|
||||
it("trigger toggles aria-expanded", async () => {
|
||||
render(AccountMenu);
|
||||
const trigger = screen.getByRole("button", { name: "Account menu" });
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
await fireEvent.click(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
await fireEvent.click(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("renders the documented items in order", async () => {
|
||||
render(AccountMenu);
|
||||
openMenu();
|
||||
const items = screen.getAllByRole("menuitem").map((el) => el.textContent?.trim());
|
||||
expect(items).toEqual(["Downloads", "Settings", "Display", "Sign out"]);
|
||||
});
|
||||
|
||||
it("shows the identity block with name and server host", async () => {
|
||||
render(AccountMenu);
|
||||
openMenu();
|
||||
expect(screen.getByText("Signed in as")).toBeTruthy();
|
||||
// "Ada" appears in both the trigger label and the identity block.
|
||||
expect(screen.getAllByText("Ada").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("Home Server")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to the URL host when no server name is set", async () => {
|
||||
h.serverNameStore.set(null);
|
||||
render(AccountMenu);
|
||||
openMenu();
|
||||
expect(screen.getByText("media.example.com")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Escape closes the menu", async () => {
|
||||
render(AccountMenu);
|
||||
const trigger = openMenu();
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
await fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("backdrop click closes the menu", async () => {
|
||||
render(AccountMenu);
|
||||
const trigger = openMenu();
|
||||
const backdrop = screen.getByRole("button", { name: "Close account menu" });
|
||||
await fireEvent.click(backdrop);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("Sign out logs out, resets library state, and redirects home", async () => {
|
||||
render(AccountMenu);
|
||||
openMenu();
|
||||
const signOut = screen.getByRole("menuitem", { name: "Sign out" });
|
||||
await fireEvent.click(signOut);
|
||||
expect(h.logout).toHaveBeenCalledOnce();
|
||||
expect(h.reset).toHaveBeenCalledOnce();
|
||||
expect(h.goto).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
it("Sign out is the last item, after the routine navigation", async () => {
|
||||
render(AccountMenu);
|
||||
openMenu();
|
||||
const items = screen.getAllByRole("menuitem").map((el) => el.textContent?.trim());
|
||||
expect(items[items.length - 1]).toBe("Sign out");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
<!--
|
||||
Downloaded browse surface: the library, filtered to what's on the device.
|
||||
|
||||
Reuses the library's own grid/cards. The top level lists only libraries with
|
||||
downloaded content; drilling into a library shows its downloaded items in the
|
||||
same grid used online. Clicking a leaf/detail item navigates to the shared
|
||||
`/library/[id]` detail page, where Play uses the local file. Per-item and
|
||||
device disk usage ride along via the size labels and the top bar.
|
||||
|
||||
TRACES: UR-055, UR-056 | DR-081, DR-082, DR-083, DR-085
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import type { Library, MediaItem } from "$lib/api/types";
|
||||
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
|
||||
import { formatBytes } from "$lib/utils/formatBytes";
|
||||
import {
|
||||
downloadedCatalog,
|
||||
downloadedLibraries,
|
||||
downloadedDeviceTotal,
|
||||
downloadedItemCount,
|
||||
} from "$lib/services/downloadedCatalog";
|
||||
|
||||
// Drill state: null = library list; otherwise the library we're inside.
|
||||
let currentLibrary = $state<Library | null>(null);
|
||||
let items = $state<MediaItem[]>([]);
|
||||
let loadingItems = $state(false);
|
||||
let loadError = $state<string | null>(null);
|
||||
|
||||
const loading = $derived($downloadedCatalog.loading);
|
||||
|
||||
onMount(() => {
|
||||
void downloadedCatalog.refresh();
|
||||
});
|
||||
|
||||
async function openLibrary(library: Library) {
|
||||
currentLibrary = library;
|
||||
loadingItems = true;
|
||||
loadError = null;
|
||||
try {
|
||||
items = await downloadedCatalog.loadItems(library.id);
|
||||
} catch (err) {
|
||||
loadError = err instanceof Error ? err.message : "Failed to load downloads";
|
||||
items = [];
|
||||
} finally {
|
||||
loadingItems = false;
|
||||
}
|
||||
}
|
||||
|
||||
function backToLibraries() {
|
||||
currentLibrary = null;
|
||||
items = [];
|
||||
loadError = null;
|
||||
}
|
||||
|
||||
// Containers (album/season/series/box set) drill via the shared detail page,
|
||||
// which is offline-aware; leaves open their detail/play surface there too.
|
||||
function onItemClick(item: MediaItem | Library) {
|
||||
if ("collectionType" in item) {
|
||||
// A Library (top level) — drill in place.
|
||||
void openLibrary(item as Library);
|
||||
return;
|
||||
}
|
||||
goto(`/library/${item.id}`);
|
||||
}
|
||||
|
||||
// A size label for a card, if we have a byte figure for it.
|
||||
function sizeLabelFor(item: MediaItem | Library): string | undefined {
|
||||
const bytes = $downloadedCatalog.sizes[item.id];
|
||||
return bytes && bytes > 0 ? formatBytes(bytes) : undefined;
|
||||
}
|
||||
|
||||
// Remove a downloaded item/container, stating the reclaim amount first.
|
||||
async function removeItem(item: MediaItem | Library) {
|
||||
if (!("type" in item)) return;
|
||||
const bytes = $downloadedCatalog.sizes[item.id] ?? 0;
|
||||
const freed = bytes > 0 ? ` This frees ${formatBytes(bytes)}.` : "";
|
||||
if (!confirm(`Remove “${item.name}” from this device?${freed}`)) return;
|
||||
try {
|
||||
await downloadedCatalog.remove(item.id);
|
||||
// Reload the current library so removed items (and now-empty containers)
|
||||
// drop out of the browse.
|
||||
if (currentLibrary) {
|
||||
items = await downloadedCatalog.loadItems(currentLibrary.id);
|
||||
}
|
||||
} catch (err) {
|
||||
loadError = err instanceof Error ? err.message : "Failed to remove download";
|
||||
}
|
||||
}
|
||||
|
||||
// Full vs partial container badge (leaves get no container badge here).
|
||||
function downloadedBadgeFor(item: MediaItem | Library): "full" | "partial" | undefined {
|
||||
if (!("type" in item)) return undefined;
|
||||
const isContainer = ["MusicAlbum", "Series", "Season", "BoxSet"].includes(item.type);
|
||||
if (!isContainer) return undefined;
|
||||
return $downloadedCatalog.partialContainers[item.id] ? "partial" : "full";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-5">
|
||||
<!-- Device total: the headline figure, reconciles with the listed sum. -->
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-gray-700 bg-[var(--color-surface)] px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="h-5 w-5 text-[var(--color-jellyfin)]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10a2 2 0 002 2h12a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H6a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<p class="text-sm text-gray-200">
|
||||
<span class="font-semibold text-white">{formatBytes($downloadedDeviceTotal)}</span>
|
||||
on device
|
||||
<span class="text-gray-500">·</span>
|
||||
{$downloadedItemCount}
|
||||
{$downloadedItemCount === 1 ? "item" : "items"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if currentLibrary}
|
||||
<!-- Inside a library: breadcrumb back to the library list. -->
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<button
|
||||
onclick={backToLibraries}
|
||||
class="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
Downloaded
|
||||
</button>
|
||||
<span class="text-gray-600">/</span>
|
||||
<span class="text-white font-medium">{currentLibrary.name}</span>
|
||||
</div>
|
||||
|
||||
{#if loadError}
|
||||
<p class="text-sm text-red-400">{loadError}</p>
|
||||
{/if}
|
||||
|
||||
<LibraryGrid
|
||||
items={items.map((i) => i)}
|
||||
loading={loadingItems}
|
||||
showViewToggle={true}
|
||||
musicContent={currentLibrary.collectionType === "music"}
|
||||
{sizeLabelFor}
|
||||
{downloadedBadgeFor}
|
||||
onItemRemove={removeItem}
|
||||
{onItemClick}
|
||||
/>
|
||||
{#if !loadingItems && items.length === 0 && !loadError}
|
||||
<p class="text-center py-8 text-gray-500 text-sm">Nothing downloaded in this library.</p>
|
||||
{/if}
|
||||
{:else if loading}
|
||||
<p class="text-center py-12 text-gray-400">Loading your downloads…</p>
|
||||
{:else if $downloadedLibraries.length === 0}
|
||||
<!-- Empty Downloaded state: authoritative "nothing downloaded", not a server miss. -->
|
||||
<div class="rounded-lg border border-gray-700 bg-[var(--color-surface)] p-10 text-center">
|
||||
<svg class="mx-auto mb-4 h-14 w-14 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10" />
|
||||
</svg>
|
||||
<p class="text-lg font-medium text-gray-300">Nothing downloaded yet</p>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
Browse your library and tap download to save media for offline.
|
||||
</p>
|
||||
<button
|
||||
onclick={() => goto("/library")}
|
||||
class="mt-5 rounded-lg bg-[var(--color-jellyfin)] px-4 py-2 text-sm font-medium text-white hover:opacity-90 transition"
|
||||
>
|
||||
Go to library
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Library list — only libraries with downloaded content. -->
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{#each $downloadedLibraries as lib (lib.id)}
|
||||
<button
|
||||
onclick={() => openLibrary(lib)}
|
||||
class="group/card flex flex-col text-left transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
<div class="relative aspect-video w-full overflow-hidden rounded-lg bg-[var(--color-surface)] shadow-md flex items-center justify-center">
|
||||
<svg class="h-10 w-10 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-7l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mt-2 truncate text-sm font-medium text-white group-hover/card:text-[var(--color-jellyfin)] transition-colors">
|
||||
{lib.name}
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,232 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
interface AlbumStorageInfo {
|
||||
album_id: string;
|
||||
album_name: string;
|
||||
artist_name: string | null;
|
||||
bytes_used: number;
|
||||
track_count: number;
|
||||
}
|
||||
|
||||
interface StorageStats {
|
||||
total_bytes: number;
|
||||
total_items: number;
|
||||
albums: AlbumStorageInfo[];
|
||||
}
|
||||
|
||||
let stats = $state<StorageStats | null>(null);
|
||||
let loading = $state(true);
|
||||
let deleting = $state(false);
|
||||
let deletingAlbum = $state<string | null>(null);
|
||||
let showDeleteAllConfirm = $state(false);
|
||||
let showBreakdown = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
loadStats();
|
||||
});
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
loading = true;
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
stats = await commands.getDownloadStorageStats(userId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load storage stats:", error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
async function deleteAllDownloads() {
|
||||
try {
|
||||
deleting = true;
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
await commands.deleteAllDownloads(userId);
|
||||
await downloads.refresh(userId);
|
||||
await loadStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete all downloads:", error);
|
||||
} finally {
|
||||
deleting = false;
|
||||
showDeleteAllConfirm = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAlbumDownloads(albumId: string) {
|
||||
try {
|
||||
deletingAlbum = albumId;
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
await commands.deleteAlbumDownloads(albumId, userId);
|
||||
await downloads.refresh(userId);
|
||||
await loadStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete album downloads:", error);
|
||||
} finally {
|
||||
deletingAlbum = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAlbumClick(albumId: string) {
|
||||
if (albumId !== "unknown") {
|
||||
goto(`/library/${albumId}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-[var(--color-surface)] rounded-xl p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Storage</h2>
|
||||
{#if stats && stats.total_items > 0}
|
||||
<button
|
||||
onclick={() => (showDeleteAllConfirm = true)}
|
||||
class="px-4 py-2 text-sm bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors"
|
||||
>
|
||||
Delete All
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="w-6 h-6 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
{:else if stats}
|
||||
<!-- Storage Summary -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-16 h-16 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-[var(--color-jellyfin)]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-white">{formatBytes(stats.total_bytes)}</p>
|
||||
<p class="text-sm text-gray-400">
|
||||
{stats.total_items} {stats.total_items === 1 ? "item" : "items"} downloaded
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Breakdown Toggle -->
|
||||
{#if stats.albums.length > 0}
|
||||
<button
|
||||
onclick={() => (showBreakdown = !showBreakdown)}
|
||||
class="w-full flex items-center justify-between py-3 px-4 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<span class="text-sm text-gray-300">Storage by album</span>
|
||||
<svg
|
||||
class="w-5 h-5 text-gray-400 transition-transform {showBreakdown ? 'rotate-180' : ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Album Breakdown -->
|
||||
{#if showBreakdown}
|
||||
<div class="space-y-2 max-h-64 overflow-y-auto">
|
||||
{#each stats.albums as album (album.album_id)}
|
||||
<div class="flex items-center gap-3 p-3 bg-white/5 rounded-lg group hover:bg-white/10 transition-colors">
|
||||
<button
|
||||
onclick={() => handleAlbumClick(album.album_id)}
|
||||
class="flex-1 min-w-0 text-left"
|
||||
>
|
||||
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
{truncateMiddle(album.album_name, 40)}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 truncate">
|
||||
{album.artist_name || "Unknown Artist"} • {album.track_count} {album.track_count === 1 ? "track" : "tracks"}
|
||||
</p>
|
||||
</button>
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
<span class="text-sm text-gray-400">{formatBytes(album.bytes_used)}</span>
|
||||
<button
|
||||
onclick={() => deleteAlbumDownloads(album.album_id)}
|
||||
disabled={deletingAlbum === album.album_id}
|
||||
class="p-1.5 rounded-full text-gray-400 hover:text-red-400 hover:bg-red-500/20 transition-colors disabled:opacity-50"
|
||||
title="Delete album downloads"
|
||||
>
|
||||
{#if deletingAlbum === album.album_id}
|
||||
<div class="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"></div>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Empty State -->
|
||||
{#if stats.total_items === 0}
|
||||
<div class="text-center py-4">
|
||||
<p class="text-gray-400 text-sm">No downloads yet</p>
|
||||
<p class="text-gray-500 text-xs mt-1">Downloaded media will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete All Confirmation Modal -->
|
||||
{#if showDeleteAllConfirm}
|
||||
<div class="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-[var(--color-surface)] rounded-2xl w-full max-w-sm shadow-2xl">
|
||||
<div class="p-6 text-center">
|
||||
<div class="mx-auto w-12 h-12 rounded-full bg-red-500/20 flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Delete All Downloads?</h3>
|
||||
<p class="text-sm text-gray-400 mb-6">
|
||||
This will remove {stats?.total_items || 0} downloaded items and free up {formatBytes(stats?.total_bytes || 0)} of storage. This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={() => (showDeleteAllConfirm = false)}
|
||||
class="flex-1 px-4 py-2 bg-white/10 text-white rounded-lg hover:bg-white/20 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={deleteAllDownloads}
|
||||
disabled={deleting}
|
||||
class="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if deleting}
|
||||
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Deleting...
|
||||
{:else}
|
||||
Delete All
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 @@
|
||||
<!-- Artist Info -->
|
||||
<div class="flex flex-col items-center text-center py-12">
|
||||
<!-- Artist Image -->
|
||||
{#if artist.primaryImageTag}
|
||||
{#if artist.imageId}
|
||||
<div class="mb-6 rounded-full overflow-hidden w-40 h-40 shadow-lg">
|
||||
<CachedImage
|
||||
itemId={artist.id}
|
||||
imageType="Primary"
|
||||
tag={artist.primaryImageTag}
|
||||
tag={artist.imageId}
|
||||
maxWidth={400}
|
||||
alt={artist.name}
|
||||
class="w-full h-full object-cover"
|
||||
@@ -160,11 +160,11 @@
|
||||
class="group cursor-pointer"
|
||||
>
|
||||
<div class="aspect-square bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2 group-hover:opacity-80 transition-opacity">
|
||||
{#if album.primaryImageTag}
|
||||
{#if album.imageId}
|
||||
<CachedImage
|
||||
itemId={album.id}
|
||||
imageType="Primary"
|
||||
tag={album.primaryImageTag}
|
||||
tag={album.imageId}
|
||||
maxWidth={200}
|
||||
alt={album.name}
|
||||
class="w-full h-full object-cover"
|
||||
@@ -218,11 +218,11 @@
|
||||
class="group text-center"
|
||||
>
|
||||
<div class="w-32 h-32 bg-[var(--color-surface)] rounded-full overflow-hidden mb-2 mx-auto group-hover:opacity-80 transition-opacity">
|
||||
{#if relatedArtist.primaryImageTag}
|
||||
{#if relatedArtist.imageId}
|
||||
<CachedImage
|
||||
itemId={relatedArtist.id}
|
||||
imageType="Primary"
|
||||
tag={relatedArtist.primaryImageTag}
|
||||
tag={relatedArtist.imageId}
|
||||
maxWidth={200}
|
||||
alt={relatedArtist.name}
|
||||
class="w-full h-full object-cover"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- TRACES: UR-048 | DR-061, DR-062 -->
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
@@ -76,8 +77,8 @@
|
||||
if (episode.backdropImageTags?.[0]) {
|
||||
return { itemId: episode.id, imageType: "Backdrop" as const, tag: episode.backdropImageTags[0] };
|
||||
}
|
||||
if (episode.primaryImageTag) {
|
||||
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.primaryImageTag };
|
||||
if (episode.imageId) {
|
||||
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.imageId };
|
||||
}
|
||||
if (series.backdropImageTags?.[0]) {
|
||||
return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
|
||||
@@ -85,9 +86,9 @@
|
||||
return null;
|
||||
});
|
||||
|
||||
function formatDuration(ticks?: number | null): 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);
|
||||
|
||||
@@ -98,10 +99,10 @@
|
||||
}
|
||||
|
||||
function getProgress(ep: MediaItem): number {
|
||||
if (!ep.userData || !ep.runTimeTicks) {
|
||||
if (!ep.userData || !ep.durationMs) {
|
||||
return 0;
|
||||
}
|
||||
return ((ep.userData.playbackPositionTicks ?? 0) / ep.runTimeTicks) * 100;
|
||||
return ((ep.userData.playbackPositionMs ?? 0) / ep.durationMs) * 100;
|
||||
}
|
||||
|
||||
function handlePlay() {
|
||||
@@ -115,7 +116,7 @@
|
||||
const episodeLabel = $derived(
|
||||
`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`
|
||||
);
|
||||
const duration = $derived(formatDuration(episode.runTimeTicks));
|
||||
const duration = $derived(formatDuration(episode.durationMs));
|
||||
const progress = $derived(getProgress(episode));
|
||||
</script>
|
||||
|
||||
@@ -245,7 +246,7 @@
|
||||
<CachedImage
|
||||
itemId={ep.id}
|
||||
imageType="Primary"
|
||||
tag={ep.primaryImageTag}
|
||||
tag={ep.imageId}
|
||||
maxWidth={400}
|
||||
alt={ep.name}
|
||||
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
|
||||
|
||||
@@ -38,13 +38,13 @@
|
||||
const downloadProgress = $derived(downloadInfo?.progress || 0);
|
||||
|
||||
const progress = $derived(() => {
|
||||
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);
|
||||
</script>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<CachedImage
|
||||
itemId={episode.id}
|
||||
imageType="Primary"
|
||||
tag={episode.primaryImageTag}
|
||||
tag={episode.imageId}
|
||||
maxWidth={320}
|
||||
alt={episode.name}
|
||||
class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
|
||||
|
||||
@@ -234,7 +234,7 @@
|
||||
<CachedImage
|
||||
itemId={item.id}
|
||||
imageType="Primary"
|
||||
tag={item.primaryImageTag}
|
||||
tag={item.imageId}
|
||||
maxWidth={300}
|
||||
alt={item.name}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
<script lang="ts">
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- TRACES: UR-029, UR-051 | DR-069, DR-070 -->
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import MediaCard from "./MediaCard.svelte";
|
||||
@@ -9,12 +10,20 @@
|
||||
title?: string;
|
||||
loading?: boolean;
|
||||
showViewToggle?: boolean;
|
||||
forceGrid?: boolean;
|
||||
musicContent?: boolean;
|
||||
onItemClick?: (item: MediaItem | Library) => void;
|
||||
/**
|
||||
* Optional per-item secondary label (e.g. on-disk size for the Downloaded
|
||||
* surface), forwarded to each card. TRACES: UR-056 | DR-085
|
||||
*/
|
||||
sizeLabelFor?: (item: MediaItem | Library) => string | undefined;
|
||||
/** Optional per-item container download badge for the Downloaded surface. */
|
||||
downloadedBadgeFor?: (item: MediaItem | Library) => "full" | "partial" | undefined;
|
||||
/** Optional per-item remove-from-device handler for the Downloaded surface. */
|
||||
onItemRemove?: (item: MediaItem | Library) => void;
|
||||
}
|
||||
|
||||
let { items, title, loading = false, showViewToggle = true, forceGrid = false, musicContent = false, onItemClick }: Props = $props();
|
||||
let { items, title, loading = false, showViewToggle = true, musicContent = false, onItemClick, sizeLabelFor, downloadedBadgeFor, onItemRemove }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -65,7 +74,7 @@
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p>No items found</p>
|
||||
</div>
|
||||
{:else if !forceGrid && $viewMode === "list"}
|
||||
{:else if $viewMode === "list"}
|
||||
<LibraryListView {items} showProgress={true} onItemClick={onItemClick} />
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
@@ -74,6 +83,9 @@
|
||||
<MediaCard
|
||||
{item}
|
||||
showProgress={true}
|
||||
sizeLabel={sizeLabelFor?.(item)}
|
||||
downloadedBadge={downloadedBadgeFor?.(item)}
|
||||
onRemove={onItemRemove ? () => onItemRemove(item) : undefined}
|
||||
onclick={() => onItemClick?.(item)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 @@
|
||||
<div class="space-y-1">
|
||||
{#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}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- TRACES: UR-051, UR-052 | DR-068, DR-078 -->
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
@@ -12,10 +13,26 @@
|
||||
size?: "small" | "medium" | "large";
|
||||
showProgress?: boolean;
|
||||
showDownloadStatus?: boolean;
|
||||
/**
|
||||
* Secondary on-disk size label (e.g. "1.2 GB"), shown under the subtitle.
|
||||
* Used by the Downloaded browse surface. TRACES: UR-056 | DR-085
|
||||
*/
|
||||
sizeLabel?: string;
|
||||
/**
|
||||
* "full" | "partial" — badges a downloaded container on the artwork so a
|
||||
* fully-downloaded item reads differently from a partially-downloaded one.
|
||||
* TRACES: UR-055 | DR-083
|
||||
*/
|
||||
downloadedBadge?: "full" | "partial";
|
||||
/**
|
||||
* When set, a hover/focus "remove from device" control appears on the card
|
||||
* (Downloaded surface only). TRACES: UR-055, UR-056 | DR-083
|
||||
*/
|
||||
onRemove?: () => void;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, onclick }: Props = $props();
|
||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick }: Props = $props();
|
||||
|
||||
// Check if this item is downloaded
|
||||
const downloadInfo = $derived(
|
||||
@@ -84,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
|
||||
@@ -96,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(() => {
|
||||
@@ -219,6 +236,43 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Remove-from-device control (Downloaded surface), shown on hover/focus -->
|
||||
{#if onRemove}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); onRemove?.(); }}
|
||||
class="absolute top-2 left-2 w-7 h-7 rounded-full bg-black/70 hover:bg-red-600 text-white flex items-center justify-center opacity-0 group-hover/card:opacity-100 focus:opacity-100 transition-opacity shadow-lg"
|
||||
title="Remove from device"
|
||||
aria-label="Remove {item.name} from device"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 7h12M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2m-7 0v12a1 1 0 001 1h6a1 1 0 001-1V7" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Container downloaded badge (Downloaded surface): full vs partial -->
|
||||
{#if downloadedBadge}
|
||||
<div
|
||||
class="absolute bottom-2 right-2"
|
||||
title={downloadedBadge === "full" ? "Fully downloaded" : "Partially downloaded"}
|
||||
>
|
||||
{#if downloadedBadge === "full"}
|
||||
<div class="w-6 h-6 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-6 h-6 rounded-full bg-amber-500 flex items-center justify-center shadow-lg" aria-label="Partially downloaded">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Server-only: queue-for-download control (kept at full opacity over the
|
||||
greyed artwork). Queued items show a "queued" badge instead. -->
|
||||
{#if isServerOnly}
|
||||
@@ -261,5 +315,8 @@
|
||||
{#if subtitle()}
|
||||
<p class="text-xs text-gray-400 truncate">{subtitle()}</p>
|
||||
{/if}
|
||||
{#if sizeLabel}
|
||||
<p class="text-xs text-gray-500 truncate">{sizeLabel}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:element>
|
||||
|
||||
@@ -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 @@
|
||||
<CachedImage
|
||||
itemId={person.id}
|
||||
imageType="Primary"
|
||||
tag={person.primaryImageTag}
|
||||
tag={person.imageId}
|
||||
maxWidth={400}
|
||||
alt={person.name}
|
||||
class="w-full rounded-lg shadow-lg"
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
const tracks = $derived(entries.map(e => ({ ...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 @@
|
||||
<div class="flex gap-6 pt-4">
|
||||
<!-- Playlist artwork -->
|
||||
<div class="flex-shrink-0 w-48">
|
||||
{#if playlist.primaryImageTag}
|
||||
{#if playlist.imageId}
|
||||
<CachedImage
|
||||
itemId={playlist.id}
|
||||
imageType="Primary"
|
||||
tag={playlist.primaryImageTag}
|
||||
tag={playlist.imageId}
|
||||
maxWidth={400}
|
||||
alt={playlist.name}
|
||||
class="w-full rounded-lg shadow-lg"
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { MediaItem, Person } from "$lib/api/types";
|
||||
import type { MediaItem, MediaKind, Person } from "$lib/api/types";
|
||||
import MediaCard from "./MediaCard.svelte";
|
||||
|
||||
interface Props {
|
||||
currentItemId: string;
|
||||
itemType: "Movie" | "Series" | "MusicAlbum" | "Audio";
|
||||
itemKind: MediaKind;
|
||||
genres?: string[];
|
||||
people?: Person[];
|
||||
artistIds?: string[];
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
let {
|
||||
currentItemId,
|
||||
itemType,
|
||||
itemKind,
|
||||
genres = [],
|
||||
people = [],
|
||||
artistIds = [],
|
||||
@@ -45,9 +45,9 @@
|
||||
|
||||
let items: MediaItem[] = [];
|
||||
|
||||
// First, try to use the Jellyfin Similar Items API (preferred method)
|
||||
// First, try to use the Similar Items API (preferred method)
|
||||
// This works for Movies and Series (most common cases)
|
||||
if (["Movie", "Series"].includes(itemType)) {
|
||||
if (itemKind === "movie" || itemKind === "series") {
|
||||
try {
|
||||
const result = await repo.getSimilarItems(currentItemId, limit);
|
||||
items = result.items.filter(item => 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<string, string> = {
|
||||
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}
|
||||
<!-- Skeleton loading state -->
|
||||
{@const isMusicContent = itemType === "MusicAlbum" || itemType === "Audio"}
|
||||
{@const isMusicContent = itemKind === "album" || itemKind === "track"}
|
||||
<div class="grid grid-cols-2 md:grid-cols-6 gap-4">
|
||||
{#each Array(6) as _}
|
||||
<div class="animate-pulse">
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<CachedImage
|
||||
itemId={season.id}
|
||||
imageType="Primary"
|
||||
tag={season.primaryImageTag}
|
||||
tag={season.imageId}
|
||||
maxWidth={200}
|
||||
alt={seasonName}
|
||||
class="w-full h-full object-cover"
|
||||
|
||||
@@ -283,7 +283,7 @@
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="text-gray-400 text-right">
|
||||
{formatDuration(track.runTimeTicks)}
|
||||
{formatDuration(track.durationMs)}
|
||||
</div>
|
||||
|
||||
<!-- Download Button Placeholder -->
|
||||
@@ -421,7 +421,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-gray-400 text-sm {showDownload ? 'mr-20' : 'mr-12'}">
|
||||
{formatDuration(track.runTimeTicks)}
|
||||
{formatDuration(track.durationMs)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
<CachedImage
|
||||
itemId={artworkItemId}
|
||||
imageType="Primary"
|
||||
tag={displayMedia?.primaryImageTag}
|
||||
tag={displayMedia?.imageId}
|
||||
maxWidth={800}
|
||||
alt=""
|
||||
class="w-full h-full object-cover blur-3xl opacity-30"
|
||||
@@ -223,7 +223,7 @@
|
||||
<CachedImage
|
||||
itemId={artworkItemId}
|
||||
imageType="Primary"
|
||||
tag={displayMedia?.primaryImageTag}
|
||||
tag={displayMedia?.imageId}
|
||||
maxWidth={500}
|
||||
alt={displayMedia?.name}
|
||||
class="w-full h-full object-cover"
|
||||
|
||||
@@ -326,7 +326,7 @@
|
||||
<CachedImage
|
||||
itemId={displayMedia.albumId || displayMedia.id}
|
||||
imageType="Primary"
|
||||
tag={displayMedia.primaryImageTag}
|
||||
tag={displayMedia.imageId}
|
||||
maxWidth={100}
|
||||
alt={displayMedia?.name}
|
||||
class="w-full h-full object-cover"
|
||||
|
||||
@@ -78,11 +78,11 @@
|
||||
<div
|
||||
class="relative flex-shrink-0 w-28 h-16 rounded-lg overflow-hidden bg-gray-800"
|
||||
>
|
||||
{#if imageId && $nextEpisodeItem.primaryImageTag}
|
||||
{#if imageId && $nextEpisodeItem.imageId}
|
||||
<CachedImage
|
||||
itemId={imageId}
|
||||
imageType="Primary"
|
||||
tag={$nextEpisodeItem.primaryImageTag}
|
||||
tag={$nextEpisodeItem.imageId}
|
||||
maxHeight={200}
|
||||
alt={$nextEpisodeItem.name}
|
||||
class="w-full h-full object-cover"
|
||||
|
||||
@@ -36,9 +36,9 @@
|
||||
let dragDisabled = $state(true);
|
||||
const flipDurationMs = 200;
|
||||
|
||||
function formatDuration(ticks?: number | null): 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 mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
@@ -184,11 +184,11 @@
|
||||
|
||||
<!-- Artwork -->
|
||||
<div class="w-10 h-10 rounded bg-gray-800 flex-shrink-0 overflow-hidden">
|
||||
{#if item.primaryImageTag}
|
||||
{#if item.imageId}
|
||||
<CachedImage
|
||||
itemId={item.id}
|
||||
imageType="Primary"
|
||||
tag={item.primaryImageTag}
|
||||
tag={item.imageId}
|
||||
maxWidth={80}
|
||||
alt={item.name}
|
||||
class="w-full h-full object-cover"
|
||||
@@ -210,7 +210,7 @@
|
||||
|
||||
<!-- Duration -->
|
||||
<span class="text-xs text-gray-500 flex-shrink-0">
|
||||
{formatDuration(item.runTimeTicks)}
|
||||
{formatDuration(item.durationMs)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
||||
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
|
||||
import {
|
||||
isBackgroundAudioSupported,
|
||||
setBackgroundAudioEnabled,
|
||||
subscribeAppBackgrounded,
|
||||
subscribeAppForegrounded,
|
||||
} from "$lib/utils/backgroundAudio";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import {
|
||||
computeHandoffPosition,
|
||||
initialHandoffState,
|
||||
@@ -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);
|
||||
@@ -1178,8 +1178,14 @@
|
||||
// playback off to the native ExoPlayer audio service; the WebView <video> is
|
||||
// torn down so no video is decoded. Mutually exclusive with auto-PiP.
|
||||
//
|
||||
// Resolved synchronously (no await) for the same native-mode reason as PiP.
|
||||
const backgroundAudioSupported = isBackgroundAudioSupported();
|
||||
// Gate on the platform, NOT on the AndroidBackgroundAudio JS-bridge probe.
|
||||
// The native isSupported() is unconditionally true on Android, but the bridge
|
||||
// is injected into the WebView asynchronously and races component mount — a
|
||||
// one-shot bridge probe here comes out false on some loads and, being a const,
|
||||
// never recovers, so the button vanished on "some videos". platform() is
|
||||
// available synchronously and is stable. toggleBackgroundAudio() no-ops safely
|
||||
// if the bridge is momentarily absent.
|
||||
const backgroundAudioSupported = platform() === "android";
|
||||
let backgroundAudioOn = $state(false); // v1: default OFF each session
|
||||
let handoffState: BackgroundAudioState = { ...initialHandoffState };
|
||||
|
||||
@@ -1223,7 +1229,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 +1632,11 @@
|
||||
{#if !isMediaReady}
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-black">
|
||||
<!-- Poster/Title Card -->
|
||||
{#if media?.primaryImageTag}
|
||||
{#if media?.imageId}
|
||||
<CachedImage
|
||||
itemId={media.id}
|
||||
imageType="Primary"
|
||||
tag={media.primaryImageTag}
|
||||
tag={media.imageId}
|
||||
maxHeight={1080}
|
||||
alt={media?.name || "Video"}
|
||||
class="max-w-full max-h-full object-contain"
|
||||
|
||||
@@ -121,11 +121,11 @@
|
||||
class="w-full flex items-center gap-3 p-3 hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<div class="w-10 h-10 flex-shrink-0 rounded overflow-hidden">
|
||||
{#if playlist.primaryImageTag}
|
||||
{#if playlist.imageId}
|
||||
<CachedImage
|
||||
itemId={playlist.id}
|
||||
imageType="Primary"
|
||||
tag={playlist.primaryImageTag}
|
||||
tag={playlist.imageId}
|
||||
maxWidth={80}
|
||||
alt={playlist.name}
|
||||
class="w-full h-full object-cover"
|
||||
|
||||
@@ -1,36 +1,28 @@
|
||||
<script lang="ts">
|
||||
// Renders search results as groups, in the user's configured order.
|
||||
//
|
||||
// Scope and order are composed as two independent axes (see
|
||||
// composeSearchGroups): out-of-scope groups drop out, the rest sort by the
|
||||
// saved order, and empty groups are omitted — the saved order itself is
|
||||
// never rewritten by scoping.
|
||||
//
|
||||
// TRACES: UR-050 | DR-067
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import MediaCard from "$lib/components/library/MediaCard.svelte";
|
||||
import TrackList from "$lib/components/library/TrackList.svelte";
|
||||
import { searchGroupOrder } from "$lib/stores/searchGroupOrder";
|
||||
import { composeSearchGroups, type SearchScope } from "$lib/utils/searchScope";
|
||||
|
||||
interface Props {
|
||||
results: MediaItem[];
|
||||
loading?: boolean;
|
||||
scope?: SearchScope;
|
||||
onItemClick?: (item: MediaItem) => void;
|
||||
}
|
||||
|
||||
let { results, loading = false, onItemClick }: Props = $props();
|
||||
let { results, loading = false, scope = "all", onItemClick }: Props = $props();
|
||||
|
||||
// Categorize results by type
|
||||
const categorized = $derived({
|
||||
music: {
|
||||
tracks: results.filter((i) => i.type === "Audio"),
|
||||
albums: results.filter((i) => i.type === "MusicAlbum"),
|
||||
artists: results.filter((i) => i.type === "MusicArtist"),
|
||||
},
|
||||
movies: results.filter((i) => i.type === "Movie"),
|
||||
tvShows: results.filter((i) => i.type === "Series" || i.type === "Episode"),
|
||||
});
|
||||
|
||||
const hasMusic = $derived(
|
||||
categorized.music.tracks.length > 0 ||
|
||||
categorized.music.albums.length > 0 ||
|
||||
categorized.music.artists.length > 0
|
||||
);
|
||||
|
||||
const hasAnyResults = $derived(
|
||||
hasMusic || categorized.movies.length > 0 || categorized.tvShows.length > 0
|
||||
);
|
||||
const groups = $derived(composeSearchGroups(results, scope, $searchGroupOrder));
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
@@ -39,7 +31,7 @@
|
||||
class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
{:else if !hasAnyResults}
|
||||
{:else if groups.length === 0}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
|
||||
@@ -48,98 +40,27 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-8">
|
||||
<!-- Music Section -->
|
||||
{#if hasMusic}
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-2xl font-semibold text-white px-4">Music</h2>
|
||||
|
||||
<!-- Tracks Subsection -->
|
||||
{#if categorized.music.tracks.length > 0}
|
||||
<div>
|
||||
<h3 class="text-lg text-gray-300 px-4 mb-3">
|
||||
Tracks ({categorized.music.tracks.length})
|
||||
</h3>
|
||||
<TrackList tracks={categorized.music.tracks} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Albums Subsection -->
|
||||
{#if categorized.music.albums.length > 0}
|
||||
<div>
|
||||
<h3 class="text-lg text-gray-300 px-4 mb-3">
|
||||
Albums ({categorized.music.albums.length})
|
||||
</h3>
|
||||
<div class="flex gap-4 overflow-x-auto scrollbar-hide px-4">
|
||||
{#each categorized.music.albums as item (item.id)}
|
||||
<MediaCard
|
||||
{item}
|
||||
size="medium"
|
||||
showProgress={true}
|
||||
onclick={() => onItemClick?.(item)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Artists Subsection -->
|
||||
{#if categorized.music.artists.length > 0}
|
||||
<div>
|
||||
<h3 class="text-lg text-gray-300 px-4 mb-3">
|
||||
Artists ({categorized.music.artists.length})
|
||||
</h3>
|
||||
<div class="flex gap-4 overflow-x-auto scrollbar-hide px-4">
|
||||
{#each categorized.music.artists as item (item.id)}
|
||||
<MediaCard
|
||||
{item}
|
||||
size="medium"
|
||||
showProgress={false}
|
||||
onclick={() => onItemClick?.(item)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Movies Section -->
|
||||
{#if categorized.movies.length > 0}
|
||||
{#each groups as group (group.id)}
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white px-4 mb-3">
|
||||
Movies ({categorized.movies.length})
|
||||
{group.label} ({group.items.length})
|
||||
</h2>
|
||||
<div class="flex gap-4 overflow-x-auto scrollbar-hide px-4">
|
||||
{#each categorized.movies as item (item.id)}
|
||||
<MediaCard
|
||||
{item}
|
||||
size="medium"
|
||||
showProgress={true}
|
||||
onclick={() => onItemClick?.(item)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if group.id === "songs"}
|
||||
<TrackList tracks={group.items} />
|
||||
{:else}
|
||||
<div class="flex gap-4 overflow-x-auto scrollbar-hide px-4">
|
||||
{#each group.items as item (item.id)}
|
||||
<MediaCard
|
||||
{item}
|
||||
size="medium"
|
||||
showProgress={group.id !== "artists"}
|
||||
onclick={() => onItemClick?.(item)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- TV Shows Section -->
|
||||
{#if categorized.tvShows.length > 0}
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white px-4 mb-3">
|
||||
TV Shows ({categorized.tvShows.length})
|
||||
</h2>
|
||||
<div class="flex gap-4 overflow-x-auto scrollbar-hide px-4">
|
||||
{#each categorized.tvShows as item (item.id)}
|
||||
<MediaCard
|
||||
{item}
|
||||
size="medium"
|
||||
showProgress={true}
|
||||
onclick={() => onItemClick?.(item)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
// Scope filter chips shown under the search bar.
|
||||
//
|
||||
// The chip row is a *radio group*: exactly one scope is active, so arrow keys
|
||||
// move between chips and the selected one is the sole tab stop.
|
||||
//
|
||||
// TRACES: UR-049 | DR-064
|
||||
import { SCOPE_LABELS, SEARCH_SCOPES, type SearchScope } from "$lib/utils/searchScope";
|
||||
|
||||
interface Props {
|
||||
scope: SearchScope;
|
||||
onChange: (scope: SearchScope) => void;
|
||||
}
|
||||
|
||||
let { scope, onChange }: Props = $props();
|
||||
|
||||
let chipEls: HTMLButtonElement[] = $state([]);
|
||||
|
||||
function select(next: SearchScope) {
|
||||
if (next === scope) return;
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent, index: number) {
|
||||
const delta = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
|
||||
if (delta === 0) return;
|
||||
event.preventDefault();
|
||||
const next = (index + delta + SEARCH_SCOPES.length) % SEARCH_SCOPES.length;
|
||||
chipEls[next]?.focus();
|
||||
select(SEARCH_SCOPES[next]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex gap-2 overflow-x-auto scrollbar-hide"
|
||||
role="radiogroup"
|
||||
aria-label="Search scope"
|
||||
>
|
||||
{#each SEARCH_SCOPES as s, i (s)}
|
||||
<button
|
||||
bind:this={chipEls[i]}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={scope === s}
|
||||
tabindex={scope === s ? 0 : -1}
|
||||
onclick={() => select(s)}
|
||||
onkeydown={(e) => onKeyDown(e, i)}
|
||||
class="px-4 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors border {scope === s
|
||||
? 'bg-[var(--color-jellyfin)] border-[var(--color-jellyfin)] text-white'
|
||||
: 'bg-[var(--color-surface)] border-gray-700 text-gray-300 hover:text-white hover:border-gray-500'}"
|
||||
>
|
||||
{SCOPE_LABELS[s]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-hide {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
// Reorderable list of search result groups.
|
||||
//
|
||||
// Drag-and-drop alone would leave this unusable with a screen reader or any
|
||||
// pointerless input, so every row also carries labelled move up/down buttons
|
||||
// which are the primary, always-available mechanism.
|
||||
//
|
||||
// TRACES: UR-050 | DR-066
|
||||
import { searchGroupOrder } from "$lib/stores/searchGroupOrder";
|
||||
import { GROUP_LABELS, type SearchGroupId } from "$lib/utils/searchScope";
|
||||
|
||||
let dragIndex = $state<number | null>(null);
|
||||
let overIndex = $state<number | null>(null);
|
||||
// Announced to assistive tech after a move, since the list itself reorders
|
||||
// silently.
|
||||
let announcement = $state("");
|
||||
|
||||
function announce(id: SearchGroupId) {
|
||||
const position = $searchGroupOrder.indexOf(id) + 1;
|
||||
announcement = `${GROUP_LABELS[id]} moved to position ${position} of ${$searchGroupOrder.length}`;
|
||||
}
|
||||
|
||||
function move(id: SearchGroupId, delta: number) {
|
||||
searchGroupOrder.move(id, delta);
|
||||
announce(id);
|
||||
}
|
||||
|
||||
function onDragStart(event: DragEvent, index: number) {
|
||||
dragIndex = index;
|
||||
event.dataTransfer?.setData("text/plain", String(index));
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent, index: number) {
|
||||
if (dragIndex === null) return;
|
||||
event.preventDefault();
|
||||
overIndex = index;
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent, index: number) {
|
||||
event.preventDefault();
|
||||
if (dragIndex !== null && dragIndex !== index) {
|
||||
const id = $searchGroupOrder[dragIndex];
|
||||
searchGroupOrder.reorder(dragIndex, index);
|
||||
if (id) announce(id);
|
||||
}
|
||||
dragIndex = null;
|
||||
overIndex = null;
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
dragIndex = null;
|
||||
overIndex = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ul class="space-y-2">
|
||||
{#each $searchGroupOrder as id, i (id)}
|
||||
<li
|
||||
draggable="true"
|
||||
ondragstart={(e) => onDragStart(e, i)}
|
||||
ondragover={(e) => onDragOver(e, i)}
|
||||
ondrop={(e) => onDrop(e, i)}
|
||||
ondragend={onDragEnd}
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-800 border transition-colors {overIndex ===
|
||||
i && dragIndex !== i
|
||||
? 'border-[var(--color-jellyfin)]'
|
||||
: 'border-gray-700'} {dragIndex === i ? 'opacity-50' : ''}"
|
||||
>
|
||||
<!-- Decorative: dragging is the mouse affordance, the buttons below are
|
||||
the accessible path. -->
|
||||
<svg
|
||||
class="w-4 h-4 text-gray-500 flex-shrink-0 cursor-grab"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9 4h2v2H9V4zm4 0h2v2h-2V4zM9 9h2v2H9V9zm4 0h2v2h-2V9zm-4 5h2v2H9v-2zm4 0h2v2h-2v-2zm-4 5h2v2H9v-2zm4 0h2v2h-2v-2z" />
|
||||
</svg>
|
||||
|
||||
<span class="text-sm text-gray-400 w-5 flex-shrink-0">{i + 1}</span>
|
||||
<span class="flex-1 text-white">{GROUP_LABELS[id]}</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => move(id, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label="Move {GROUP_LABELS[id]} up"
|
||||
class="p-2 rounded text-gray-300 hover:bg-gray-700 hover:text-white transition-colors disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => move(id, 1)}
|
||||
disabled={i === $searchGroupOrder.length - 1}
|
||||
aria-label="Move {GROUP_LABELS[id]} down"
|
||||
class="p-2 rounded text-gray-300 hover:bg-gray-700 hover:text-white transition-colors disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="sr-only" role="status" aria-live="polite">{announcement}</div>
|
||||
|
||||
<div class="flex justify-end pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
searchGroupOrder.reset();
|
||||
announcement = "Search group order reset to default";
|
||||
}}
|
||||
class="text-sm text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
// Downloaded catalog service — the "Downloaded" browse surface's data source.
|
||||
//
|
||||
// This is the offline library, filtered to what's on the device. It reads the
|
||||
// dedicated offline-only browse path on the repository (never the hybrid merge),
|
||||
// so results are authoritative regardless of connectivity: an empty result means
|
||||
// "nothing downloaded here", never "server unreachable". See the spec
|
||||
// docs/specs/downloads-as-offline-library.md and ux-flows §7.2–7.3.
|
||||
//
|
||||
// It also owns disk-usage: a per-item/container byte map plus the device total,
|
||||
// aggregated from `downloads.file_size` by the backend.
|
||||
//
|
||||
// TRACES: UR-055, UR-056 | DR-082, DR-083, DR-085
|
||||
|
||||
import { writable, derived, get } from "svelte/store";
|
||||
import type { Library, MediaItem, GetItemsOptions } from "$lib/api/types";
|
||||
import type { DownloadDiskUsage } from "$lib/api/bindings";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
interface DownloadedCatalogState {
|
||||
libraries: Library[];
|
||||
/** item id → bytes on disk (leaf's own size, or a container subtotal). */
|
||||
sizes: Record<string, number>;
|
||||
/** container id → true when only partially downloaded. */
|
||||
partialContainers: Record<string, boolean>;
|
||||
deviceTotalBytes: number;
|
||||
itemCount: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initial: DownloadedCatalogState = {
|
||||
libraries: [],
|
||||
sizes: {},
|
||||
partialContainers: {},
|
||||
deviceTotalBytes: 0,
|
||||
itemCount: 0,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function createDownloadedCatalogStore() {
|
||||
const { subscribe, update, set } = writable<DownloadedCatalogState>(initial);
|
||||
|
||||
function repo() {
|
||||
return auth.getRepository();
|
||||
}
|
||||
|
||||
/** Load the downloaded-library list and disk-usage totals for the top bar. */
|
||||
async function refresh(): Promise<void> {
|
||||
update((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const [libraries, usage] = await Promise.all([
|
||||
repo().getDownloadedLibraries(),
|
||||
repo().getDownloadDiskUsage() as Promise<DownloadDiskUsage>,
|
||||
]);
|
||||
// The wire type is Partial<{ [k]: number }>; normalise to a dense record.
|
||||
const sizes: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(usage.sizes)) {
|
||||
if (typeof v === "number") sizes[k] = v;
|
||||
}
|
||||
const partialContainers: Record<string, boolean> = {};
|
||||
for (const [k, v] of Object.entries(usage.partialContainers)) {
|
||||
if (v) partialContainers[k] = true;
|
||||
}
|
||||
update((s) => ({
|
||||
...s,
|
||||
libraries,
|
||||
sizes,
|
||||
partialContainers,
|
||||
deviceTotalBytes: usage.deviceTotalBytes,
|
||||
itemCount: usage.itemCount,
|
||||
loading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load downloads";
|
||||
update((s) => ({ ...s, loading: false, error: message }));
|
||||
}
|
||||
}
|
||||
|
||||
/** Downloaded-only items under a container (library, album, season, series). */
|
||||
async function loadItems(parentId: string, options?: GetItemsOptions): Promise<MediaItem[]> {
|
||||
const result = await repo().getDownloadedItems(parentId, options);
|
||||
return result.items;
|
||||
}
|
||||
|
||||
/** Bytes on disk for an item id (leaf's own, or a container subtotal), or 0. */
|
||||
function sizeOf(itemId: string): number {
|
||||
return get({ subscribe }).sizes[itemId] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every completed download at or under a container/leaf, then refresh
|
||||
* so the browse and totals update. Returns the number of downloads removed.
|
||||
* TRACES: UR-055, UR-056 | DR-083
|
||||
*/
|
||||
async function remove(itemId: string): Promise<number> {
|
||||
const userId = auth.getUserId();
|
||||
if (!userId) throw new Error("Not signed in");
|
||||
const removed = await commands.deleteDownloadsUnder(itemId, userId);
|
||||
await refresh();
|
||||
return removed;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
set(initial);
|
||||
}
|
||||
|
||||
return { subscribe, refresh, loadItems, sizeOf, remove, reset };
|
||||
}
|
||||
|
||||
export const downloadedCatalog = createDownloadedCatalogStore();
|
||||
|
||||
export const downloadedLibraries = derived(downloadedCatalog, ($c) => $c.libraries);
|
||||
export const downloadedDeviceTotal = derived(downloadedCatalog, ($c) => $c.deviceTotalBytes);
|
||||
export const downloadedItemCount = derived(downloadedCatalog, ($c) => $c.itemCount);
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Tests for the network-transport reporter behind the WiFi-only download gate.
|
||||
*
|
||||
* TRACES: UR-053 | DR-074 | UT-066
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const setNetworkState = vi.fn();
|
||||
const getDownloadsAllowed = vi.fn();
|
||||
|
||||
vi.mock('$lib/api/bindings', () => ({
|
||||
commands: {
|
||||
setNetworkState: (...args: unknown[]) => setNetworkState(...args),
|
||||
getDownloadsAllowed: () => getDownloadsAllowed()
|
||||
}
|
||||
}));
|
||||
|
||||
import {
|
||||
isNetworkDetectionSupported,
|
||||
reportNetworkState,
|
||||
startNetworkReporting,
|
||||
areDownloadsAllowed
|
||||
} from './networkType';
|
||||
|
||||
/** Install a fake Android bridge on window. */
|
||||
function installBridge(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
const bridge = {
|
||||
currentType: vi.fn(() => 'wifi'),
|
||||
isUnmetered: vi.fn(() => true),
|
||||
isAcceptable: vi.fn(() => true),
|
||||
isSupported: vi.fn(() => true),
|
||||
...overrides
|
||||
};
|
||||
(window as unknown as Record<string, unknown>).AndroidNetworkType = bridge;
|
||||
return bridge;
|
||||
}
|
||||
|
||||
function removeBridge() {
|
||||
delete (window as unknown as Record<string, unknown>).AndroidNetworkType;
|
||||
}
|
||||
|
||||
describe('networkType service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setNetworkState.mockResolvedValue(null);
|
||||
getDownloadsAllowed.mockResolvedValue(true);
|
||||
removeBridge();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
removeBridge();
|
||||
});
|
||||
|
||||
describe('isNetworkDetectionSupported', () => {
|
||||
it('is false with no Android bridge (desktop)', () => {
|
||||
expect(isNetworkDetectionSupported()).toBe(false);
|
||||
});
|
||||
|
||||
it('is true when the Android bridge is present', () => {
|
||||
installBridge();
|
||||
expect(isNetworkDetectionSupported()).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when the bridge throws', () => {
|
||||
installBridge({
|
||||
isSupported: vi.fn(() => {
|
||||
throw new Error('bridge exploded');
|
||||
})
|
||||
});
|
||||
expect(isNetworkDetectionSupported()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reportNetworkState', () => {
|
||||
it('does not call the backend on desktop', async () => {
|
||||
await reportNetworkState();
|
||||
expect(setNetworkState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports transport and metered-ness from the bridge', async () => {
|
||||
installBridge({
|
||||
currentType: vi.fn(() => 'cellular'),
|
||||
isUnmetered: vi.fn(() => false)
|
||||
});
|
||||
|
||||
await reportNetworkState();
|
||||
|
||||
expect(setNetworkState).toHaveBeenCalledWith({
|
||||
networkType: 'cellular',
|
||||
unmetered: false
|
||||
});
|
||||
});
|
||||
|
||||
it('reports metered WiFi as WiFi-but-metered, not as unmetered', async () => {
|
||||
// A phone hotspot: WiFi transport, metered connection.
|
||||
installBridge({
|
||||
currentType: vi.fn(() => 'wifi'),
|
||||
isUnmetered: vi.fn(() => false)
|
||||
});
|
||||
|
||||
await reportNetworkState();
|
||||
|
||||
expect(setNetworkState).toHaveBeenCalledWith({
|
||||
networkType: 'wifi',
|
||||
unmetered: false
|
||||
});
|
||||
});
|
||||
|
||||
it('swallows backend errors so the UI never breaks', async () => {
|
||||
installBridge();
|
||||
setNetworkState.mockRejectedValue(new Error('ipc down'));
|
||||
|
||||
await expect(reportNetworkState()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startNetworkReporting', () => {
|
||||
it('reports once immediately and again on network change', async () => {
|
||||
installBridge();
|
||||
|
||||
const stop = startNetworkReporting();
|
||||
await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(1));
|
||||
|
||||
window.dispatchEvent(new CustomEvent('jellytau-network-changed'));
|
||||
await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(2));
|
||||
|
||||
stop();
|
||||
});
|
||||
|
||||
it('stops reporting after teardown', async () => {
|
||||
installBridge();
|
||||
|
||||
const stop = startNetworkReporting();
|
||||
await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(1));
|
||||
stop();
|
||||
|
||||
window.dispatchEvent(new CustomEvent('jellytau-network-changed'));
|
||||
// Give any stray listener a chance to fire before asserting.
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(setNetworkState).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('areDownloadsAllowed', () => {
|
||||
it('returns the backend verdict', async () => {
|
||||
getDownloadsAllowed.mockResolvedValue(false);
|
||||
expect(await areDownloadsAllowed()).toBe(false);
|
||||
});
|
||||
|
||||
it('fails open if the query errors, so the UI never falsely blames WiFi', async () => {
|
||||
getDownloadsAllowed.mockRejectedValue(new Error('ipc down'));
|
||||
expect(await areDownloadsAllowed()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Reports the device's network transport to the Rust backend, so the download
|
||||
* queue can honour the "WiFi Only" setting.
|
||||
*
|
||||
* Android exposes the real transport through the `AndroidNetworkType`
|
||||
* JavascriptInterface (backed by NetworkCapabilities). On desktop that
|
||||
* interface is absent and we report nothing — the backend defaults to unmetered
|
||||
* ethernet, so desktop downloads are never gated.
|
||||
*
|
||||
* TRACES: UR-053 | DR-074
|
||||
*/
|
||||
|
||||
import { commands } from '$lib/api/bindings';
|
||||
import type { NetworkType } from '$lib/api/bindings';
|
||||
|
||||
/** The Android bridge, present only in the Android WebView. */
|
||||
interface AndroidNetworkTypeBridge {
|
||||
currentType(): NetworkType;
|
||||
isUnmetered(): boolean;
|
||||
isAcceptable(wifiOnly: boolean): boolean;
|
||||
isSupported(): boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
AndroidNetworkType?: AndroidNetworkTypeBridge;
|
||||
}
|
||||
}
|
||||
|
||||
/** Event dispatched into the WebView by MainActivity on any network change. */
|
||||
const NETWORK_CHANGED_EVENT = 'jellytau-network-changed';
|
||||
|
||||
function bridge(): AndroidNetworkTypeBridge | undefined {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
return window.AndroidNetworkType;
|
||||
}
|
||||
|
||||
/** Whether native network detection is available (Android only). */
|
||||
export function isNetworkDetectionSupported(): boolean {
|
||||
try {
|
||||
return bridge()?.isSupported() ?? false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current transport from Android and push it into Rust.
|
||||
*
|
||||
* No-op on desktop, where the backend's unmetered-ethernet default already
|
||||
* means downloads run unconditionally.
|
||||
*/
|
||||
export async function reportNetworkState(): Promise<void> {
|
||||
const android = bridge();
|
||||
if (!android) return;
|
||||
|
||||
try {
|
||||
const networkType = android.currentType();
|
||||
const unmetered = android.isUnmetered();
|
||||
|
||||
await commands.setNetworkState({ networkType, unmetered });
|
||||
} catch (error) {
|
||||
// Never let network reporting break the UI — the gate fails closed on
|
||||
// the Rust side, so a missed report at worst delays a queued download.
|
||||
console.warn('[NetworkType] Failed to report network state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start reporting network state: once immediately, then on every native network
|
||||
* change. Reporting an acceptable network re-pumps the download queue on the
|
||||
* Rust side, so a queue parked on "waiting for WiFi" drains itself.
|
||||
*
|
||||
* Returns a teardown function.
|
||||
*/
|
||||
export function startNetworkReporting(): () => void {
|
||||
if (typeof window === 'undefined') return () => {};
|
||||
|
||||
void reportNetworkState();
|
||||
|
||||
const onChange = () => {
|
||||
void reportNetworkState();
|
||||
};
|
||||
|
||||
window.addEventListener(NETWORK_CHANGED_EVENT, onChange);
|
||||
// The browser's own online/offline events are a useful extra nudge on
|
||||
// desktop-style webviews where the native callback may not fire.
|
||||
window.addEventListener('online', onChange);
|
||||
window.addEventListener('offline', onChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(NETWORK_CHANGED_EVENT, onChange);
|
||||
window.removeEventListener('online', onChange);
|
||||
window.removeEventListener('offline', onChange);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether downloads are currently permitted by the WiFi-only gate. Used by the
|
||||
* downloads UI to show "Waiting for WiFi" instead of a stuck-looking queue.
|
||||
*/
|
||||
export async function areDownloadsAllowed(): Promise<boolean> {
|
||||
try {
|
||||
return await commands.getDownloadsAllowed();
|
||||
} catch (error) {
|
||||
console.warn('[NetworkType] Failed to query download gate:', error);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// UT-068: `pushCatalogVisibility` resolves `serverReachable || showCatalog` and
|
||||
// pushes the result to the backend whenever either input changes.
|
||||
// See docs/specs/offline-downloaded-only-filter.md (DR-078/DR-079).
|
||||
//
|
||||
// `isConnected` now tracks server reachability alone (DR-079), so from this
|
||||
// service's perspective its input is "is the server reachable". We drive it and
|
||||
// the `showServerCatalog` toggle and assert what gets pushed via
|
||||
// `commands.setShowServerCatalog`.
|
||||
|
||||
const h = vi.hoisted(() => {
|
||||
function shim<T>(initial: T) {
|
||||
let value = initial;
|
||||
const subs = new Set<(v: T) => void>();
|
||||
return {
|
||||
set(v: T) {
|
||||
value = v;
|
||||
subs.forEach((fn) => fn(value));
|
||||
},
|
||||
subscribe(fn: (v: T) => void) {
|
||||
subs.add(fn);
|
||||
fn(value);
|
||||
return () => subs.delete(fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
isConnectedStore: shim(true),
|
||||
setShowServerCatalog: vi.fn(async () => {}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("$lib/stores/connectivity", () => ({
|
||||
isConnected: { subscribe: h.isConnectedStore.subscribe },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/api/bindings", () => ({
|
||||
commands: {
|
||||
setShowServerCatalog: h.setShowServerCatalog,
|
||||
syncFullCatalog: vi.fn(),
|
||||
resumeQueuedDownloads: vi.fn(),
|
||||
catalogSyncStatus: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
auth: { getRepository: () => ({ getHandle: () => "handle-1" }) },
|
||||
}));
|
||||
|
||||
describe("pushCatalogVisibility resolves reachable || showCatalog (UT-068)", () => {
|
||||
beforeEach(() => {
|
||||
h.setShowServerCatalog.mockClear();
|
||||
h.isConnectedStore.set(true);
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("pushes include=true while the server is reachable (initial subscribe)", async () => {
|
||||
const { showServerCatalog } = await import("./offlineCatalog");
|
||||
// Initial subscription with reachable=true, showCatalog=false ⇒ include=true.
|
||||
expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(true);
|
||||
// Keep the import binding referenced so tree-shaking never elides it.
|
||||
expect(showServerCatalog).toBeDefined();
|
||||
});
|
||||
|
||||
it("pushes include=false when unreachable and the toggle is off", async () => {
|
||||
h.isConnectedStore.set(false);
|
||||
await import("./offlineCatalog");
|
||||
// Fresh module subscribes with reachable=false, showCatalog=false ⇒ false.
|
||||
expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it("re-pushes include=true when the toggle flips on while unreachable", async () => {
|
||||
h.isConnectedStore.set(false);
|
||||
const { showServerCatalog } = await import("./offlineCatalog");
|
||||
h.setShowServerCatalog.mockClear();
|
||||
|
||||
showServerCatalog.set(true); // showCatalog input changes ⇒ include flips to true
|
||||
expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(true);
|
||||
|
||||
h.setShowServerCatalog.mockClear();
|
||||
showServerCatalog.set(false); // back off ⇒ include flips to false
|
||||
expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it("does not re-push when the resolved value is unchanged", async () => {
|
||||
// reachable=true ⇒ include already true. Turning the toggle on keeps it true.
|
||||
const { showServerCatalog } = await import("./offlineCatalog");
|
||||
h.setShowServerCatalog.mockClear();
|
||||
|
||||
showServerCatalog.set(true); // include stays true (true || true) ⇒ no push
|
||||
expect(h.setShowServerCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
// It also owns the `showServerCatalog` UI flag (the offline banner toggle that
|
||||
// reveals greyed-out, non-downloaded server media).
|
||||
//
|
||||
// TRACES: UR-002
|
||||
// TRACES: UR-002, UR-052 | DR-078
|
||||
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
|
||||
@@ -29,7 +29,7 @@ vi.mock("$lib/stores/auth", () => ({
|
||||
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);
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function reportPlaybackStart(
|
||||
contextType: "container" | "single" = "single",
|
||||
contextId: string | null = null
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@@ -73,8 +73,8 @@ function makeItem(overrides: Partial<MediaItem> = {}): 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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -104,12 +104,12 @@ class SyncService {
|
||||
*/
|
||||
async queuePlaybackProgress(
|
||||
itemId: string,
|
||||
positionTicks: number
|
||||
positionMs: number
|
||||
): Promise<number> {
|
||||
// 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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -569,3 +569,5 @@ export const securityWarning = derived(auth, ($auth) => $auth.securityWarning);
|
||||
export const authError = derived(auth, ($auth) => $auth.error);
|
||||
export const isVerifying = derived(auth, ($auth) => $auth.isVerifying);
|
||||
export const sessionVerified = derived(auth, ($auth) => $auth.sessionVerified);
|
||||
export const serverName = derived(auth, ($auth) => $auth.serverName);
|
||||
export const serverUrl = derived(auth, ($auth) => $auth.serverUrl);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
// UT-069: `isConnected` must follow backend server reachability ALONE, never
|
||||
// navigator.onLine. See docs/specs/offline-downloaded-only-filter.md (DR-079).
|
||||
//
|
||||
// The connectivity store registers a `connectivity:changed` listener at import
|
||||
// time and mirrors its payload into `isServerReachable`. We capture that
|
||||
// listener via the mocked `listen`, then drive reachability directly while
|
||||
// pinning navigator.onLine to the *opposite* value to prove it is ignored.
|
||||
|
||||
const h = vi.hoisted(() => {
|
||||
const listeners = new Map<string, (event: { payload: unknown }) => void>();
|
||||
return { listeners };
|
||||
});
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (name: string, cb: (event: { payload: unknown }) => void) => {
|
||||
h.listeners.set(name, cb);
|
||||
return () => h.listeners.delete(name);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/api/bindings", () => ({
|
||||
commands: {
|
||||
connectivityCheckServer: vi.fn(async () => true),
|
||||
connectivityGetStatus: vi.fn(async () => ({
|
||||
isServerReachable: true,
|
||||
lastChecked: null,
|
||||
connectionError: null,
|
||||
isChecking: false,
|
||||
})),
|
||||
connectivitySetServerUrl: vi.fn(async () => {}),
|
||||
connectivityStartMonitoring: vi.fn(async () => {}),
|
||||
connectivityStopMonitoring: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$app/environment", () => ({ browser: true }));
|
||||
|
||||
function setNavigatorOnLine(value: boolean) {
|
||||
Object.defineProperty(window.navigator, "onLine", {
|
||||
configurable: true,
|
||||
get: () => value,
|
||||
});
|
||||
}
|
||||
|
||||
function emitReachable(isReachable: boolean) {
|
||||
const cb = h.listeners.get("connectivity:changed");
|
||||
if (!cb) throw new Error("connectivity:changed listener was not registered");
|
||||
cb({ payload: { isReachable } });
|
||||
}
|
||||
|
||||
describe("isConnected follows server reachability alone (UT-069)", () => {
|
||||
beforeEach(() => {
|
||||
h.listeners.clear();
|
||||
vi.resetModules(); // fresh connectivity module ⇒ re-registers its listener
|
||||
});
|
||||
|
||||
it("is false when the server is unreachable even though navigator.onLine is true", async () => {
|
||||
setNavigatorOnLine(true);
|
||||
const { isConnected } = await import("./connectivity");
|
||||
|
||||
emitReachable(false);
|
||||
expect(get(isConnected)).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when the server is reachable even though navigator.onLine is false", async () => {
|
||||
setNavigatorOnLine(false);
|
||||
const { isConnected } = await import("./connectivity");
|
||||
|
||||
emitReachable(true);
|
||||
expect(get(isConnected)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
// mirrors status; it does not decide reachability itself. navigator.onLine is
|
||||
// advisory and triggers a recheck rather than forcing offline.
|
||||
// See docs/architecture/07-connectivity.md.
|
||||
// TRACES: UR-002 | DR-013
|
||||
// TRACES: UR-002, UR-043, UR-052 | DR-013, DR-055, DR-079
|
||||
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { browser } from "$app/environment";
|
||||
@@ -231,8 +231,20 @@ export const connectivity = createConnectivityStore();
|
||||
// Derived stores for convenience
|
||||
export const isOnline = derived(connectivity, ($c) => $c.isOnline);
|
||||
export const isServerReachable = derived(connectivity, ($c) => $c.isServerReachable);
|
||||
// "Connected" follows backend reachability ALONE — not navigator.onLine.
|
||||
// The Rust ConnectivityMonitor (fed by real repository traffic) is the source
|
||||
// of truth (DR-055); navigator.onLine is advisory and can be wrong (server
|
||||
// unreachable on a live device link — server down, wrong LAN, dropped VPN —
|
||||
// still reports online). Folding it in kept `isConnected` true in exactly those
|
||||
// cases and prevented the offline "downloaded only" gate from ever closing.
|
||||
// navigator.onLine stays a *trigger* for a recheck (the online/offline
|
||||
// listeners call checkServerReachable), never a *term* in this decision.
|
||||
// The startup default (isServerReachable: true) is intentionally optimistic —
|
||||
// a brief full-catalog flash before the first probe beats flipping the app to
|
||||
// "offline" on launch. See docs/architecture/07-connectivity.md.
|
||||
// TRACES: UR-052 | DR-079
|
||||
export const isConnected = derived(
|
||||
connectivity,
|
||||
($c) => $c.isOnline && $c.isServerReachable
|
||||
($c) => $c.isServerReachable
|
||||
);
|
||||
export const connectionError = derived(connectivity, ($c) => $c.connectionError);
|
||||
|
||||
@@ -309,6 +309,35 @@ describe("downloads store", () => {
|
||||
expect(state.stats.queuedCount).toBe(1); // 1 pending
|
||||
});
|
||||
|
||||
// The Transfers view shows only in-flight rows; a completed transfer must
|
||||
// NOT appear there (it lives in Downloaded). Mirrors the /downloads page's
|
||||
// `transfers` derivation: active + pending + failed.
|
||||
// TRACES: UR-055 | DR-084 | UT-052
|
||||
it("transfers set excludes completed downloads", async () => {
|
||||
const { downloads, activeDownloads, pendingDownloads, failedDownloads } = await import(
|
||||
"./downloads"
|
||||
);
|
||||
|
||||
mockInvoke.mockResolvedValueOnce({
|
||||
downloads: [
|
||||
{ id: 1, itemId: "a", userId: "u", filePath: "/a", status: "downloading", progress: 0.5, bytesDownloaded: 5, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user" },
|
||||
{ id: 2, itemId: "b", userId: "u", filePath: "/b", status: "pending", progress: 0, bytesDownloaded: 0, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user" },
|
||||
{ id: 3, itemId: "c", userId: "u", filePath: "/c", status: "completed", progress: 1, bytesDownloaded: 9, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user" },
|
||||
{ id: 4, itemId: "d", userId: "u", filePath: "/d", status: "failed", progress: 0, bytesDownloaded: 0, queuedAt: "t", retryCount: 1, priority: 0, mediaType: "audio", downloadSource: "user" },
|
||||
],
|
||||
stats: { total: 4, activeCount: 1, queuedCount: 1, completedCount: 1, failedCount: 1, pausedCount: 0 },
|
||||
});
|
||||
|
||||
await downloads.refresh("u");
|
||||
|
||||
const transfers = get(activeDownloads)
|
||||
.concat(get(pendingDownloads))
|
||||
.concat(get(failedDownloads));
|
||||
const ids = transfers.map((d) => d.id).sort();
|
||||
expect(ids).toEqual([1, 2, 4]);
|
||||
expect(transfers.some((d) => d.status === "completed")).toBe(false);
|
||||
});
|
||||
|
||||
it("should support status filter", async () => {
|
||||
const { downloads } = await import("./downloads");
|
||||
|
||||
|
||||
@@ -40,7 +40,16 @@ export interface DownloadInfo {
|
||||
}
|
||||
|
||||
export interface DownloadEvent {
|
||||
type: 'queued' | 'started' | 'progress' | 'completed' | 'failed' | 'paused' | 'cancelled';
|
||||
type:
|
||||
| 'queued'
|
||||
| 'started'
|
||||
| 'progress'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'paused'
|
||||
| 'cancelled'
|
||||
| 'waitingForNetwork';
|
||||
/** Absent on 'waitingForNetwork', which is queue-wide rather than per-download. */
|
||||
downloadId: number;
|
||||
itemId: string;
|
||||
bytesDownloaded?: number;
|
||||
@@ -64,6 +73,15 @@ interface DownloadsState {
|
||||
stats: DownloadStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the download queue is held because "WiFi Only" is enabled and the
|
||||
* device is on a metered/cellular network. Pending rows stay pending; the queue
|
||||
* resumes automatically when an acceptable network appears.
|
||||
*
|
||||
* TRACES: UR-053 | DR-074
|
||||
*/
|
||||
export const waitingForNetwork = writable(false);
|
||||
|
||||
function createDownloadsStore() {
|
||||
const { subscribe, update, set } = writable<DownloadsState>({
|
||||
downloads: {},
|
||||
@@ -607,6 +625,17 @@ function handleDownloadEvent(payload: DownloadEvent): void {
|
||||
case 'cancelled':
|
||||
removeDownloadFromStore(payload.downloadId);
|
||||
break;
|
||||
|
||||
case 'waitingForNetwork':
|
||||
// Queue-wide, not tied to one download: the pump refused to start
|
||||
// anything because WiFi-only is on and we're on a metered network.
|
||||
waitingForNetwork.set(true);
|
||||
break;
|
||||
}
|
||||
|
||||
// Any per-download progress proves the gate isn't holding us any more.
|
||||
if (payload.type === 'started' || payload.type === 'progress') {
|
||||
waitingForNetwork.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
|
||||
import type { SearchOptions } from "$lib/api/bindings";
|
||||
import { scopeItemTypes, type SearchScope } from "$lib/utils/searchScope";
|
||||
import { auth } from "./auth";
|
||||
|
||||
/**
|
||||
@@ -200,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) => {
|
||||
@@ -222,7 +224,17 @@ function createLibraryStore() {
|
||||
}
|
||||
}
|
||||
|
||||
async function search(query: string) {
|
||||
/**
|
||||
* Search the library, optionally narrowed to a scope.
|
||||
*
|
||||
* `scope` is additive and defaults to `all`, which sends no
|
||||
* `includeItemTypes` at all — see scopeItemTypes() for why that differs from
|
||||
* listing every type. Both the online and offline repository paths already
|
||||
* honour the filter.
|
||||
*
|
||||
* TRACES: UR-049 | DR-065
|
||||
*/
|
||||
async function search(query: string, scope: SearchScope = "all") {
|
||||
// Bump the request id for every call (including clears) so any in-flight
|
||||
// backend update for a previous query is ignored when it arrives.
|
||||
const requestId = ++searchRequestId;
|
||||
@@ -247,8 +259,13 @@ function createLibraryStore() {
|
||||
// Phase 1: the command resolves with instant local-cache results. The
|
||||
// merged (cache + server) union arrives later via the `search-event`
|
||||
// listener above, tagged with this same requestId.
|
||||
const itemTypes = scopeItemTypes(scope);
|
||||
const options: SearchOptions = { limit: 10000 };
|
||||
// Omit the key entirely for the `all` scope rather than sending null.
|
||||
if (itemTypes) options.includeItemTypes = itemTypes;
|
||||
|
||||
const result = await Promise.race([
|
||||
repo.search(query, { limit: 10000 }, requestId),
|
||||
repo.search(query, options, requestId),
|
||||
timeoutPromise
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Scoped search through the library store.
|
||||
*
|
||||
* TRACES: UR-049 | DR-065 | UT-*
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
const searchMock = vi.fn();
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async () => () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("./auth", () => ({
|
||||
auth: {
|
||||
getRepository: () => ({ search: searchMock }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { library } from "./library";
|
||||
|
||||
function result(items: { id: string; type: string }[] = []) {
|
||||
return { items, totalRecordCount: items.length };
|
||||
}
|
||||
|
||||
describe("library.search scoping", () => {
|
||||
beforeEach(() => {
|
||||
searchMock.mockReset();
|
||||
searchMock.mockResolvedValue(result());
|
||||
library.clearSearch();
|
||||
});
|
||||
|
||||
it("omits includeItemTypes entirely for the default (all) scope", async () => {
|
||||
await library.search("office");
|
||||
|
||||
const options = searchMock.mock.calls[0][1];
|
||||
expect(options).not.toHaveProperty("includeItemTypes");
|
||||
expect(options.limit).toBe(10000);
|
||||
});
|
||||
|
||||
it("forwards music item types when scoped to music", async () => {
|
||||
await library.search("office", "music");
|
||||
|
||||
expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual([
|
||||
"MusicAlbum",
|
||||
"MusicArtist",
|
||||
"Audio",
|
||||
"Playlist",
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards tv item types when scoped to tv", async () => {
|
||||
await library.search("office", "tv");
|
||||
|
||||
expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual(["Series", "Episode"]);
|
||||
});
|
||||
|
||||
it("forwards movie item types when scoped to movies", async () => {
|
||||
await library.search("office", "movies");
|
||||
|
||||
expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual(["Movie"]);
|
||||
});
|
||||
|
||||
it("stores results and the query on success", async () => {
|
||||
searchMock.mockResolvedValue(result([{ id: "1", type: "Movie" }]));
|
||||
|
||||
await library.search("office", "movies");
|
||||
|
||||
const state = get(library);
|
||||
expect(state.searchQuery).toBe("office");
|
||||
expect(state.searchResults.map((i) => i.id)).toEqual(["1"]);
|
||||
});
|
||||
|
||||
it("clears results for an empty query without hitting the repository", async () => {
|
||||
searchMock.mockResolvedValue(result([{ id: "1", type: "Movie" }]));
|
||||
await library.search("office", "movies");
|
||||
searchMock.mockClear();
|
||||
|
||||
await library.search(" ");
|
||||
|
||||
expect(searchMock).not.toHaveBeenCalled();
|
||||
const state = get(library);
|
||||
expect(state.searchQuery).toBe("");
|
||||
expect(state.searchResults).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards a superseded response (stale requestId guard)", async () => {
|
||||
// First search resolves *after* a newer one has already started; its
|
||||
// results must not clobber the fresher ones.
|
||||
let resolveFirst: (value: unknown) => void = () => {};
|
||||
searchMock.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (resolveFirst = resolve))
|
||||
);
|
||||
searchMock.mockResolvedValueOnce(result([{ id: "new", type: "Movie" }]));
|
||||
|
||||
const first = library.search("old", "all");
|
||||
await library.search("new", "movies");
|
||||
|
||||
resolveFirst(result([{ id: "old", type: "Audio" }]));
|
||||
await first;
|
||||
|
||||
expect(get(library).searchResults.map((i) => i.id)).toEqual(["new"]);
|
||||
});
|
||||
|
||||
it("passes an increasing requestId to the repository", async () => {
|
||||
await library.search("a");
|
||||
await library.search("b");
|
||||
|
||||
const [firstId, secondId] = searchMock.mock.calls.map((c) => c[2]);
|
||||
expect(secondId).toBeGreaterThan(firstId);
|
||||
});
|
||||
|
||||
it("surfaces a repository failure as a store error", async () => {
|
||||
searchMock.mockRejectedValue(new Error("boom"));
|
||||
|
||||
await expect(library.search("office", "tv")).rejects.toThrow("boom");
|
||||
expect(get(library).error).toBe("boom");
|
||||
expect(get(library).loadingCount).toBe(0);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user