Compare commits

...
25 Commits
Author SHA1 Message Date
dtourolle 90f03dd142 fix(player): show background-audio button on all Android video playback
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 11m49s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 10m18s
Traceability Validation / Check Requirement Traces (push) Successful in 1m2s
Build & Release / Run Tests (push) Successful in 10m46s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 7m16s
Build & Release / Build Linux (push) Successful in 24m59s
Build & Release / Build Android (push) Successful in 33m5s
Build & Release / Create Release (push) Successful in 17s
The audio-only (background-audio) button was gated on the
AndroidBackgroundAudio JS-bridge probe, resolved once as a const at mount.
The bridge is injected into the WebView asynchronously and races component
mount, so on some loads the probe returned false and never recovered,
hiding the button on 'some videos' at random.

Gate on platform() === 'android' instead (synchronous, stable), matching
the convention in VolumeControl. toggleBackgroundAudio() already no-ops if
the bridge is momentarily absent.

Bump version to 0.0.18.
2026-07-24 20:32:08 +02:00
dtourolle 514e42fccb Merge branch 'frontend-domain-model' into ci-docs-publish-fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 8m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 5m18s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m38s
Build & Release / Build Linux (push) Successful in 17m22s
Build & Release / Build Android (push) Successful in 22m44s
Build & Release / Create Release (push) Successful in 14s
2026-07-23 22:18:59 +02:00
dtourolle 9b1c9b3c91 feat(settings): rework settings page; remove unused SkeletonLoader/StorageManagement
Settings page refactor plus supporting docs (requirements, ux-flows,
traceability) and the frontend-domain-model spec with implementation-status
banner. Removes SkeletonLoader and StorageManagement components (no remaining
references).
2026-07-23 22:18:37 +02:00
dtourolle 1780109fb1 domain: flip last missed catalog .type reader (movies page) to .kind
library/movies/+page.svelte handleItemClick still read item.type === "Folder"
(missed in the phase-2a sweep). Now item.kind === "folder". Final sweep
confirms zero catalog .type/runTimeTicks/primaryImageTag/playbackPositionTicks
reads remain anywhere in src/.

Decision on dropping the dual-carry legacy Rust fields: KEPT. They are now
internal-only (no frontend reader), but internal Rust still depends on
item_type (SQL WHERE item_type='Audio', player audio filter, player-queue
passthrough) and the DB stores ticks. Removing them needs a DB/query-layer
migration with real regression risk and zero frontend benefit — out of scope
for the frontend-model goal, which is met.

Frontend 626, check clean.
2026-07-23 22:15:15 +02:00
dtourolle 3a18ad060b domain: delete dead jellyfinFieldMapping; scope playbackUnits to session boundary (phase 4e)
jellyfinFieldMapping.ts (SORT_FIELD_MAP friendly->Jellyfin sort names) had
zero consumers — sort code passes raw Jellyfin field names directly — so it
and its test are deleted.

playbackUnits.ts can't be removed: its tick<->seconds helpers are still the
correct converters for the remote Jellyfin *session* boundary
(SessionInfo.playState.positionTicks, NowPlayingItem.runTimeTicks), which
legitimately arrives in ticks. Documented that narrowed role; formatTime/
calculateProgress remain neutral seconds-based presentation helpers.

Note (out of scope): sortBy still passes raw Jellyfin field names
("SortName", "CommunityRating") — a separate sort-taxonomy leak that would
need its own Rust SortKey, like the search-scope work.

Frontend 626 tests (jellyfinFieldMapping's 18 removed with it), check clean.
2026-07-23 22:13:54 +02:00
dtourolle 1968c06172 domain: neutral StreamKind for media streams (phase 4d)
Add StreamKind enum (audio/video/subtitle/other) to the domain module with
a total stream_kind_from_jellyfin mapper. MediaStream gains a kind field
(dual-carry), populated at the mapping seam. Frontend VideoPlayer track/
subtitle selection and the channel-video check now use stream.kind instead
of the Jellyfin stream.type string.

Rust 456 (+ stream_kinds_map test), frontend 644, check clean.
2026-07-23 22:11:31 +02:00
dtourolle ec8a7610f5 domain: player/reporting ticks -> milliseconds (phase 4c)
Playback position now crosses the IPC boundary in milliseconds. Ticks
survive only inside Rust (DB storage, Jellyfin API) and at the genuine
remote-session boundary (session seek / transfer / RemoteControls).

Rust command signatures (ms in, converted to ticks internally):
- storage_update_playback_progress / _context: position_ms
- repository_report_playback_start / _progress / _stopped: position_ms
- PlaybackProgress.position_ticks -> position_ms (converted in the query)

Frontend:
- playbackReporting, playerEvents, VideoPlayer, Queue, player/[id] resume:
  seconds*1000 / durationMs/1000 instead of tick math.
- repository-client + syncService param names -> positionMs.
- Tests updated to ms fixtures/assertions.

Out of scope (legitimately ticks): NowPlayingItem, PlayState.positionTicks,
sessionSeek, playbackModeTransferToLocal, RemoteControls, SessionCard — the
remote Jellyfin session API.

Rust 456, frontend 644, check clean.
2026-07-23 22:02:29 +02:00
dtourolle 93d198ce21 domain: primaryImageTag -> imageId end-to-end (phase 4a/4b)
Rust: PlayerMediaItem and MergedMediaItem gain image_id (dual-carry),
populated from primary_image_tag at every construction/conversion site.
Regenerated bindings.

Frontend: all catalog + player + merged readers now use imageId. The
NowPlayingItem->MediaItem bridge (player.ts) properly maps the remote
session's Jellyfin fields (Type, runTimeTicks, primaryImageTag) onto the
neutral kind/durationMs/imageId. Types that are genuinely out of scope
(Person, NowPlayingItem, PlayItemRequest) keep primaryImageTag.

Rust 456, frontend 644, check clean.
2026-07-23 21:40:58 +02:00
dtourolle 2b42b74912 domain: replace user-facing Jellyfin type badge with kind label (phase 3b)
The library detail page showed the raw Jellyfin item_type string
("MusicAlbum") to users. Add utils/mediaKind.ts with kindLabel(), a
presentation-only MediaKind -> human label map, and use it for the badge.
Flip the two remaining .type debug logs to .kind.

This removes the last user-visible Jellyfin vocabulary on the catalog
surface. primaryImageTag -> imageId rename (naming-only, ~40 sites across
catalog + player/merged types needing a Rust round-trip) intentionally
deferred as the lowest-value slice.

Frontend 644 tests, check clean.
2026-07-23 21:31:51 +02:00
dtourolle 7660a33dfc domain: catalog frontend off Jellyfin ticks -> milliseconds (phase 3a)
The catalog surface now speaks milliseconds, the app's neutral time unit.
Ticks no longer reach library/home components.

Rust:
- UserData gains playback_position_ms (dual-carry), populated from ticks
  at the offline mapping seam via domain::ticks_to_ms.

Frontend:
- formatDuration(duration.ts) and the two local copies now take ms, not
  ticks; all callers pass item.durationMs.
- Progress bars (EpisodeRow, EpisodeFocusView, MediaCard, LibraryListView)
  compute playbackPositionMs / durationMs — unit-consistent, no tick math.
- PlaylistDetailView totalDuration sums durationMs.
- duration.test.ts + TrackList.test.ts fixtures updated to ms.

Deferred: player/session/reporting tick math (Queue, SessionCard,
RemoteControls, playbackReporting, playerEvents) — those cross the
storage/Jellyfin command boundary in ticks and need command-signature
changes (phase 3b). Display {item.type} badge -> kind label (phase 4).

Rust 456, frontend 644, check + check:boundary clean.
2026-07-23 21:30:04 +02:00
dtourolle 3e962a202c test: update playerVisibility fixtures to .kind (phase 2a)
isVideoItem now reads item.kind, so the mini-player visibility fixtures
must set kind (track/movie/liveChannel) instead of the old Jellyfin
type strings. Renames channelItem -> liveChannelItem to match its kind.

All 644 frontend tests pass.
2026-07-23 21:16:42 +02:00
dtourolle 43ddc5a889 domain: flip isVideoItem in player store to .kind (phase 2a completion)
The catalog MediaItem check in stores/player.ts isVideoItem() was missed
in the phase 2a sweep (excluded by a player-path filter). It reads the
catalog MediaItem, so it flips like the rest: type Movie/Episode/TvChannel
-> kind movie/episode/liveChannel.

Verified: no catalog .type === "<JellyfinType>" comparisons remain in the
frontend (only stream.type in VideoPlayer, deferred to phase 4). check clean.
2026-07-23 21:14:45 +02:00
dtourolle 772e9ca6d5 domain: flip catalog frontend off Jellyfin item-type strings (phase 2a)
Migrate catalog MediaItem consumers from stringly item.type ("Audio",
"MusicAlbum", …) to the neutral item.kind enum across all classification
logic: home, library detail, player routing, artist/person/related/genre
components, tv store.

Model refinements found during migration (each a real distinction the
flat item_type collapsed):
- MediaKind::LiveChannel — live TV (playable, non-seekable) vs
- MediaKind::ChannelItem — channel VOD leaf (playable, seekable) vs
- MediaKind::Channel — channel container (drill-in).
  TvChannel->LiveChannel, non-folder ChannelFolderItem->ChannelItem.

RelatedItemsSection and GenreTags props migrated from Jellyfin type
strings to MediaKind; MediaKind re-exported from api/types.

Deferred by design: display {item.type} text, ResultsCounter labels,
Person.type (role), stream.type (phase 4), and all runTimeTicks/tick math
(coupled to playbackPositionTicks — phase 3). Old fields still dual-carried
so nothing breaks.

Rust 456 + 7 domain tests, frontend 644 tests, check clean.
2026-07-23 21:12:20 +02:00
dtourolle 55fa26377a domain: introduce provider-neutral media model (phase 1)
Establish src-tauri/src/domain/ as the single source of truth for the
media model, with all Jellyfin translation isolated in from_jellyfin.rs.
Adds MediaKind enum and neutral duration_ms/image_id fields to MediaItem
as additive, defaulted dual-carry alongside the legacy Jellyfin-named
fields, so nothing breaks while the frontend migrates off them.

- domain/media.rs: canonical MediaKind (closed enum, replaces stringly
  item_type), Default = Other so unknown/defaulted items are inert.
- domain/from_jellyfin.rs: total, panic-free item_type -> MediaKind
  classification (all audited types + person subroles) and ticks->ms.
- MediaItem gains kind/duration_ms/image_id, populated at both mapping
  seams (online to_media_item, offline cached_item_to_media_item) and
  the synthesized-album/person sites.
- Regenerated bindings.ts: frontend now HAS the neutral model available.

Phase 1 of docs/specs/frontend-domain-model.md. No frontend behaviour
change yet; wire shape is a superset of before.

Rust 456 tests, frontend 644 tests, check + check:boundary all green.
2026-07-23 20:53:47 +02:00
dtourolle f89b241ad6 docs: document frontend/backend boundary rule and spec workflow
Add the "domain vocabulary lives in Rust" principle, the
check:boundary pre-commit gate, and a spec-writing section pointing at
the spec template and review checklist.
2026-07-23 20:04:54 +02:00
dtourolle 8b028b6b60 docs: specs, requirements, ux-flows and traceability for new features
Add specs for the account menu, downloads-as-offline-library, offline
downloaded-only filter, and scoped search (+ boundary revision). Add the
new UR/DR entries to requirements.md, update ux-flows, and regenerate the
traceability matrix.

TRACES: UR-049, UR-050, UR-052, UR-053, UR-054, UR-055, UR-056
2026-07-23 20:04:35 +02:00
dtourolle dd9d4191f1 chore(ci): add frontend boundary tripwire script
Grep-based tripwire flagging multi-type includeItemTypes literals in the
Svelte frontend — the machine-detectable signature of the item-type
taxonomy leaking into presentation. Wire it into the Gitea build-and-test
workflow and a check:boundary package script. Motivated by
docs/specs/scoped-search-boundary.md.
2026-07-23 20:04:35 +02:00
dtourolle bacb9ca0bb docs(traces): tag episode focus view and library detail with TRACES
Add TRACES comments linking the episode focus view and library detail
page to their existing requirements.

TRACES: UR-035, UR-038, UR-048 | DR-043, DR-061, DR-062
2026-07-23 20:03:16 +02:00
dtourolle cf9472f04f feat(chrome): shared account menu and global app header
Move account actions (Settings, Downloads, Display preferences, Sign
out) out of the library-only header into a shared AccountMenu anchored in
a global AppHeader, available on every authenticated non-immersive
screen. Add a layoutShell helper deciding where chrome shows, expose
serverName/serverUrl auth stores, and a display view-mode preference. The
settings page also gains the UR-053 WiFi-only toggle.

TRACES: UR-054 | DR-075, DR-076, DR-077
2026-07-23 20:03:12 +02:00
dtourolle f25deba824 feat(downloads): browsable downloaded library with on-disk usage
Replace the flat download list with a Downloaded browse surface that
reuses the online grids/cards/detail pages, filtered to on-device media,
plus a demoted Transfers tab. Add repository browse commands
(getDownloadedLibraries/Items, disk usage) with offline/hybrid
implementations, a downloadedCatalog service, formatBytes helper, and
per-item/device disk-usage labels on cards and grids. Regenerated
bindings.

Also carries the inseparable UR-052 offline-filter hunks in
offline.rs/hybrid.rs.

TRACES: UR-055 | DR-081, DR-082, DR-083, DR-084; UR-056 | DR-085
2026-07-23 20:02:55 +02:00
dtourolle 8f4f651bac fix(offline): gate library listing to downloaded-only when offline (#10)
The connectivity store now drives the "downloaded only" view so an
offline library page shows just on-device media, with the server catalog
revealed only when "Show all server media" is toggled.

TRACES: UR-052 | DR-078, DR-079
2026-07-23 20:02:33 +02:00
dtourolle c175378f38 feat(search): context-scoped search with filter chips and group order
Add a search scope (all/music/shows/movies) resolved from the entry
route and adjustable via filter chips, threaded through the library
store's search() into includeItemTypes. Results group by type in a
user-configurable order, editable from settings.

TRACES: UR-049 | DR-063, DR-064, DR-065; UR-050 | DR-066, DR-067
2026-07-23 20:02:15 +02:00
dtourolle e083b53ee8 feat(downloads): WiFi-only network-type-aware download gating
Add a metered/cellular network detector so downloads honour a "WiFi
only" preference. Android reports network type via NetworkTypeMonitor;
Rust exposes it through download/network.rs and holds the queue pump when
on a metered connection, emitting a queue-wide waitingForNetwork event.
The frontend surfaces this via the networkType service and a
waitingForNetwork store flag.

TRACES: UR-053 | DR-074
2026-07-23 20:02:07 +02:00
dtourolle 8f8433eebe ci: remove superseded traceability workflow
traceability.yml duplicated the coverage check now owned by
traceability-check.yml, running the same extraction on every push and PR
to master/main/develop. Dead CI: nothing references it, and keeping both
doubled runner time for one result.
2026-07-23 19:17:40 +02:00
dtourolle 6f057ad14a ci: fix Bad substitution in docs publish step
The gitea-pages push step used ${GITHUB_SHA::8}, a bash-only substring
expansion. The Gitea runner executes run: blocks with /bin/sh (dash),
which rejects it with "Bad substitution" and exits 2, failing the job
after the site had already built successfully.

Use cut(1) to shorten the SHA instead, which is POSIX sh compatible.
2026-07-23 19:13:29 +02:00
130 changed files with 9079 additions and 2254 deletions
+7
View File
@@ -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
+3 -1
View File
@@ -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
-172
View File
@@ -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
});
+21
View File
@@ -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
+60 -19
View File
@@ -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=="],
+20 -8
View File
@@ -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
+51
View File
@@ -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 |
---
+68
View File
@@ -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.
+105
View File
@@ -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
<!-- 24 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.
-->
+164
View File
@@ -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.21.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.21.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.
+166
View 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.27.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, 23
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.27.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-078080). Coordinate on [downloads/+page.svelte](../../src/routes/downloads/+page.svelte)
and the repository layer; check `git diff` before repairing unexpected changes.
+309
View File
@@ -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.
+273
View File
@@ -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.
+202
View File
@@ -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.
+8 -8
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+545 -89
View File
@@ -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/&#91;id&#93;] --> 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 &#40;/&#41;| All[Scope: All]
Where -->|Library root &#40;/library&#41;| 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 &#40;default&#41;| 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 23 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
View File
@@ -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",
+85
View File
@@ -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
}
}
+186
View File
@@ -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 {
+13 -4
View File
@@ -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,
+2
View File
@@ -211,6 +211,7 @@ pub async fn player_add_track_by_id(
artist_items: track.artist_items.clone(), // For clickable artist links
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),
+57 -3
View File
@@ -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)
+17 -9
View File
@@ -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"));
+180
View File
@@ -0,0 +1,180 @@
//! Jellyfin → domain translation.
//!
//! The ONLY place Jellyfin's vocabulary touches the domain model. Adding a
//! second provider later means a sibling `from_<provider>.rs`; the domain types
//! and every consumer stay untouched.
//!
//! Spec: docs/specs/frontend-domain-model.md
use super::media::{MediaKind, StreamKind};
/// Classify a Jellyfin media-stream `Type` string into a neutral [`StreamKind`].
/// Total and panic-free.
pub fn stream_kind_from_jellyfin(stream_type: &str) -> StreamKind {
match stream_type {
"Audio" => StreamKind::Audio,
"Video" => StreamKind::Video,
"Subtitle" => StreamKind::Subtitle,
_ => StreamKind::Other,
}
}
/// Jellyfin ticks per second (10 million). A tick is 100 ns.
/// The frontend must never see ticks — this is where they die.
const TICKS_PER_MILLISECOND: i64 = 10_000;
/// Convert a Jellyfin `RunTimeTicks` value to milliseconds.
///
/// Domain durations are milliseconds; ticks are a Jellyfin unit and stop here.
pub fn ticks_to_ms(ticks: i64) -> i64 {
ticks / TICKS_PER_MILLISECOND
}
/// Classify a Jellyfin `Type` string into a neutral [`MediaKind`].
///
/// **Total and panic-free**: any unrecognised string maps to [`MediaKind::Other`]
/// rather than failing. `is_folder` disambiguates the one Jellyfin type
/// (`ChannelFolderItem`) whose kind depends on whether it is a container.
///
/// The recognised set is every `item_type` the frontend audit found in use
/// (docs/specs/frontend-domain-model.md), plus the common cast/crew person
/// subtypes Jellyfin returns in `People[].Type`.
pub fn kind_from_jellyfin(item_type: &str, is_folder: bool) -> MediaKind {
match item_type {
// Music
"Audio" | "MusicVideo" => MediaKind::Track,
"MusicAlbum" => MediaKind::Album,
"MusicArtist" | "AlbumArtist" => MediaKind::Artist,
"Playlist" => MediaKind::Playlist,
// Video
"Movie" => MediaKind::Movie,
"Series" => MediaKind::Series,
"Season" => MediaKind::Season,
"Episode" => MediaKind::Episode,
// A bare video leaf with no richer classification.
"Video" => MediaKind::Movie,
// Cast / crew — Jellyfin uses both a "Person" item type and role-typed
// people (Actor/Director/Writer/Composer/…) in People[].Type.
"Person" | "Actor" | "Director" | "Writer" | "Composer" | "GuestStar" | "Producer" => {
MediaKind::Person
}
// A live TV channel: playable, but a non-seekable live stream.
"TvChannel" | "LiveTvChannel" => MediaKind::LiveChannel,
// A bare channel is a container the user drills into.
"Channel" => MediaKind::Channel,
// Containers
"Folder" | "CollectionFolder" | "UserView" | "BoxSet" => MediaKind::Folder,
// ChannelFolderItem is a container when it is a folder, else a playable
// channel leaf (distinct kind so the UI can route it to playback).
"ChannelFolderItem" => {
if is_folder {
MediaKind::Folder
} else {
MediaKind::ChannelItem
}
}
// Unknown → safe sink. Never panics.
_ => {
if is_folder {
MediaKind::Folder
} else {
MediaKind::Other
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ticks_convert_to_milliseconds() {
// 1 second = 10,000,000 ticks = 1000 ms
assert_eq!(ticks_to_ms(10_000_000), 1000);
// 90.5 s
assert_eq!(ticks_to_ms(905_000_000), 90_500);
assert_eq!(ticks_to_ms(0), 0);
// Sub-millisecond truncates toward zero, not panics.
assert_eq!(ticks_to_ms(9_999), 0);
}
#[test]
fn music_types_map() {
assert_eq!(kind_from_jellyfin("Audio", false), MediaKind::Track);
assert_eq!(kind_from_jellyfin("MusicAlbum", true), MediaKind::Album);
assert_eq!(kind_from_jellyfin("MusicArtist", true), MediaKind::Artist);
assert_eq!(kind_from_jellyfin("Playlist", true), MediaKind::Playlist);
}
#[test]
fn video_types_map() {
assert_eq!(kind_from_jellyfin("Movie", false), MediaKind::Movie);
assert_eq!(kind_from_jellyfin("Series", true), MediaKind::Series);
assert_eq!(kind_from_jellyfin("Season", true), MediaKind::Season);
assert_eq!(kind_from_jellyfin("Episode", false), MediaKind::Episode);
assert_eq!(kind_from_jellyfin("Video", false), MediaKind::Movie);
}
#[test]
fn person_and_role_types_map_to_person() {
for t in ["Person", "Actor", "Director", "Writer", "Composer"] {
assert_eq!(kind_from_jellyfin(t, false), MediaKind::Person, "{t}");
}
}
#[test]
fn channel_and_container_types_map() {
assert_eq!(
kind_from_jellyfin("TvChannel", false),
MediaKind::LiveChannel
);
assert_eq!(kind_from_jellyfin("Channel", false), MediaKind::Channel);
assert_eq!(
kind_from_jellyfin("CollectionFolder", true),
MediaKind::Folder
);
assert_eq!(kind_from_jellyfin("BoxSet", true), MediaKind::Folder);
}
#[test]
fn channel_folder_item_disambiguates_on_is_folder() {
assert_eq!(
kind_from_jellyfin("ChannelFolderItem", true),
MediaKind::Folder
);
assert_eq!(
kind_from_jellyfin("ChannelFolderItem", false),
MediaKind::ChannelItem
);
}
#[test]
fn stream_kinds_map() {
assert_eq!(stream_kind_from_jellyfin("Audio"), StreamKind::Audio);
assert_eq!(stream_kind_from_jellyfin("Video"), StreamKind::Video);
assert_eq!(stream_kind_from_jellyfin("Subtitle"), StreamKind::Subtitle);
assert_eq!(
stream_kind_from_jellyfin("EmbeddedImage"),
StreamKind::Other
);
assert_eq!(stream_kind_from_jellyfin(""), StreamKind::Other);
}
#[test]
fn unknown_type_never_panics_and_falls_back() {
// The whole point: garbage in, safe kind out, no panic.
assert_eq!(kind_from_jellyfin("Epis0de", false), MediaKind::Other);
assert_eq!(kind_from_jellyfin("", false), MediaKind::Other);
assert_eq!(
kind_from_jellyfin("SomeFutureType", true),
MediaKind::Folder
);
assert_eq!(kind_from_jellyfin("🎵unicode", false), MediaKind::Other);
}
}
+91
View File
@@ -0,0 +1,91 @@
//! Canonical, provider-neutral media domain model.
//!
//! This is the *single source of truth* for what a media item is across the
//! whole app. Rust (repositories, player, downloads) uses these types directly;
//! the frontend consumes the tauri-specta-generated projection in
//! `src/lib/api/bindings.ts`. There is no second hand-written copy in either
//! language, so the model cannot drift.
//!
//! No provider (Jellyfin) vocabulary belongs in this file. Translation from a
//! provider's wire shape lives beside it in `from_jellyfin.rs` and is the only
//! place provider terms touch the domain type.
//!
//! Spec: docs/specs/frontend-domain-model.md
use serde::{Deserialize, Serialize};
/// The kind of a media item — provider-neutral classification.
///
/// Replaces the stringly-typed `item_type` that carried Jellyfin's vocabulary
/// (`"Audio"`, `"MusicAlbum"`, …) across the boundary. A closed enum means a
/// typo or an unhandled kind is a compile error on the frontend, not a silent
/// runtime miss across ~127 comparison sites.
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum MediaKind {
// Music
Track,
Album,
Artist,
Playlist,
// Video
Movie,
Series,
Season,
Episode,
// Cast/crew
Person,
// Containers / live TV
/// A channel *container* the user drills into (Jellyfin `Channel`).
Channel,
Folder,
/// A live TV channel — playable, but a live stream with no seekable
/// timeline (no resume/seek). Jellyfin `TvChannel`/`LiveTvChannel`.
LiveChannel,
/// A playable leaf inside a channel (Jellyfin `ChannelFolderItem` that is
/// not itself a folder) — e.g. a plugin-channel VOD item that has no
/// dedicated item type but carries its own media streams. Playable and
/// seekable, unlike `LiveChannel`. Distinct from `Channel` (the container)
/// and from `Other` so the UI can route it to playback.
ChannelItem,
/// A kind we do not model explicitly. Reached only for provider item types
/// that map to nothing meaningful; consumers treat it like an opaque
/// container. The mapping must be *total* — it never panics — so this is the
/// safe sink for unknown strings. Also the `Default`, so a defaulted
/// `MediaItem` (see the dual-carry migration) is inert rather than a lie.
#[default]
Other,
}
/// The kind of a media stream within an item (audio track, video track,
/// subtitle, …) — provider-neutral, replacing the stringly Jellyfin stream type.
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum StreamKind {
Audio,
Video,
Subtitle,
/// Any stream kind we do not model explicitly (e.g. embedded image, data).
#[default]
Other,
}
impl MediaKind {
/// True for kinds that are containers/collections rather than playable leaves.
/// Presentation-neutral helper the backend can use for e.g. drill-vs-play.
// Consumed by later migration phases (drill-vs-play routing); kept now so the
// domain surface is complete alongside the type it describes.
#[allow(dead_code)]
pub fn is_container(self) -> bool {
matches!(
self,
MediaKind::Album
| MediaKind::Artist
| MediaKind::Series
| MediaKind::Season
| MediaKind::Playlist
| MediaKind::Channel
| MediaKind::Folder
)
}
}
+10
View File
@@ -0,0 +1,10 @@
//! Canonical, provider-neutral domain model — the single source of truth for
//! the app's core data shapes, shared with the frontend via generated bindings.
//!
//! Spec: docs/specs/frontend-domain-model.md
pub mod from_jellyfin;
pub mod media;
pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
pub use media::{MediaKind, StreamKind};
+24 -2
View File
@@ -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;
+5
View File
@@ -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)]
+1
View File
@@ -8,6 +8,7 @@
pub mod cache;
pub mod events;
pub mod network;
pub mod worker;
use crate::utils::lock::MutexSafe;
+196
View File
@@ -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\""
);
}
}
+21
View File
@@ -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();
+2
View File
@@ -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),
+3
View File
@@ -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),
+14 -1
View File
@@ -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),
+4
View File
@@ -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,
+1
View File
@@ -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),
+2
View File
@@ -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),
+1
View File
@@ -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),
+130
View File
@@ -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
+477 -5
View File
@@ -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();
+10 -1
View File
@@ -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,
+70 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
*/
+41 -1
View File
@@ -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,
});
});
});
+32 -7
View File
@@ -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) =====
+1
View File
@@ -14,6 +14,7 @@ export type {
Library,
LiveStreamInfo,
MediaItem,
MediaKind,
MediaSource,
MediaStream,
Person,
+77
View File
@@ -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>
-91
View File
@@ -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}
+6 -6
View File
@@ -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'}"
+4 -4
View File
@@ -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"
+16 -13
View File
@@ -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>
+15 -3
View File
@@ -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}
+63 -6
View File
@@ -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"
+2 -2
View File
@@ -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>
+4 -4
View File
@@ -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,
},
];
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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"
+6 -6
View File
@@ -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;
}
+19 -13
View File
@@ -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"
+31 -110
View File
@@ -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>
+116
View File
@@ -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.27.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);
+156
View File
@@ -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);
});
});
});
+109
View File
@@ -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;
}
}
+94
View File
@@ -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();
});
});
+1 -1
View File
@@ -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";
+9 -9
View File
@@ -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);
+9 -9
View File
@@ -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");
+1 -1
View File
@@ -170,7 +170,7 @@ function handlePositionUpdate(position: number, duration: number): void {
* with 0 when runTimeTicks is missing (which would zero the slider's max).
*/
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) {
+3 -3
View File
@@ -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 });
}
/**
+2
View File
@@ -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);
+75
View File
@@ -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);
});
});
+14 -2
View File
@@ -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);
+29
View File
@@ -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");
+30 -1
View File
@@ -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);
}
}
+20 -3
View File
@@ -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
]);
+122
View File
@@ -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