From 1b70926c3686d20841a36b1433253b83671ad899 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 9 Aug 2026 16:38:07 +0200 Subject: [PATCH] feat(offline): play downloaded video, and drain the offline sync queue (0.4.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles this session's work plus the concurrent search/offline/player changes. Every gate passes on the combined tree: 885 frontend tests, 610 Rust tests, clippy clean, boundary clean, trace coverage 86%. Offline video playback — four separate defects, each of which alone stopped it: DR-133 A completed download's file_path is already absolute (the worker rewrites it on completion), but the player rooted it a second time and handed the webview /data/user/0/app//data/user/0/app/videos/x.mp4. DR-134 The asset protocol was never enabled: no protocol-asset feature and no assetProtocol config, so convertFileSrc produced URLs nothing answered. Also silently defeated the cached-thumbnail path, which fails soft to the server copy and hid it whenever the server was reachable. DR-137 Tauri's asset protocol answers a range-less request by reading the whole file into memory, and only advertises Accept-Ranges from inside its range branch, so the first request never learns ranges exist. Chromium gave up with PIPELINE_ERROR_READ after ~31s. Local media is now served by a loopback HTTP server: bounded 4 MiB chunks streamed from the file handle, every response length-delimited, and a range-less request answered with one chunk rather than the file. Confined by a per-session token and to the app data directory, because loopback is shared between apps on Android. DR-138 Release builds set usesCleartextTraffic=false, so Android rejected the request to that server before any I/O. A network-security-config exempts 127.0.0.1 only; a remote server must still be HTTPS. Downloads: DR-135 download_item never records media_type and the reconnect resolver read that NULL as 'audio', so a movie queued from a media card had its URL resolved by get_audio_stream_url and completed as an audio-only transcode. The item's own type now decides. DR-136 Rows already downloaded that way are requeued on reconnect, since prevention alone leaves them reading "downloaded" and still unplayable. Known limitation: a download taken at `original` quality is a byte copy of the source, so it can be any container. One such file is an AVI holding XVID, which the webview cannot play in any case — the media server serves it correctly and Chromium refuses it. That needs either a transcoded download preset or the native ExoPlayer surface work, and is not addressed here. Also fixes two ID collisions between concurrent work: DR-143 defined twice (search vs offline gate) and UT-131 defined twice (Episode Focus hero vs channel cap). The search requirement is now DR-147 and the channel-cap test UT-141, with their code references and matrix rows updated. --- docs/requirements.md | 43 +- docs/traceability.md | 6535 +++++++++++------ package.json | 2 +- scripts/extract-traces.test.ts | 22 +- scripts/sync-android-sources.sh | 10 + src-tauri/Cargo.lock | 33 +- src-tauri/Cargo.toml | 3 +- .../android/src/main/AndroidManifest.xml | 1 + .../main/res/xml/network_security_config.xml | 23 + src-tauri/src/commands/catalog.rs | 246 +- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/storage/mod.rs | 24 + src-tauri/src/commands/sync.rs | 44 +- src-tauri/src/commands/sync_drain.rs | 838 +++ src-tauri/src/lib.rs | 28 + src-tauri/src/media_server.rs | 588 ++ src-tauri/src/player/mod.rs | 3 + src-tauri/src/repository/device_profile.rs | 4 +- src-tauri/src/repository/hybrid.rs | 13 + src-tauri/src/repository/mod.rs | 8 + src-tauri/src/repository/offline.rs | 6 + src-tauri/src/repository/online.rs | 42 + src-tauri/tauri.conf.json | 2 +- src/lib/api/bindings.ts | 54 +- src/lib/components/AppHeader.svelte | 21 +- src/lib/components/Search.svelte | 10 +- .../library/EpisodeFocusView.svelte | 121 +- .../library/EpisodeFocusView.test.ts | 175 + .../library/GenericGenreBrowser.svelte | 9 +- .../library/GenericMediaListPage.svelte | 6 + .../library/seriesNavigation.test.ts | 13 + .../components/library/seriesNavigation.ts | 17 +- src/lib/components/search/HeaderSearch.svelte | 88 + .../components/search/HeaderSearch.test.ts | 110 + .../components/sync/PendingSyncList.svelte | 137 + .../components/sync/PendingSyncModal.svelte | 57 + .../useOfflineFilterReload.test.ts | 55 + src/lib/composables/useOfflineFilterReload.ts | 47 + .../services/offlineCatalog.reload.test.ts | 162 + src/lib/services/offlineCatalog.ts | 42 +- src/lib/services/pendingSync.logic.test.ts | 85 + src/lib/services/pendingSync.logic.ts | 79 + src/lib/services/syncService.ts | 17 +- src/lib/utils/layoutShell.test.ts | 23 + src/lib/utils/layoutShell.ts | 17 + src/lib/utils/searchScope.test.ts | 66 + src/lib/utils/searchScope.ts | 54 +- src/routes/+layout.svelte | 55 +- src/routes/library/+layout.svelte | 57 +- src/routes/library/[id]/+page.svelte | 109 +- src/routes/library/favorites/+page.svelte | 3 + src/routes/library/movies/+page.svelte | 3 + src/routes/library/music/+page.svelte | 3 + src/routes/library/tv/+page.svelte | 3 + src/routes/player/[id]/+page.svelte | 18 +- src/routes/search/+page.svelte | 97 +- src/routes/search/searchPage.test.ts | 129 + src/routes/settings/+page.svelte | 15 +- 58 files changed, 8130 insertions(+), 2347 deletions(-) create mode 100644 src-tauri/android/src/main/res/xml/network_security_config.xml create mode 100644 src-tauri/src/commands/sync_drain.rs create mode 100644 src-tauri/src/media_server.rs create mode 100644 src/lib/components/library/EpisodeFocusView.test.ts create mode 100644 src/lib/components/search/HeaderSearch.svelte create mode 100644 src/lib/components/search/HeaderSearch.test.ts create mode 100644 src/lib/components/sync/PendingSyncList.svelte create mode 100644 src/lib/components/sync/PendingSyncModal.svelte create mode 100644 src/lib/composables/useOfflineFilterReload.test.ts create mode 100644 src/lib/composables/useOfflineFilterReload.ts create mode 100644 src/lib/services/offlineCatalog.reload.test.ts create mode 100644 src/lib/services/pendingSync.logic.test.ts create mode 100644 src/lib/services/pendingSync.logic.ts create mode 100644 src/routes/search/searchPage.test.ts diff --git a/docs/requirements.md b/docs/requirements.md index 1a3e2fe5..7ebf6038 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -166,6 +166,7 @@ API endpoints and data contracts required for Jellyfin integration. | JA-032 | Get audio-only stream URL for a video item (selected audio-stream index) | MediaInfo | UR-040 | Done | | JA-033 | Query favourite items (`Filters=IsFavorite`, recursive, scoped by item type) | Items | UR-067 | Done | | JA-034 | Read `UserData` (favourite, played, resume position) from item responses | UserData | UR-069 | Done | +| JA-035 | Mark item played (`POST /Users/{userId}/PlayedItems/{itemId}`) | UserData | UR-025 | Done | ### 2.3 Development Requirements @@ -299,12 +300,21 @@ Internal architecture, components, and application logic. | DR-128 | Audio-only playback of *downloaded* media reads the local file rather than fetching an audio-only stream. No transcode is involved or wanted: the Linux backend already runs MPV with `video: no`, so handing it the downloaded video file decodes the audio track and ignores the video, and ExoPlayer disables its video renderer equivalently. Transcoding to a separate audio artifact would cost CPU and battery, need an encoder the project does not ship, and produce a second file to keep in step — for no gain over simply not decoding the video | Playback | UR-071 | Done | | DR-129 | A stream that stops delivering is recovered, not treated as terminal. Two failure shapes, because the streams differ. (a) *Phantom end* — the background audio-only handoff uses a progressive mp3 transcode over plain HTTP, chunked and therefore length-less, so a dropped connection reaches the player as end-of-input and ExoPlayer reports `STATE_ENDED` indistinguishably from the real end. The item's runtime is the only thing that can tell them apart: an end reported more than a tolerance short of it (comparing the *absolute* position — handoff base plus the player's relative position) is a truncation. Left unhandled, playback parked in `STATE_ENDED` and the next play intent from the lockscreen, notification or a Bluetooth reconnect seeks an ended player to position 0 — the user-visible "the episode randomly restarted". (b) *Recoverable error* — music (`/Audio/{id}/stream?Static=true`) and video (`/Videos/{id}/master.m3u8`) declare their length, so the player detects the truncation itself and raises an error; the frontend's handler stopped playback outright, turning a hiccup into silence. Both resume the current item **in place** (never via `play_item`, which would replace the queue with a single item and lose the album), the error path after a per-attempt backoff. Seekable streams are re-prepared at the URL they already have and seeked; the length-less transcode, which cannot be seeked, has `StartTimeTicks` rewritten into its existing URL so the user's audio-track selection survives and recovery needs no network round-trip. Only `Remote` sources qualify — a local file cannot fail from the network. A shared budget of consecutive attempts at the same position, refilled whenever playback progresses, stops an unreachable server from looping | Playback | UR-040, UR-004 | Done | | DR-130 | A backend's position and duration must survive the end of the file they describe. MPV exposes `time-pos`/`duration` as properties of the *loaded* file, so at EOF it unloads and both stop resolving — the accessors reported `0.0`/unknown at exactly the moment end-of-file handling asks where playback reached, and any position-versus-runtime check would have read every natural end as a truncation. The poll thread records the last reading and the accessors fall back to it. Linux resilience is layered on the same principle that the stream, not the player, is what failed: MPV is configured with ffmpeg reconnection (`stream-lavf-o`, `network-timeout`) so ordinary blips never surface, and `EndFile(ERROR)` — previously a bare log, which left playback halted while the UI still showed "playing" — is emitted as a *recoverable* error. Because MpvBackend is constructed before `PlayerController` exists, it cannot decide in-process like the Android JNI callback: the frontend echoes the error into `player_recover_stream`, which keeps the decision in Rust (the same shape as `PlaybackEnded` → `player_on_playback_ended`). Android reports errors it has already declined as *unrecoverable*, so the echo never asks twice | Playback | UR-004, UR-040 | Done | -| DR-140 | An audio track is pinned only when the user picked one. Jellyfin's `MediaStream.Index` is global across every stream in a media source, so index 0 is the *video* stream on virtually all files — yet `AudioStreamIndex=0` was sent as "the first audio track" on the HLS transcode URL, the background audio-only handoff URL, the direct-play fallback URL, and the `PlaybackInfo` negotiation body. A server that honours the request literally then transcodes the video stream into the audio slot and the result plays as a picture with no sound; only servers that silently correct the index hid the bug, which is why it presented as "some videos have no audio". The parameter is now omitted whenever no track has been chosen, so the server resolves the source's `DefaultAudioStreamIndex`; an explicit selection from `player_switch_audio_track` is still carried through unchanged. On the `static=true` direct-play URL it is dropped outright — the original file is served untouched, so the parameter could only mislead | Playback | UR-004, UR-040 | Done | -| DR-141 | One search input per screen, and the URL is the search's single source of truth. The header bar rendered only under `/library/**` and merely *navigated* to `/search` (DR-063), so a desktop search handed the user to a screen whose input was a different element — the header box cleared itself and vanished, and the page's own box took over mid-word. That page then re-derived its input from `?q=` against `library.searchQuery` on every store write, so the next keystroke re-ran the effect and snapped the text back to the query the header had sent (and a scope chip back to the URL's scope); entering from the bottom-nav Search tab skipped it only because the effect early-returned on an empty query. The bar now renders on `/search` too (`showHeaderSearch`) and is the sole md+ input — the page's own input is `md:hidden` — and on that route it republishes the query into the URL with `replaceState`, so a whole session of typing costs one history entry. The page *consumes* that URL once per distinct value (`seedFromSearchUrl` against a non-reactive `applied` marker) instead of continuously reconciling it, and the scope chips publish through the same URL so the bar and the chips cannot disagree. Landing on `/search` with a seeded query focuses the bar and puts the caret at the end, because the box the user was typing in belonged to the unmounted route | UI | UR-049, UR-054 | Done | +| DR-131 | The offline mutation queue is drained. `sync_queue` had producers and no consumer: `PlaybackReporter::queue_for_sync` writes a row for every start/stop/mark-played that cannot reach the server, `sync_mark_processing`/`_completed`/`_failed` were registered commands with no callers, and no Rust task processed the table — so queued watch positions never reached Jellyfin and the offline banner's count only ever grew. A drain hangs off the same `connectivity:reconnected` transition as DR-120 (in Rust, because a drain started by a component dies with it) and replays rows oldest-first, so a stale start cannot move the server's resume position backwards after a later stop. `update_progress` replays as *stopped at N* rather than as progress — replaying a mid-playback report hours later would claim the item is still playing — and payloads are read in both dialects that exist in users' databases (`position_ticks` from Rust, camelCase `positionMs` from the frontend helper). A failed row stays queued for the next reconnect; after `MAX_SYNC_ATTEMPTS` it is `abandoned` and stops counting, because a row nothing can ever push is what turns the queue into a counter that only grows. An *unreachable* server is not counted as an attempt at all — the row goes back to `pending` untouched — so opening the app offline a few times cannot abandon good rows; only a server that answers and refuses spends the budget. The drain also runs once at startup, because a queue built in a previous session would otherwise sit untouched for a whole run whenever the server was reachable the entire time and no offline→online transition ever fired. Requires `MediaRepository::mark_played` (JA-035) — the previous stand-in reported a stop at `i64::MAX` | Backend | UR-025, UR-002 | Done | +| DR-132 | The pending-sync count is answerable. The offline banner's badge read "N pending sync(s)" and led nowhere, so it was taken for pending *transfers* and looked for on the Downloads page — which lists the `downloads` table and structurally cannot show `sync_queue` rows. The badge becomes a button opening the queue it counts: each row's operation, the item's title (resolved by a `LEFT JOIN items` in `sync_get_pending`, not a per-row frontend fetch), when it was queued, and the error of anything failing, plus a "Sync now" that runs the DR-131 drain on demand. The same list is a Settings section, because a row that keeps failing is still queued when the server is reachable and no banner is on screen. The drain emits `sync-queue-changed` so the badge updates on reconnect instead of lagging by up to one 10s poll | UI | UR-025 | Done | | DR-133 | A downloaded file has exactly one on-disk path, and the row that names it is authoritative. `downloads.file_path` starts relative to the storage root, but the worker rewrites it to the absolute path it actually wrote when the transfer completes — so a *completed* row is already rooted. The video player's offline branch rooted it a second time, handing the asset protocol `/data/user/0/app//data/user/0/app/videos/x.mp4`; the webview reported `MEDIA_ERR_SRC_NOT_SUPPORTED` with `NETWORK_NO_SOURCE`, so every downloaded video failed to play while audio — which resolves the same column through Rust's `resolve_local_media_path`, without re-rooting — played fine. The join is absolute-aware (POSIX, Windows drive letters and UNC) so rows written before completion still resolve | Playback | UR-071 | Done | | DR-134 | The webview can actually fetch the local files it is handed. `convertFileSrc` rewrites a path to `http://asset.localhost/…` unconditionally, but Tauri only answers that origin when the `protocol-asset` cargo feature is compiled in *and* `app.security.assetProtocol.enable` is set — neither was, so every such URL reached a protocol with no handler and the webview reported `NETWORK_NO_SOURCE`. This silently defeated both offline video (`