Merge branch 'master' into worktree-mosaic-library
Renumbers the mosaic's requirement IDs out of the way of the download work that landed on master in parallel: it had already claimed DR-163/DR-164 and UT-162, so the mosaic layout is now DR-172, the library favourites scope DR-173, and its composition test UT-167. Note for the download branch: its UT-162..UT-165 rows trace to DR-163..DR-166, none of which are defined in requirements.md — that branch defined DR-167..171 instead. Those references are orphaned and want a look; nothing here touches them.
This commit is contained in:
+21
-11
@@ -324,9 +324,14 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-155 | A watch position set on another device reaches this one. The resume check reads the local `user_data` row and nothing else, but `mirror_user_data` — the only path by which server `UserData` lands in that table — mirrored `is_favorite` alone, and returned early whenever that field was absent, which is exactly the shape of an ordinary watched episode. So `playback_position_ticks` was write-only from this device's perspective: watch 40 minutes in a browser, open JellyTau, and it resumed from whatever *this* device last saw or offered no resume at all — the same user-visible symptom as DR-150's Android bug, from an unrelated cause, which is why resume read as broadly flaky. The mirror now carries the position alongside the favourite flag under the same `pending_sync = 0` conflict rule, so a local position still waiting to be pushed is never pulled *backwards* by a server that has not yet heard where we got to; `COALESCE(excluded.x, user_data.x)` means a field the server omitted keeps its stored value rather than being nulled, and a row with neither field is still skipped rather than fabricated as zeroes. Mirroring alone was not sufficient: `get_item` — the call the player route makes — returned the cached copy on a hit and never consulted the server, so for an already-cached item the mirror never ran. It now refreshes in the background on a cache hit (`race_with_refresh`, the reusable form of what `get_items` already did inline), which is why browsing a season picked up other devices' state while opening the episode directly did not. The refreshed value lands for the next read, the cache-first race still answering immediately | Backend | UR-025, UR-002 | Done |
|
| DR-155 | A watch position set on another device reaches this one. The resume check reads the local `user_data` row and nothing else, but `mirror_user_data` — the only path by which server `UserData` lands in that table — mirrored `is_favorite` alone, and returned early whenever that field was absent, which is exactly the shape of an ordinary watched episode. So `playback_position_ticks` was write-only from this device's perspective: watch 40 minutes in a browser, open JellyTau, and it resumed from whatever *this* device last saw or offered no resume at all — the same user-visible symptom as DR-150's Android bug, from an unrelated cause, which is why resume read as broadly flaky. The mirror now carries the position alongside the favourite flag under the same `pending_sync = 0` conflict rule, so a local position still waiting to be pushed is never pulled *backwards* by a server that has not yet heard where we got to; `COALESCE(excluded.x, user_data.x)` means a field the server omitted keeps its stored value rather than being nulled, and a row with neither field is still skipped rather than fabricated as zeroes. Mirroring alone was not sufficient: `get_item` — the call the player route makes — returned the cached copy on a hit and never consulted the server, so for an already-cached item the mirror never ran. It now refreshes in the background on a cache hit (`race_with_refresh`, the reusable form of what `get_items` already did inline), which is why browsing a season picked up other devices' state while opening the episode directly did not. The refreshed value lands for the next read, the cache-first race still answering immediately | Backend | UR-025, UR-002 | Done |
|
||||||
| DR-156 | A page no longer inherits the previous page's scroll position. The shell keeps its scrollers alive across navigation by design — the root layout, the home page and the library layout each own a `flex-1 overflow-y-auto` box that outlives the route inside it, which is what lets `BottomUi` be a flex sibling rather than a measured overlay — but the element therefore never remounts and its `scrollTop` survives the route change. SvelteKit's own scroll restoration could not help: it saves and restores `window` scroll, and in this app the window never scrolls at all, so there was no scroll handling of any kind. The symptom was that opening an item from half-way down a library grid dropped the viewer half-way down the detail page, and returning to the grid landed at the top of it — exactly backwards. `ScrollMemory` (pure, one instance per container, keyed on path + query so a genre-filtered grid keeps its own place) records the offset a route is left at in `beforeNavigate` and decides in `afterNavigate`: `link`/`goto`/`form` reset to the top, `popstate` restores that route's saved offset, and the initial `enter` is left alone. Deciding does not consume the offset, so a route returned to more than once restores each time. Applied via the `scrollContainer` action on all three scrollers | UI | UR-072 | Done |
|
| DR-156 | A page no longer inherits the previous page's scroll position. The shell keeps its scrollers alive across navigation by design — the root layout, the home page and the library layout each own a `flex-1 overflow-y-auto` box that outlives the route inside it, which is what lets `BottomUi` be a flex sibling rather than a measured overlay — but the element therefore never remounts and its `scrollTop` survives the route change. SvelteKit's own scroll restoration could not help: it saves and restores `window` scroll, and in this app the window never scrolls at all, so there was no scroll handling of any kind. The symptom was that opening an item from half-way down a library grid dropped the viewer half-way down the detail page, and returning to the grid landed at the top of it — exactly backwards. `ScrollMemory` (pure, one instance per container, keyed on path + query so a genre-filtered grid keeps its own place) records the offset a route is left at in `beforeNavigate` and decides in `afterNavigate`: `link`/`goto`/`form` reset to the top, `popstate` restores that route's saved offset, and the initial `enter` is left alone. Deciding does not consume the offset, so a route returned to more than once restores each time. Applied via the `scrollContainer` action on all three scrollers | UI | UR-072 | Done |
|
||||||
| DR-160 | Picture-in-picture works on the path that actually plays video. PiP shrinks the whole *Activity*, so `canEnterPip` demanded a native ExoPlayer `SurfaceView` be attached and rendering — `isPlayingVideo() && getSurfaceView() != null && isVideoSurfaceAttached()`. But the native path sits behind `experimentalNativeVideo`, which defaulted to **off**, so in the shipping configuration video played in the WebView's `<video>` element and all three conditions were false. `enterPip` bailed with "Not entering PiP: no local video playing" every single time: the button was offered (gated only on OS capability) and could not work, however it was pressed. The manager now accepts either surface. The frontend reports the element through `AndroidPictureInPicture.setHtml5VideoState(active, width, height, playing)` — intrinsic size because the PiP window's aspect ratio came from the letterboxed surface's measured bounds, which do not exist here, and play state because `ExoPlayer.isPlaying` is false on this path and the PiP play/pause action would be frozen on "Play" mid-playback. Two behaviours invert when the WebView *is* the video: it must stay visible in PiP rather than be hidden (`hideWebView` is now gated on the native path — hiding it would leave an empty black window), and the play/pause `RemoteAction` has to reach the element, so the receiver dispatches `jellytau-pip-play`/`jellytau-pip-pause` DOM events instead of driving ExoPlayer. `jellytau-pip-entered`/`-exited` let the player strip its own chrome, since controls, title and gradients would otherwise be rendered into a window a couple of inches wide. The `<video>` is deregistered on teardown so PiP is never offered over a video that has gone | UI | UR-041 | Done (pending device verification) |
|
| DR-160 | Picture-in-picture works on the path that actually plays video. PiP shrinks the whole *Activity*, so `canEnterPip` demanded a native ExoPlayer `SurfaceView` be attached and rendering — `isPlayingVideo() && getSurfaceView() != null && isVideoSurfaceAttached()`. But the native path sits behind `experimentalNativeVideo`, which defaulted to **off**, so in the shipping configuration video played in the WebView's `<video>` element and all three conditions were false. `enterPip` bailed with "Not entering PiP: no local video playing" every single time: the button was offered (gated only on OS capability) and could not work, however it was pressed. The manager now accepts either surface. The frontend reports the element through `AndroidPictureInPicture.setHtml5VideoState(active, width, height, playing)` — intrinsic size because the PiP window's aspect ratio came from the letterboxed surface's measured bounds, which do not exist here, and play state because `ExoPlayer.isPlaying` is false on this path and the PiP play/pause action would be frozen on "Play" mid-playback. Two behaviours invert when the WebView *is* the video: it must stay visible in PiP rather than be hidden (`hideWebView` is now gated on the native path — hiding it would leave an empty black window), and the play/pause `RemoteAction` has to reach the element, so the receiver dispatches `jellytau-pip-play`/`jellytau-pip-pause` DOM events instead of driving ExoPlayer. `jellytau-pip-entered`/`-exited` let the player strip its own chrome, since controls, title and gradients would otherwise be rendered into a window a couple of inches wide. The `<video>` is deregistered on teardown so PiP is never offered over a video that has gone | UI | UR-041 | Done (pending device verification) |
|
||||||
|
| DR-167 | Each downloaded library shows only its own media. Cached items carry no link back to their library — `library_id` and `parent_id` are NULL on every row ([[offline-libraries-never-cached]]) — so `get_downloaded_items` matched the library branch with `EXISTS (SELECT 1 FROM libraries l WHERE l.id = ?)`, which asserts only that the requested library *exists* and never constrains the item to it. Opening any downloaded library therefore listed every downloaded top-level item on the server: films under Music, albums under TV. The sibling query that decides which libraries *appear* already carried the right rule — a `collection_type` ↔ `item_type` mapping — so the two disagreed about the same question. That mapping is now the named constant `LIBRARY_HOLDS_ITEM`, used by both, and a library of unknown collection type still keeps everything rather than being emptied by a rule that cannot classify it. The taxonomy stays in Rust, never the frontend | Downloads | UR-055 | Done |
|
||||||
|
| DR-168 | Pause and resume actually stop and restart the bytes. `pause_download` wrote `status = 'paused'` and did nothing else, and no cancellation existed anywhere in the download stack — no token, no flag, no abort — so the streaming task ran on, kept writing, and overwrote the row with `completed`/`failed` when it finished: the row flicked to "paused" and undid itself. `resume_download` had the mirror defect, flipping the row to `pending` without calling `pump_download_queue`; the pump runs when something calls it rather than polling, so a resumed download sat untouched until an unrelated event happened to pump the queue. A per-download stop flag (`download::stop`) is the missing half — a module-level registry because the two sides never meet, the command holding Tauri state and the worker running detached in `async_runtime::spawn`. The worker reads it between chunks and on retry (so a pause is not swallowed by a 45-second backoff), flushes, and returns `Stopped`, which is deliberately **not** retryable and **not** recorded as a failure: the `.part` file is left intact because that is exactly what the resume's Range request continues from. Registering returns a *fresh* flag, or a resumed download would inherit the pause that stopped it and halt instantly. Cancel and `clear_stale_downloads` signal it too, so neither deletes a file still being written | Downloads | UR-055 | Done |
|
||||||
|
| DR-169 | Partial files are actually reaped. The worker named its sidecar with `Path::with_extension("part")`, which *replaces* the extension — `movie.mp4` became `movie.part` — while every cleanup path deleted `"{file_path}.part"`, i.e. `movie.mp4.part`. The two never matched, so the partial file of every cancelled or failed download stayed on disk indefinitely, invisible to the disk-usage totals because no `downloads` row pointed at it. `partial_path` appends instead, is the single definition both the writer and the cleaners use, and incidentally removes a collision the old form had, where `movie.mp4` and `movie.mkv` mapped to one `movie.part` | Downloads | UR-055 | Done |
|
||||||
|
| DR-170 | Downloads at a chosen bitrate are no longer corrupted by their own retries. Only the `original` preset asks for `Static=true`; every other rung requests a **transcode**, which Jellyfin serves chunked, with no `Content-Length`, and cannot byte-seek — so it ignores `Range` and answers `200` with the whole stream from the beginning rather than `206` with the requested tail. The worker sent the Range header whenever a `.part` existed and appended the body unconditionally, so each retry and each resume concatenated a fresh copy of the entire transcode onto the bytes already on disk: the file grew past its real size and would not play, which is why "downloads for different bitrates" stayed broken after the `videoBitRate` casing fix (DR-adc460f3) corrected the *request*. `resume_offset` makes the response decide — append only on a `206`, otherwise truncate and take the stream from the top — and the total size is computed from that offset rather than from a partial length the server never agreed to | Downloads | UR-071 | Done |
|
||||||
|
| DR-171 | A downloaded video keeps audio the device can actually decode. `original` quality asked for `Static=true`, which hands back the source file byte-for-byte — E-AC-3/AC-3/DTS/TrueHD track included — and video is rendered on both platforms by the webview `<video>` element, which decodes none of them. Streaming already knew this: DR-149 judges the track the server would serve against `WEBVIEW_AUDIO_CODECS` and forces a transcode over Jellyfin's own direct-play offer, because 10.11.5 honours a `DirectPlayProfile`'s container and video codec but ignores its audio codec. The download path never consulted that policy, so the *same film* had sound when streamed and played as picture in silence once downloaded — and offline a download is the only source a video has, so there was no working path left to fall back to. The rule is now one rule: `served_audio_codec` picks the track the server will serve (the default, or the first when none is marked) and both callers judge it, the streaming verdict staying a bool and the download path needing the codec itself so it can say what to re-encode. Only the audio is re-encoded — `allowVideoStreamCopy=true` keeps an h264 source's picture byte-for-byte and no bitrate or resolution cap is added, so `original` still means original quality; a source the webview could not have rendered anyway (HEVC) becomes h264 as a side effect, which is the only form of it that would have played. The decision is per item rather than blanket because the transcode costs the byte-range resumability `Static=true` gives the download worker (see DR-170 for what a chunked, length-less response does to a resume), so a file whose audio already plays keeps the direct copy. An unknown codec — item not fetchable, or the server named none — changes nothing: the policy only ever *adds* a transcode, so it cannot make a working download worse. `resolve_video_download_url` is the single entrance for all three resolution sites (the frontend's per-item command, the bulk series/season enqueue, and the offline-queued resume), since the pure builder cannot look a codec up and a caller that forgets to is exactly how the silent downloads shipped. **Files already downloaded stay silent** — the bytes on disk are the wrong bytes and only a re-download replaces them | Downloads | UR-071, UR-004 | Done |
|
||||||
| DR-162 | Video streams are opened against a **bandwidth ceiling the user chose**, instead of a fixed allowance nobody could change. Every video URL carried `MaxStreamingBitrate=20000000`/`VideoBitrate=18000000`, `PlaybackInfo` negotiated at 20 Mbps, and the device profile advertised `999999999` — so on a metered or slow connection the only lever was not watching. `StreamingQuality` is a ladder of ceilings (Original, 20/10/8/4/2/1 Mbps, 720 kbps) in which a step is not a label but a bundle of transcode parameters: the total ceiling, the audio share of it, and the resolution that budget can carry. It lives in Rust because those numbers are Jellyfin encoding vocabulary — the frontend names a variant and reads labels back over `player_get_streaming_qualities`, the same arrangement as the EQ preset curves. The video bitrate is the total *minus* the audio share, so the two together honour the cap rather than overshooting it by the size of the audio track, and `MaxHeight` falls with the ladder so a small budget is not spent on pixels it cannot afford. The cap has to reach the **negotiation**, not only the transcode URL: `max_static_bitrate` in the device profile is what makes the server refuse to direct-play a source fatter than the ceiling, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot — which is why it is applied at all four places that decide bandwidth (the HLS builder, `PlaybackInfo`, `open_live_stream`, and the background-audio handoff, which takes the lower of the cap and its own 384 kbps). The ceiling is process-wide rather than a field on `OnlineRepository`, mirroring `INCLUDE_CATALOG_BROWSE`: it is a preference about *this device's connection*, it must survive a repository rebuilt on re-login, and every builder plus the negotiation have to agree on it or the cap leaks. Settings owns the durable default and is the only writer to `app_settings` — persisted unlike the rest of `VideoSettings`, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to show for it — and it is restored at startup from the async runtime, defaulting to uncapped if the read fails so a database problem degrades to the old behaviour rather than to an arbitrary limit. The in-player menu is the per-video override: a cap is a property of the stream the server is producing, so it cannot be applied to one already in flight — `player_set_stream_quality` re-opens the stream at the new quality and resumes at the current position, reloading a native backend itself and handing HTML5 a URL for the same `reloadSource` primitive the audio-track switch uses, so no strategy branch lives in the UI. It deliberately does not persist. This gives UR-070 its resume-at-the-same-point mechanism; the server-offered per-item rendition list that requirement also asks for remains proposed | Playback | UR-074, UR-070 | Done |
|
| DR-162 | Video streams are opened against a **bandwidth ceiling the user chose**, instead of a fixed allowance nobody could change. Every video URL carried `MaxStreamingBitrate=20000000`/`VideoBitrate=18000000`, `PlaybackInfo` negotiated at 20 Mbps, and the device profile advertised `999999999` — so on a metered or slow connection the only lever was not watching. `StreamingQuality` is a ladder of ceilings (Original, 20/10/8/4/2/1 Mbps, 720 kbps) in which a step is not a label but a bundle of transcode parameters: the total ceiling, the audio share of it, and the resolution that budget can carry. It lives in Rust because those numbers are Jellyfin encoding vocabulary — the frontend names a variant and reads labels back over `player_get_streaming_qualities`, the same arrangement as the EQ preset curves. The video bitrate is the total *minus* the audio share, so the two together honour the cap rather than overshooting it by the size of the audio track, and `MaxHeight` falls with the ladder so a small budget is not spent on pixels it cannot afford. The cap has to reach the **negotiation**, not only the transcode URL: `max_static_bitrate` in the device profile is what makes the server refuse to direct-play a source fatter than the ceiling, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot — which is why it is applied at all four places that decide bandwidth (the HLS builder, `PlaybackInfo`, `open_live_stream`, and the background-audio handoff, which takes the lower of the cap and its own 384 kbps). The ceiling is process-wide rather than a field on `OnlineRepository`, mirroring `INCLUDE_CATALOG_BROWSE`: it is a preference about *this device's connection*, it must survive a repository rebuilt on re-login, and every builder plus the negotiation have to agree on it or the cap leaks. Settings owns the durable default and is the only writer to `app_settings` — persisted unlike the rest of `VideoSettings`, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to show for it — and it is restored at startup from the async runtime, defaulting to uncapped if the read fails so a database problem degrades to the old behaviour rather than to an arbitrary limit. The in-player menu is the per-video override: a cap is a property of the stream the server is producing, so it cannot be applied to one already in flight — `player_set_stream_quality` re-opens the stream at the new quality and resumes at the current position, reloading a native backend itself and handing HTML5 a URL for the same `reloadSource` primitive the audio-track switch uses, so no strategy branch lives in the UI. It deliberately does not persist. This gives UR-070 its resume-at-the-same-point mechanism; the server-offered per-item rendition list that requirement also asks for remains proposed | Playback | UR-074, UR-070 | Done |
|
||||||
| DR-163 | Tiles of mixed shapes are laid out **justified** rather than gridded. A CSS grid gives every cell one box, so on a page holding square music covers, 16:9 library backdrops and 2:3 posters at once, everything that is not the chosen shape is cropped to it — the home shortcut strip was explicitly forcing `aspect="video"` on music libraries for exactly this reason, which lined the row up by cutting the covers down. `layoutMosaic` packs tiles into rows of a **shared height** and gives each its own width from its own aspect ratio: it adds tiles to a row until the height needed to fill the container has fallen to the target, closes the row there (so rows land at or below the target, never above), and justifies the row to the container width by absorbing the rounding remainder into its widest tile, where a pixel is least visible. The last row is deliberately *not* justified — with one tile left over, filling the width would inflate it to a banner — so it sits at the target height, left-aligned. Ratios are clamped to a band, which costs a crop on genuine outliers and stops one panorama owning a row or one very tall image shrinking to a sliver. It is a pure module with no DOM: the component supplies only the two things the DOM knows — the measured container width, and the artwork's *decoded* aspect ratio, reported by `CachedImage` so the layout uses the shape an image actually has rather than the one its item type implies. Those measurements are committed in one debounced batch rather than per image, because artwork arrives over several hundred milliseconds and re-packing on each arrival would shuffle the grid under the pointer repeatedly. Labels are drawn *over* the bottom of each tile rather than beneath it: a caption below sits outside the computed box, and one that wraps to two lines would break the row alignment the layout exists to provide | UI | UR-075 | Done |
|
| DR-172 | Tiles of mixed shapes are laid out **justified** rather than gridded. A CSS grid gives every cell one box, so on a page holding square music covers, 16:9 library backdrops and 2:3 posters at once, everything that is not the chosen shape is cropped to it — the home shortcut strip was explicitly forcing `aspect="video"` on music libraries for exactly this reason, which lined the row up by cutting the covers down. `layoutMosaic` packs tiles into rows of a **shared height** and gives each its own width from its own aspect ratio: it adds tiles to a row until the height needed to fill the container has fallen to the target, closes the row there (so rows land at or below the target, never above), and justifies the row to the container width by absorbing the rounding remainder into its widest tile, where a pixel is least visible. The last row is deliberately *not* justified — with one tile left over, filling the width would inflate it to a banner — so it sits at the target height, left-aligned. Ratios are clamped to a band, which costs a crop on genuine outliers and stops one panorama owning a row or one very tall image shrinking to a sliver. It is a pure module with no DOM: the component supplies only the two things the DOM knows — the measured container width, and the artwork's *decoded* aspect ratio, reported by `CachedImage` so the layout uses the shape an image actually has rather than the one its item type implies. Those measurements are committed in one debounced batch rather than per image, because artwork arrives over several hundred milliseconds and re-packing on each arrival would shuffle the grid under the pointer repeatedly. Labels are drawn *over* the bottom of each tile rather than beneath it: a caption below sits outside the computed box, and one that wraps to two lines would break the row alignment the layout exists to provide | UI | UR-075 | Done |
|
||||||
| DR-164 | A library knows which favourites category it belongs to, and the frontend does not work it out. The mosaic offers a favourites tile per category beside its library, which needs a collection-type → category answer; deriving it in Svelte would have re-created the exact leak `SearchScope::item_types` was extracted to close (docs/specs/scoped-search-boundary.md) — one table of Jellyfin vocabulary, differing only in which vocabulary. `SearchScope::for_collection_type` maps `movies`/`tvshows`/`music` and returns `None` for everything else, so a Live TV or books library gets no tile at all rather than one opening an unfiltered list; `All` is never derived from a library, being the cross-library entry offered beside them rather than a property of one. `Library::new` stamps the result onto every library at construction — a constructor rather than a struct literal precisely so a derived field cannot be forgotten at one of the four sites — and it rides to the frontend as an optional `favoritesScope`, absent rather than null when there is none. The UI's remaining share is presentation only: what to call the tile, where to put it, and showing a category's tile **once** however many libraries share it, since two movie libraries have one favourites list between them | UI | UR-075, UR-067 | Done |
|
| DR-173 | A library knows which favourites category it belongs to, and the frontend does not work it out. The mosaic offers a favourites tile per category beside its library, which needs a collection-type → category answer; deriving it in Svelte would have re-created the exact leak `SearchScope::item_types` was extracted to close (docs/specs/scoped-search-boundary.md) — one table of Jellyfin vocabulary, differing only in which vocabulary. `SearchScope::for_collection_type` maps `movies`/`tvshows`/`music` and returns `None` for everything else, so a Live TV or books library gets no tile at all rather than one opening an unfiltered list; `All` is never derived from a library, being the cross-library entry offered beside them rather than a property of one. `Library::new` stamps the result onto every library at construction — a constructor rather than a struct literal precisely so a derived field cannot be forgotten at one of the four sites — and it rides to the frontend as an optional `favoritesScope`, absent rather than null when there is none. The UI's remaining share is presentation only: what to call the tile, where to put it, and showing a category's tile **once** however many libraries share it, since two movie libraries have one favourites list between them | UI | UR-075, UR-067 | Done |
|
||||||
| DR-161 | Native video is the default, so picture-in-picture has a real surface. DR-160 makes PiP work on the HTML5 path, but that path can only ever shrink the *UI* into the PiP window; showing the video itself needs the SurfaceView behind the WebView, which is what `experimentalNativeVideo` gates. The flag now defaults to on when the user has never chosen, with an explicit stored choice still winning in both directions so anyone who turned it off keeps it off. This is a deliberate acceptance of risk: the flag existed because the native path was an unfinished spike, and `VideoPlayer.scrubRegression.test.ts` documents its history — a native init that flipped to HTML5 mid-lifecycle and left seeks going down one path while ExoPlayer played on another. Those tests pin the **flag-off** interim override (native response overridden to HTML5, backend stopped once), which the default no longer selects, so they now mock the flag off rather than inherit it: they still guard that path, but they no longer describe what ships. The native scrub/seek path is consequently not covered by the suite and needs device verification | UI | UR-041, UR-003 | Needs device verification |
|
| DR-161 | Native video is the default, so picture-in-picture has a real surface. DR-160 makes PiP work on the HTML5 path, but that path can only ever shrink the *UI* into the PiP window; showing the video itself needs the SurfaceView behind the WebView, which is what `experimentalNativeVideo` gates. The flag now defaults to on when the user has never chosen, with an explicit stored choice still winning in both directions so anyone who turned it off keeps it off. This is a deliberate acceptance of risk: the flag existed because the native path was an unfinished spike, and `VideoPlayer.scrubRegression.test.ts` documents its history — a native init that flipped to HTML5 mid-lifecycle and left seeks going down one path while ExoPlayer played on another. Those tests pin the **flag-off** interim override (native response overridden to HTML5, backend stopped once), which the default no longer selects, so they now mock the flag off rather than inherit it: they still guard that path, but they no longer describe what ships. The native scrub/seek path is consequently not covered by the suite and needs device verification | UI | UR-041, UR-003 | Needs device verification |
|
||||||
| DR-159 | The background-audio handoff stops leaking its relative timeline. The handoff plays the episode as a *relative* stream — the audio-only URL is built with `StartTimeTicks` = the position the screen was locked at, so ExoPlayer's zero is the handoff point — and `background_audio_base` holds the offset that turns one back into a real position. The base was a **display-only** correction, applied in exactly two places (the lockscreen scrubber and the internal truncation maths) while every other consumer worked in the relative timeline treating the number as absolute. Each crossing threw away exactly `base` seconds, which is why the jump-back distance varied with where the screen was locked and read as random. Three crossings were live: progress reporting to Jellyfin sent the relative position every 30s, so the server was told `real − base` — and since DR-155 now mirrors the server's position back and refreshes on a cache hit, that regressed value returned as the resume point (lock at 40 min, listen to 90, reopen at 50); lockscreen seeks went out absolute and came back relative, against a chunked length-less transcode that cannot honour a seek at all, so a clamped seek landed at stream zero; and media3's own `seekToDefaultPosition`/`seekBack`/`seekForward` bypassed the `ForwardingPlayer` wrapper entirely, reaching the real ExoPlayer — `Util.handlePlayButtonAction` seeking an ended player to the relative zero being the same mechanism as DR-129's truncation bug through a different door. The fix converts **once, at the boundary**: `JellyTauPlayer`'s position tick adds the base (and shifts the duration with it, since the stream's own length is only what remains) before either `nativeOnPositionUpdate` or the lockscreen sees it, so position updates, progress reports, the frontend and the truncation check all speak the episode's timeline and none needs to know a handoff happened. The base is consequently *removed* from `claim_stream_resume`, `truncated_stream_resume_position` and `player_exit_background_audio`, where adding it now double-counts, and the lockscreen's `positionOffsetMs` addition goes with it (the field remains, read-only, as the tick's input). Inbound seeks go the other way: `seek_absolute` is the new boundary for every outside seek, re-opening the stream at the requested position via `resume_stream_at` when a handoff is active — which is what `onSeekTo` had claimed for months in a comment describing code that did not exist — and an ordinary seek otherwise. `seekToDefaultPosition` is swallowed rather than forwarded, since Rust already owns what "play after the stream ended" means and the `play()` that follows reaches it. Exit reads the position *before* clearing either base, or a tick landing in between hands back a relative one | Player | UR-040, UR-005, UR-025 | Done (pending device verification) |
|
| DR-159 | The background-audio handoff stops leaking its relative timeline. The handoff plays the episode as a *relative* stream — the audio-only URL is built with `StartTimeTicks` = the position the screen was locked at, so ExoPlayer's zero is the handoff point — and `background_audio_base` holds the offset that turns one back into a real position. The base was a **display-only** correction, applied in exactly two places (the lockscreen scrubber and the internal truncation maths) while every other consumer worked in the relative timeline treating the number as absolute. Each crossing threw away exactly `base` seconds, which is why the jump-back distance varied with where the screen was locked and read as random. Three crossings were live: progress reporting to Jellyfin sent the relative position every 30s, so the server was told `real − base` — and since DR-155 now mirrors the server's position back and refreshes on a cache hit, that regressed value returned as the resume point (lock at 40 min, listen to 90, reopen at 50); lockscreen seeks went out absolute and came back relative, against a chunked length-less transcode that cannot honour a seek at all, so a clamped seek landed at stream zero; and media3's own `seekToDefaultPosition`/`seekBack`/`seekForward` bypassed the `ForwardingPlayer` wrapper entirely, reaching the real ExoPlayer — `Util.handlePlayButtonAction` seeking an ended player to the relative zero being the same mechanism as DR-129's truncation bug through a different door. The fix converts **once, at the boundary**: `JellyTauPlayer`'s position tick adds the base (and shifts the duration with it, since the stream's own length is only what remains) before either `nativeOnPositionUpdate` or the lockscreen sees it, so position updates, progress reports, the frontend and the truncation check all speak the episode's timeline and none needs to know a handoff happened. The base is consequently *removed* from `claim_stream_resume`, `truncated_stream_resume_position` and `player_exit_background_audio`, where adding it now double-counts, and the lockscreen's `positionOffsetMs` addition goes with it (the field remains, read-only, as the tick's input). Inbound seeks go the other way: `seek_absolute` is the new boundary for every outside seek, re-opening the stream at the requested position via `resume_stream_at` when a handoff is active — which is what `onSeekTo` had claimed for months in a comment describing code that did not exist — and an ordinary seek otherwise. `seekToDefaultPosition` is swallowed rather than forwarded, since Rust already owns what "play after the stream ended" means and the `play()` that follows reaches it. Exit reads the position *before* clearing either base, or a tick landing in between hands back a relative one | Player | UR-040, UR-005, UR-025 | Done (pending device verification) |
|
||||||
| DR-158 | A watched toggle, on the episode row, the season header, the series and movie hero, and the Episode Focus View. Both halves of the backend already existed and neither had a caller: `mark_played` (`POST /PlayedItems`) was reachable only from the sync drain replaying rows the *reporter* had queued, and `clear_watch_history` (`DELETE /PlayedItems`) only from the destructive "erase this series' history" button — so the sole way to mark something watched was to play it. Jellyfin applies both recursively over a season or series, so the container case needs no client-side fan-out *online*. Offline it does: `storage_set_watched` writes the item **and its descendants** (drawn from `items` by `parent_id`/`album_id`/`season_id`/`series_id`, so an uncached id selects nothing and the statement no-ops instead of raising a foreign-key error), because otherwise marking a season watched with no server would tick the season and leave every episode inside it unwatched. It is deliberately separate from `storage_mark_played`, which stays the single-item "this finished playing" path that increments `play_count`. Un-marking clears the resume position as well as the flag, matching the server. `QueuedOp::MarkUnplayed` gives the queue the missing direction — pushing as `clear_watch_history` — so the toggle works offline both ways rather than only one; without it un-marking would have been the half that needed a connection. The button is an everyday toggle, so unlike `ClearHistoryButton` it does not confirm, and it holds an optimistic state because the caller's `watched` prop only catches up after a reload (a season means a round trip, during which the button would otherwise appear to ignore the tap) | UI | UR-073 | Done |
|
| DR-158 | A watched toggle, on the episode row, the season header, the series and movie hero, and the Episode Focus View. Both halves of the backend already existed and neither had a caller: `mark_played` (`POST /PlayedItems`) was reachable only from the sync drain replaying rows the *reporter* had queued, and `clear_watch_history` (`DELETE /PlayedItems`) only from the destructive "erase this series' history" button — so the sole way to mark something watched was to play it. Jellyfin applies both recursively over a season or series, so the container case needs no client-side fan-out *online*. Offline it does: `storage_set_watched` writes the item **and its descendants** (drawn from `items` by `parent_id`/`album_id`/`season_id`/`series_id`, so an uncached id selects nothing and the statement no-ops instead of raising a foreign-key error), because otherwise marking a season watched with no server would tick the season and leave every episode inside it unwatched. It is deliberately separate from `storage_mark_played`, which stays the single-item "this finished playing" path that increments `play_count`. Un-marking clears the resume position as well as the flag, matching the server. `QueuedOp::MarkUnplayed` gives the queue the missing direction — pushing as `clear_watch_history` — so the toggle works offline both ways rather than only one; without it un-marking would have been the half that needed a connection. The button is an everyday toggle, so unlike `ClearHistoryButton` it does not confirm, and it holds an optimistic state because the caller's `watched` prop only catches up after a reload (a season means a round trip, during which the button would otherwise appear to ignore the tap) | UI | UR-073 | Done |
|
||||||
@@ -349,7 +354,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-001 | IR-001, IR-002 | - |
|
| UR-001 | IR-001, IR-002 | - |
|
||||||
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
||||||
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
|
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
|
||||||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129 |
|
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171 |
|
||||||
| UR-005 | - | DR-001, DR-005, DR-009 |
|
| UR-005 | - | DR-001, DR-005, DR-009 |
|
||||||
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
|
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
|
||||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||||||
@@ -400,7 +405,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 |
|
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 |
|
||||||
| UR-053 | IR-029 | DR-074 |
|
| UR-053 | IR-029 | DR-074 |
|
||||||
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
|
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
|
||||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 |
|
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169 |
|
||||||
| UR-056 | - | DR-085 |
|
| UR-056 | - | DR-085 |
|
||||||
| UR-057 | - | DR-086 |
|
| UR-057 | - | DR-086 |
|
||||||
| UR-058 | - | DR-087, DR-142 |
|
| UR-058 | - | DR-087, DR-142 |
|
||||||
@@ -415,11 +420,11 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-068 | - | DR-119 |
|
| UR-068 | - | DR-119 |
|
||||||
| UR-069 | - | DR-113, DR-114, DR-120 |
|
| UR-069 | - | DR-113, DR-114, DR-120 |
|
||||||
| UR-070 | - | DR-121, DR-122 |
|
| UR-070 | - | DR-121, DR-122 |
|
||||||
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138 |
|
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171 |
|
||||||
| UR-072 | - | DR-156 |
|
| UR-072 | - | DR-156 |
|
||||||
| UR-073 | - | DR-158 |
|
| UR-073 | - | DR-158 |
|
||||||
| UR-074 | - | DR-162 |
|
| UR-074 | - | DR-162 |
|
||||||
| UR-075 | - | DR-163, DR-164 |
|
| UR-075 | - | DR-172, DR-173 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -570,15 +575,20 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-150 | `set-version.sh` stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffix | DR-153 | Done |
|
| UT-150 | `set-version.sh` stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffix | DR-153 | Done |
|
||||||
| UT-151 | An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later report | DR-154 | Done |
|
| UT-151 | An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later report | DR-154 | Done |
|
||||||
| UT-152 | Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards | DR-155 | Done |
|
| UT-152 | Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards | DR-155 | Done |
|
||||||
|
| UT-162 | Each downloaded library lists only its own media: the music library shows the album and neither the film nor the series, the movie library only the film, the TV library only the series | DR-163 | Done |
|
||||||
|
| UT-163 | `partial_path` appends rather than replacing the extension, so it matches what the cleanup paths delete, keeps two sources for one title apart, and still produces a sidecar for an extension-less target | DR-165 | Done |
|
||||||
|
| UT-164 | `resume_offset` appends only when the server answered `206`; a `200` after a Range request restarts the file, because that body is the whole stream | DR-166 | Done |
|
||||||
|
| UT-165 | A registered download starts unflagged, `signal` sets the flag its worker reads, signalling an unregistered id reports not-in-flight, `clear` forgets it, and re-registering drops a previous stop so a resumed download does not halt instantly | DR-164 | Done |
|
||||||
|
| UT-166 | `original` quality re-encodes audio the webview cannot decode (E-AC-3/AC-3/DTS/TrueHD) to AAC without capping bitrate or resolution, keeps the `Static=true` direct copy for audio that plays here (AAC/MP3/Opus/Vorbis/FLAC) and for an unknown codec, leaves the explicit quality presets untouched, and picks the served track by the same default-or-first rule the streaming verdict uses | DR-171 | Done |
|
||||||
| UT-155 | A seek during a background-audio handoff re-opens the stream at the requested absolute position (`StartTimeTicks`) and rebases the handoff to it, while a seek outside a handoff stays an ordinary seek and invents no base | DR-159 | Done |
|
| UT-155 | A seek during a background-audio handoff re-opens the stream at the requested absolute position (`StartTimeTicks`) and rebases the handoff to it, while a seek outside a handoff stays an ordinary seek and invents no base | DR-159 | Done |
|
||||||
| UT-154 | `mark_unplayed` parses to `QueuedOp::MarkUnplayed` and is rejected without an item id, and a queued un-mark drains to the server as `clear_watch_history` | DR-158 | Done |
|
| UT-154 | `mark_unplayed` parses to `QueuedOp::MarkUnplayed` and is rejected without an item id, and a queued un-mark drains to the server as `clear_watch_history` | DR-158 | Done |
|
||||||
| UT-156 | A capped step reaches the transcode URL as all four of its parts (total ceiling, the video/audio split summing to the cap, and a `MaxHeight`), the uncapped default keeps the historical 20/18 Mbps allowance and constrains no resolution, and the background-audio handoff takes the lower of the cap and its own 384 kbps | DR-162 | Done |
|
| UT-156 | A capped step reaches the transcode URL as all four of its parts (total ceiling, the video/audio split summing to the cap, and a `MaxHeight`), the uncapped default keeps the historical 20/18 Mbps allowance and constrains no resolution, and the background-audio handoff takes the lower of the cap and its own 384 kbps | DR-162 | Done |
|
||||||
| UT-157 | The quality ladder is internally consistent — video + audio equals the cap at every step, audio never consumes the budget, only `Original` is uncapped — descends in bitrate, resolution and audio share together, and round-trips through the serde token it is persisted as | DR-162 | Done |
|
| UT-157 | The quality ladder is internally consistent — video + audio equals the cap at every step, audio never consumes the budget, only `Original` is uncapped — descends in bitrate, resolution and audio share together, and round-trips through the serde token it is persisted as | DR-162 | Done |
|
||||||
| UT-158 | Justified rows fill the container width exactly and never overflow it, every tile in a row shares one height, and each tile's width follows its own aspect ratio — a 16:9 tile coming out more than twice the width of a 2:3 tile at the same height | DR-163 | Done |
|
| UT-158 | Justified rows fill the container width exactly and never overflow it, every tile in a row shares one height, and each tile's width follows its own aspect ratio — a 16:9 tile coming out more than twice the width of a 2:3 tile at the same height | DR-172 | Done |
|
||||||
| UT-159 | The awkward cases of the packing: a short last row is left at the target height rather than stretched across the container, a last row that would overflow is brought down, an extreme ratio is clamped instead of taking a row to itself, a missing or nonsensical ratio falls back to square instead of collapsing the tile, an unmeasured container renders nothing rather than 1px tiles, and every tile is placed exactly once in order | DR-163 | Done |
|
| UT-159 | The awkward cases of the packing: a short last row is left at the target height rather than stretched across the container, a last row that would overflow is brought down, an extreme ratio is clamped instead of taking a row to itself, a missing or nonsensical ratio falls back to square instead of collapsing the tile, an unmeasured container renders nothing rather than 1px tiles, and every tile is placed exactly once in order | DR-172 | Done |
|
||||||
| UT-160 | The default row height suits its container: it grows with the width, stays inside its bounds, and at phone width still fits two 16:9 tiles side by side | DR-163 | Done |
|
| UT-160 | The default row height suits its container: it grows with the width, stays inside its bounds, and at phone width still fits two 16:9 tiles side by side | DR-172 | Done |
|
||||||
| UT-161 | A collection type maps to its favourites scope (`movies`/`tvshows`/`music`), every other kind — Live TV, channels, box sets, books, unknown — maps to none rather than to `All`, and a constructed library carries the scope across the wire as `favoritesScope`, omitted entirely when it has none | DR-164 | Done |
|
| UT-161 | A collection type maps to its favourites scope (`movies`/`tvshows`/`music`), every other kind — Live TV, channels, box sets, books, unknown — maps to none rather than to `All`, and a constructed library carries the scope across the wire as `favoritesScope`, omitted entirely when it has none | DR-173 | Done |
|
||||||
| UT-162 | The mosaic's composition: the cross-library favourites entry leads, each library is followed by its own category tile pointing at that category's tab, a category shared by two libraries still yields one tile, a library kind favourites do not carve up yields none, a scope the page offers no tab for is ignored, and every tile is uniquely keyed | DR-163, DR-164 | Done |
|
| UT-167 | The mosaic's composition: the cross-library favourites entry leads, each library is followed by its own category tile pointing at that category's tab, a category shared by two libraries still yields one tile, a library kind favourites do not carve up yields none, a scope the page offers no tab for is ignored, and every tile is uniquely keyed | DR-172, DR-173 | Done |
|
||||||
| UT-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done |
|
| UT-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done |
|
||||||
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
|
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
|
||||||
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
|
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Spec: Library mosaic (library overview + home shortcuts)
|
# Spec: Library mosaic (library overview + home shortcuts)
|
||||||
|
|
||||||
**Status:** Implemented
|
**Status:** Implemented
|
||||||
**Requirements:** UR-075 → DR-163, DR-164 (with UR-067 → DR-117 extended)
|
**Requirements:** UR-075 → DR-172, DR-173 (with UR-067 → DR-117 extended)
|
||||||
**UX spec:** [ux-flows.md](../ux-flows.md) §5C.2 (Favourites)
|
**UX spec:** [ux-flows.md](../ux-flows.md) §5C.2 (Favourites)
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|||||||
+1665
-1446
File diff suppressed because it is too large
Load Diff
@@ -175,8 +175,8 @@ describe("live requirements.md", () => {
|
|||||||
|
|
||||||
expect(defined.UR).toBe(75);
|
expect(defined.UR).toBe(75);
|
||||||
expect(defined.IR).toBe(32);
|
expect(defined.IR).toBe(32);
|
||||||
expect(defined.DR).toBe(159);
|
expect(defined.DR).toBe(164);
|
||||||
expect(defined.JA).toBe(35);
|
expect(defined.JA).toBe(35);
|
||||||
expect(defined.total).toBe(301);
|
expect(defined.total).toBe(306);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -631,8 +631,6 @@ pub async fn resume_queued_downloads(
|
|||||||
) -> Result<ResumeQueuedResult, String> {
|
) -> Result<ResumeQueuedResult, String> {
|
||||||
use crate::repository::MediaRepository;
|
use crate::repository::MediaRepository;
|
||||||
|
|
||||||
use crate::repository::HybridRepository;
|
|
||||||
|
|
||||||
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
|
||||||
// The pump needs a target_dir; use the same storage root the other download
|
// The pump needs a target_dir; use the same storage root the other download
|
||||||
@@ -683,12 +681,13 @@ pub async fn resume_queued_downloads(
|
|||||||
async move {
|
async move {
|
||||||
if media_type == "video" {
|
if media_type == "video" {
|
||||||
Some(
|
Some(
|
||||||
<HybridRepository as MediaRepository>::get_video_download_url(
|
crate::repository::resolve_video_download_url(
|
||||||
repo.as_ref(),
|
repo.as_ref(),
|
||||||
&item_id,
|
&item_id,
|
||||||
&quality,
|
&quality,
|
||||||
None,
|
None,
|
||||||
),
|
)
|
||||||
|
.await,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
match repo.get_audio_stream_url(&item_id).await {
|
match repo.get_audio_stream_url(&item_id).await {
|
||||||
|
|||||||
@@ -845,7 +845,19 @@ pub async fn get_downloads(
|
|||||||
Ok(DownloadsResponse { downloads, stats })
|
Ok(DownloadsResponse { downloads, stats })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pause a download
|
/// Pause a download.
|
||||||
|
///
|
||||||
|
/// Writing `status = 'paused'` is only half of it, and used to be all of it: the
|
||||||
|
/// streaming task knew nothing about the row and kept running, then overwrote it
|
||||||
|
/// with `completed`/`failed` when it finished. The row flicked to "paused" and
|
||||||
|
/// undid itself — the reported "pause does not work". Signalling the worker is
|
||||||
|
/// what actually stops the bytes; it leaves the `.part` file in place so
|
||||||
|
/// [`resume_download`] can continue from it.
|
||||||
|
///
|
||||||
|
/// A queued (not yet started) download has no worker to signal, and the status
|
||||||
|
/// write alone is enough — the pump skips anything that is not `pending`.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-055 | DR-168
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn pause_download(
|
pub async fn pause_download(
|
||||||
@@ -858,19 +870,34 @@ pub async fn pause_download(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let query = Query::with_params(
|
let query = Query::with_params(
|
||||||
"UPDATE downloads SET status = 'paused' WHERE id = ? AND status = 'downloading'",
|
"UPDATE downloads SET status = 'paused' WHERE id = ? AND status IN ('downloading', 'pending')",
|
||||||
vec![QueryParam::Int64(download_id)],
|
vec![QueryParam::Int64(download_id)],
|
||||||
);
|
);
|
||||||
|
|
||||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let was_running = crate::download::stop::signal(download_id);
|
||||||
|
info!(
|
||||||
|
"[pause] Download {} paused (in flight: {})",
|
||||||
|
download_id, was_running
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resume a paused download
|
/// Resume a paused download.
|
||||||
|
///
|
||||||
|
/// Flipping the row back to `pending` is likewise not enough on its own: the
|
||||||
|
/// pump is not a poller, it runs when something calls it, so a resumed download
|
||||||
|
/// sat untouched until some unrelated event happened to pump the queue. That is
|
||||||
|
/// the other half of "resume does not work".
|
||||||
|
///
|
||||||
|
/// TRACES: UR-055 | DR-168
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn resume_download(
|
pub async fn resume_download(
|
||||||
|
app: tauri::AppHandle,
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
download_manager: State<'_, DownloadManagerWrapper>,
|
||||||
download_id: i64,
|
download_id: i64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let db_service = {
|
let db_service = {
|
||||||
@@ -879,11 +906,22 @@ pub async fn resume_download(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let query = Query::with_params(
|
let query = Query::with_params(
|
||||||
"UPDATE downloads SET status = 'pending' WHERE id = ? AND status = 'paused'",
|
"UPDATE downloads SET status = 'pending', error_message = NULL WHERE id = ? AND status IN ('paused', 'failed')",
|
||||||
vec![QueryParam::Int64(download_id)],
|
vec![QueryParam::Int64(download_id)],
|
||||||
);
|
);
|
||||||
|
|
||||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Drop any stale stop flag before the pump can start this id again, or the
|
||||||
|
// resumed run would read the pause that stopped it and halt immediately.
|
||||||
|
crate::download::stop::clear(download_id);
|
||||||
|
|
||||||
|
let active_downloads = {
|
||||||
|
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
manager.get_active_downloads()
|
||||||
|
};
|
||||||
|
pump_download_queue(app, db_service, active_downloads).await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -923,6 +961,13 @@ pub async fn cancel_download(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Stop the worker if this download is actually running. Without this the
|
||||||
|
// task keeps streaming into a `.part` file whose `downloads` row has just
|
||||||
|
// been deleted — bytes with nothing pointing at them, and the file below is
|
||||||
|
// removed while still being written to. (DR-168)
|
||||||
|
crate::download::stop::signal(download_id);
|
||||||
|
crate::download::stop::clear(download_id);
|
||||||
|
|
||||||
// Unregister from download manager (in case it was active)
|
// Unregister from download manager (in case it was active)
|
||||||
{
|
{
|
||||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -934,10 +979,12 @@ pub async fn cancel_download(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete partial file if exists
|
// Delete the partial file, and any completed file, if present. Both go
|
||||||
|
// through `partial_path` so this cannot drift from what the worker writes —
|
||||||
|
// it did, and every cancelled download leaked its partial. (DR-169)
|
||||||
if let Some(path) = file_path {
|
if let Some(path) = file_path {
|
||||||
let partial_path = format!("{}.part", path);
|
let target = std::path::PathBuf::from(&path);
|
||||||
let _ = std::fs::remove_file(&partial_path); // Ignore errors
|
let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1244,8 +1291,6 @@ pub async fn enqueue_video_downloads(
|
|||||||
download_ids: Vec<i64>,
|
download_ids: Vec<i64>,
|
||||||
target_dir: String,
|
target_dir: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
use crate::repository::MediaRepository;
|
|
||||||
|
|
||||||
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
|
||||||
let db_service = {
|
let db_service = {
|
||||||
@@ -1270,10 +1315,12 @@ pub async fn enqueue_video_downloads(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build the transcode URL (pure URL builder, no server round-trip).
|
// Build the download URL, resolving the source's audio codec first so a
|
||||||
let stream_url = repo
|
// track this device cannot decode is re-encoded on the way down rather
|
||||||
.as_ref()
|
// than saved as a silent file (DR-167).
|
||||||
.get_video_download_url(&item_id, &quality, None);
|
let stream_url =
|
||||||
|
crate::repository::resolve_video_download_url(repo.as_ref(), &item_id, &quality, None)
|
||||||
|
.await;
|
||||||
|
|
||||||
let update_query = Query::with_params(
|
let update_query = Query::with_params(
|
||||||
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
|
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
|
||||||
@@ -1530,7 +1577,11 @@ fn spawn_download_worker(
|
|||||||
let _ = progress_app.emit("download-event", event);
|
let _ = progress_app.emit("download-event", event);
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = worker.download(&task, on_progress).await;
|
// Registering returns a fresh flag, so a download resumed after a pause
|
||||||
|
// does not inherit the stop that ended its previous run. (DR-168)
|
||||||
|
let stop_flag = crate::download::stop::register(download_id);
|
||||||
|
let result = worker.download(&task, &stop_flag, on_progress).await;
|
||||||
|
crate::download::stop::clear(download_id);
|
||||||
|
|
||||||
// Free the slot before pumping so the next download can take it.
|
// Free the slot before pumping so the next download can take it.
|
||||||
if let Ok(mut active) = active_downloads.lock() {
|
if let Ok(mut active) = active_downloads.lock() {
|
||||||
@@ -1601,6 +1652,17 @@ fn spawn_download_worker(
|
|||||||
Err(e) => error!(" Completed event emit failed: {:?}", e),
|
Err(e) => error!(" Completed event emit failed: {:?}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A pause or cancel is not a failure. The row already says `paused`
|
||||||
|
// (or the row is gone, for a cancel), and overwriting that with
|
||||||
|
// `failed` is what made a pause look like an error and stranded the
|
||||||
|
// download outside the resumable set. The `.part` file is deliberately
|
||||||
|
// left alone — it is what the resume continues from. (DR-168)
|
||||||
|
Err(e) if e.is_stopped() => {
|
||||||
|
info!(
|
||||||
|
"[pump] Download {} stopped by request; partial file kept for resume",
|
||||||
|
download_id
|
||||||
|
);
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Download failed: {:?}", e);
|
error!("Download failed: {:?}", e);
|
||||||
|
|
||||||
@@ -1848,17 +1910,26 @@ pub async fn clear_stale_downloads(
|
|||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get file paths for stale downloads (pending/paused/failed)
|
// Ids as well as paths: a stale row may still have a worker attached (a
|
||||||
|
// 'downloading' row that was paused mid-flight is 'paused' here), and
|
||||||
|
// deleting the row without stopping the task leaves it writing to a file we
|
||||||
|
// are about to remove. (DR-168)
|
||||||
let file_query = Query::with_params(
|
let file_query = Query::with_params(
|
||||||
"SELECT file_path FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
|
"SELECT id, file_path FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
|
||||||
vec![QueryParam::String(user_id.clone())],
|
vec![QueryParam::String(user_id.clone())],
|
||||||
);
|
);
|
||||||
|
|
||||||
let file_paths: Vec<String> = db_service
|
let stale: Vec<(i64, String)> = db_service
|
||||||
.query_many(file_query, |row| row.get(0))
|
.query_many(file_query, |row| Ok((row.get(0)?, row.get(1)?)))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
for (id, _) in &stale {
|
||||||
|
crate::download::stop::signal(*id);
|
||||||
|
crate::download::stop::clear(*id);
|
||||||
|
}
|
||||||
|
let file_paths: Vec<String> = stale.into_iter().map(|(_, path)| path).collect();
|
||||||
|
|
||||||
// Delete all pending, paused, and failed downloads (but keep completed ones)
|
// Delete all pending, paused, and failed downloads (but keep completed ones)
|
||||||
let delete_query = Query::with_params(
|
let delete_query = Query::with_params(
|
||||||
"DELETE FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
|
"DELETE FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
|
||||||
@@ -1870,10 +1941,12 @@ pub async fn clear_stale_downloads(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Delete any partial files
|
// Delete any partial files, via the shared helper so this cannot drift from
|
||||||
|
// what the worker actually writes. (DR-169)
|
||||||
for path in file_paths {
|
for path in file_paths {
|
||||||
let _ = std::fs::remove_file(&path);
|
let target = std::path::PathBuf::from(&path);
|
||||||
let _ = std::fs::remove_file(format!("{}.part", path));
|
let _ = std::fs::remove_file(&target);
|
||||||
|
let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(deleted_count as i64)
|
Ok(deleted_count as i64)
|
||||||
|
|||||||
@@ -804,7 +804,7 @@ pub fn repository_get_subtitle_url(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn repository_get_video_download_url(
|
pub async fn repository_get_video_download_url(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
@@ -812,9 +812,16 @@ pub fn repository_get_video_download_url(
|
|||||||
media_source_id: Option<String>,
|
media_source_id: Option<String>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
Ok(repo
|
// Async because the audio-codec policy has to know what the source's audio
|
||||||
.as_ref()
|
// is before it can decide whether the file may be copied verbatim (DR-171).
|
||||||
.get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
|
// The frontend calls this exactly as before — the decision stays in Rust.
|
||||||
|
Ok(crate::repository::resolve_video_download_url(
|
||||||
|
repo.as_ref(),
|
||||||
|
&item_id,
|
||||||
|
&quality,
|
||||||
|
media_source_id.as_deref(),
|
||||||
|
)
|
||||||
|
.await)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark an item as favorite
|
/// Mark an item as favorite
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
|
pub mod stop;
|
||||||
pub mod worker;
|
pub mod worker;
|
||||||
|
|
||||||
use crate::utils::lock::MutexSafe;
|
use crate::utils::lock::MutexSafe;
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
//! Stop signalling for in-flight downloads.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-055 | DR-168
|
||||||
|
//!
|
||||||
|
//! Pausing and cancelling used to be database-only: `pause_download` wrote
|
||||||
|
//! `status = 'paused'` and nothing else. No cancellation existed anywhere in the
|
||||||
|
//! download stack — no token, no flag, no abort — so the streaming task kept
|
||||||
|
//! running, kept writing bytes, and on finishing overwrote the row with
|
||||||
|
//! `completed` or `failed`. The row flicked to "paused" and then undid itself,
|
||||||
|
//! which is precisely the reported "pause does not work".
|
||||||
|
//!
|
||||||
|
//! This is the missing half: a flag per in-flight download that the worker reads
|
||||||
|
//! between chunks. Setting it makes the worker return [`Stopped`] promptly and
|
||||||
|
//! leave the `.part` file **intact**, which is what lets a resume pick up from
|
||||||
|
//! where it stopped via the existing HTTP Range request.
|
||||||
|
//!
|
||||||
|
//! Kept as a module-level registry rather than on `DownloadManager` because the
|
||||||
|
//! two sides never meet: the command handler holds the manager's lock, while the
|
||||||
|
//! worker runs detached inside `tauri::async_runtime::spawn` with no access to
|
||||||
|
//! Tauri state. A registry both can reach is the smallest thing that works.
|
||||||
|
//!
|
||||||
|
//! [`Stopped`]: crate::download::worker::DownloadError::Stopped
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
|
use crate::utils::lock::MutexSafe;
|
||||||
|
|
||||||
|
/// download id → its stop flag, for downloads currently in flight.
|
||||||
|
fn registry() -> &'static Mutex<HashMap<i64, Arc<AtomicBool>>> {
|
||||||
|
static REGISTRY: OnceLock<Mutex<HashMap<i64, Arc<AtomicBool>>>> = OnceLock::new();
|
||||||
|
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register `download_id` as in-flight and hand back its stop flag.
|
||||||
|
///
|
||||||
|
/// Called by the worker as it starts. A previous flag for the same id is
|
||||||
|
/// replaced, so a download that is paused and later resumed does not inherit the
|
||||||
|
/// set flag from its last run and stop immediately.
|
||||||
|
pub fn register(download_id: i64) -> Arc<AtomicBool> {
|
||||||
|
let flag = Arc::new(AtomicBool::new(false));
|
||||||
|
registry().lock_safe().insert(download_id, flag.clone());
|
||||||
|
flag
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask an in-flight download to stop.
|
||||||
|
///
|
||||||
|
/// Returns whether one was actually in flight — the caller uses this to tell a
|
||||||
|
/// running download (which will stop shortly) from a merely queued one (which
|
||||||
|
/// the database update alone has already handled).
|
||||||
|
pub fn signal(download_id: i64) -> bool {
|
||||||
|
match registry().lock_safe().get(&download_id) {
|
||||||
|
Some(flag) => {
|
||||||
|
flag.store(true, Ordering::SeqCst);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget a download's flag. Called when its task finishes, however it ended.
|
||||||
|
pub fn clear(download_id: i64) {
|
||||||
|
registry().lock_safe().remove(&download_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a stop has been requested for `download_id`.
|
||||||
|
///
|
||||||
|
/// The worker reads its own `Arc<AtomicBool>` directly rather than looking the id
|
||||||
|
/// up, so this exists for the tests that assert the registry's behaviour.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn is_stopping(download_id: i64) -> bool {
|
||||||
|
registry()
|
||||||
|
.lock_safe()
|
||||||
|
.get(&download_id)
|
||||||
|
.map(|f| f.load(Ordering::SeqCst))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Ids are per-test so the shared registry cannot leak between them.
|
||||||
|
fn unique_id(seed: i64) -> i64 {
|
||||||
|
900_000 + seed
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_registered_download_starts_unflagged() {
|
||||||
|
let id = unique_id(1);
|
||||||
|
let flag = register(id);
|
||||||
|
assert!(!flag.load(Ordering::SeqCst));
|
||||||
|
assert!(!is_stopping(id));
|
||||||
|
clear(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_signal_sets_the_flag_the_worker_reads() {
|
||||||
|
let id = unique_id(2);
|
||||||
|
let flag = register(id);
|
||||||
|
|
||||||
|
assert!(signal(id), "a registered download reports as in flight");
|
||||||
|
assert!(
|
||||||
|
flag.load(Ordering::SeqCst),
|
||||||
|
"the worker's own handle sees it"
|
||||||
|
);
|
||||||
|
assert!(is_stopping(id));
|
||||||
|
|
||||||
|
clear(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pump only needs to abort a task that exists; a queued row is handled
|
||||||
|
/// by its database status alone.
|
||||||
|
#[test]
|
||||||
|
fn test_signalling_an_unregistered_download_reports_not_in_flight() {
|
||||||
|
assert!(!signal(unique_id(3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clear_forgets_the_download() {
|
||||||
|
let id = unique_id(4);
|
||||||
|
register(id);
|
||||||
|
signal(id);
|
||||||
|
clear(id);
|
||||||
|
|
||||||
|
assert!(!is_stopping(id));
|
||||||
|
assert!(!signal(id), "a cleared download is no longer in flight");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bug this guards: pause sets the flag, and resume re-runs the same
|
||||||
|
/// download id. If registering reused the old flag, the resumed run would see
|
||||||
|
/// a set flag and stop instantly — a download that could never be resumed.
|
||||||
|
#[test]
|
||||||
|
fn test_reregistering_clears_a_previous_stop() {
|
||||||
|
let id = unique_id(5);
|
||||||
|
register(id);
|
||||||
|
signal(id);
|
||||||
|
assert!(is_stopping(id));
|
||||||
|
|
||||||
|
let fresh = register(id);
|
||||||
|
assert!(!fresh.load(Ordering::SeqCst));
|
||||||
|
assert!(
|
||||||
|
!is_stopping(id),
|
||||||
|
"a resumed download must not inherit the pause"
|
||||||
|
);
|
||||||
|
|
||||||
|
clear(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Download worker for HTTP streaming with progress tracking and retry logic
|
//! Download worker for HTTP streaming with progress tracking and retry logic
|
||||||
|
|
||||||
use log::warn;
|
use log::warn;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
@@ -31,10 +32,18 @@ impl DownloadWorker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download a file with retry logic and progress tracking
|
/// Download a file with retry logic and progress tracking.
|
||||||
|
///
|
||||||
|
/// `stop` is the pause/cancel flag (see [`crate::download::stop`]). It is
|
||||||
|
/// checked between chunks and again between retries, so a paused download
|
||||||
|
/// stops promptly rather than after its next backoff — up to 45 seconds
|
||||||
|
/// away, which reads as the pause having done nothing.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-055 | DR-168
|
||||||
pub async fn download<F>(
|
pub async fn download<F>(
|
||||||
&self,
|
&self,
|
||||||
task: &DownloadTask,
|
task: &DownloadTask,
|
||||||
|
stop: &AtomicBool,
|
||||||
on_progress: F,
|
on_progress: F,
|
||||||
) -> Result<DownloadResult, DownloadError>
|
) -> Result<DownloadResult, DownloadError>
|
||||||
where
|
where
|
||||||
@@ -43,7 +52,10 @@ impl DownloadWorker {
|
|||||||
let mut retries = 0;
|
let mut retries = 0;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match self.try_download(task, &on_progress).await {
|
if stop.load(Ordering::SeqCst) {
|
||||||
|
return Err(DownloadError::Stopped);
|
||||||
|
}
|
||||||
|
match self.try_download(task, stop, &on_progress).await {
|
||||||
Ok(result) => return Ok(result),
|
Ok(result) => return Ok(result),
|
||||||
Err(e) if retries < self.max_retries && e.is_retryable() => {
|
Err(e) if retries < self.max_retries && e.is_retryable() => {
|
||||||
retries += 1;
|
retries += 1;
|
||||||
@@ -63,6 +75,7 @@ impl DownloadWorker {
|
|||||||
async fn try_download<F>(
|
async fn try_download<F>(
|
||||||
&self,
|
&self,
|
||||||
task: &DownloadTask,
|
task: &DownloadTask,
|
||||||
|
stop: &AtomicBool,
|
||||||
on_progress: &F,
|
on_progress: &F,
|
||||||
) -> Result<DownloadResult, DownloadError>
|
) -> Result<DownloadResult, DownloadError>
|
||||||
where
|
where
|
||||||
@@ -76,7 +89,7 @@ impl DownloadWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for partial download
|
// Check for partial download
|
||||||
let temp_path = task.target_path.with_extension("part");
|
let temp_path = partial_path(&task.target_path);
|
||||||
let existing_bytes = if temp_path.exists() {
|
let existing_bytes = if temp_path.exists() {
|
||||||
fs::metadata(&temp_path).await.map(|m| m.len()).unwrap_or(0)
|
fs::metadata(&temp_path).await.map(|m| m.len()).unwrap_or(0)
|
||||||
} else {
|
} else {
|
||||||
@@ -100,22 +113,32 @@ impl DownloadWorker {
|
|||||||
return Err(DownloadError::Http(response.status().as_u16()));
|
return Err(DownloadError::Http(response.status().as_u16()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get content length
|
// Did the server actually honour the Range? A transcode does not, and
|
||||||
|
// answers 200 with the whole stream — appending that would duplicate what
|
||||||
|
// we already hold. (DR-170)
|
||||||
|
let resume_from = resume_offset(existing_bytes, response.status().as_u16());
|
||||||
|
if existing_bytes > 0 && resume_from == 0 {
|
||||||
|
warn!(
|
||||||
|
"Server ignored the Range request (HTTP {}) — restarting {} from the beginning \
|
||||||
|
instead of appending to {} existing bytes",
|
||||||
|
response.status().as_u16(),
|
||||||
|
task.target_path.display(),
|
||||||
|
existing_bytes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get content length. Absent on a chunked transcode, which is why progress
|
||||||
|
// for a non-`original` preset has no percentage to show.
|
||||||
let _total_bytes = response
|
let _total_bytes = response
|
||||||
.headers()
|
.headers()
|
||||||
.get(reqwest::header::CONTENT_LENGTH)
|
.get(reqwest::header::CONTENT_LENGTH)
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|v| v.parse::<u64>().ok())
|
.and_then(|v| v.parse::<u64>().ok())
|
||||||
.map(|len| {
|
.map(|len| len + resume_from);
|
||||||
if existing_bytes > 0 {
|
|
||||||
len + existing_bytes
|
|
||||||
} else {
|
|
||||||
len
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open file for appending
|
// Append only when resuming a range the server agreed to; otherwise
|
||||||
let mut file = if existing_bytes > 0 {
|
// create/truncate so the restarted stream replaces the stale bytes.
|
||||||
|
let mut file = if resume_from > 0 {
|
||||||
fs::OpenOptions::new().append(true).open(&temp_path).await
|
fs::OpenOptions::new().append(true).open(&temp_path).await
|
||||||
} else {
|
} else {
|
||||||
fs::File::create(&temp_path).await
|
fs::File::create(&temp_path).await
|
||||||
@@ -123,11 +146,22 @@ impl DownloadWorker {
|
|||||||
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
||||||
|
|
||||||
// Stream download with progress tracking
|
// Stream download with progress tracking
|
||||||
let mut downloaded = existing_bytes;
|
let mut downloaded = resume_from;
|
||||||
let mut stream = response.bytes_stream();
|
let mut stream = response.bytes_stream();
|
||||||
let mut last_progress_emit = std::time::Instant::now();
|
let mut last_progress_emit = std::time::Instant::now();
|
||||||
|
|
||||||
while let Some(chunk) = stream.next().await {
|
while let Some(chunk) = stream.next().await {
|
||||||
|
// Checked before writing, so a paused download stops on a byte
|
||||||
|
// boundary the `.part` file already accounts for — the Range request
|
||||||
|
// on resume then asks for exactly what is missing. Flushing what we
|
||||||
|
// have and leaving the file in place is the whole mechanism behind
|
||||||
|
// "resume", so this must never delete it. (DR-168)
|
||||||
|
if stop.load(Ordering::SeqCst) {
|
||||||
|
let _ = file.flush().await;
|
||||||
|
let _ = file.sync_all().await;
|
||||||
|
return Err(DownloadError::Stopped);
|
||||||
|
}
|
||||||
|
|
||||||
let chunk = chunk.map_err(|e| DownloadError::Network(e.to_string()))?;
|
let chunk = chunk.map_err(|e| DownloadError::Network(e.to_string()))?;
|
||||||
|
|
||||||
file.write_all(&chunk)
|
file.write_all(&chunk)
|
||||||
@@ -138,7 +172,7 @@ impl DownloadWorker {
|
|||||||
|
|
||||||
// Emit progress every 500ms or every MB
|
// Emit progress every 500ms or every MB
|
||||||
if last_progress_emit.elapsed() > Duration::from_millis(500)
|
if last_progress_emit.elapsed() > Duration::from_millis(500)
|
||||||
|| downloaded % (1024 * 1024) == 0
|
|| downloaded.is_multiple_of(1024 * 1024)
|
||||||
{
|
{
|
||||||
last_progress_emit = std::time::Instant::now();
|
last_progress_emit = std::time::Instant::now();
|
||||||
on_progress(downloaded, _total_bytes);
|
on_progress(downloaded, _total_bytes);
|
||||||
@@ -167,6 +201,56 @@ impl DownloadWorker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where to resume writing a partial download, given how the server answered.
|
||||||
|
///
|
||||||
|
/// A byte offset of 0 means "start the file again"; anything else means "append
|
||||||
|
/// from here".
|
||||||
|
///
|
||||||
|
/// This is what makes non-`original` downloads survive. Those presets ask
|
||||||
|
/// Jellyfin to **transcode**, and a live transcode is chunked with no
|
||||||
|
/// `Content-Length` and cannot be byte-seeked: the server ignores `Range` and
|
||||||
|
/// answers `200` with the whole stream from the beginning, not `206` with the
|
||||||
|
/// requested tail. The worker sent the header and appended the body regardless,
|
||||||
|
/// so every retry — and every resume — concatenated a fresh copy of the whole
|
||||||
|
/// transcode onto the bytes already on disk. The file grew past its real size
|
||||||
|
/// and would not play. Only a `206` actually promises the tail; a `200` means we
|
||||||
|
/// must discard what we have and take the stream from the top.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-170
|
||||||
|
pub fn resume_offset(existing_bytes: u64, status: u16) -> u64 {
|
||||||
|
if existing_bytes == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// 206 Partial Content is the only answer that honours the Range request.
|
||||||
|
if status == 206 {
|
||||||
|
existing_bytes
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The partial-download sidecar for `target`.
|
||||||
|
///
|
||||||
|
/// **Appends** `.part` rather than replacing the extension. The worker used
|
||||||
|
/// `Path::with_extension("part")`, which replaces: `movie.mp4` became
|
||||||
|
/// `movie.part`. Every cleanup path meanwhile deleted `"{file_path}.part"` —
|
||||||
|
/// `movie.mp4.part` — so nothing ever matched and the partial file of every
|
||||||
|
/// cancelled or failed download was left on disk forever, invisible to the
|
||||||
|
/// disk-usage totals because no `downloads` row pointed at it. That is the
|
||||||
|
/// reported "failure is not cleaned".
|
||||||
|
///
|
||||||
|
/// Appending also removes a collision the old form had: `movie.mp4` and
|
||||||
|
/// `movie.mkv` both mapped to `movie.part` and would have fought over one file.
|
||||||
|
///
|
||||||
|
/// One function so the writer and the cleaners cannot disagree again.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-055 | DR-169
|
||||||
|
pub fn partial_path(target: &std::path::Path) -> std::path::PathBuf {
|
||||||
|
let mut s = target.as_os_str().to_os_string();
|
||||||
|
s.push(".part");
|
||||||
|
std::path::PathBuf::from(s)
|
||||||
|
}
|
||||||
|
|
||||||
/// Result of a successful download
|
/// Result of a successful download
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct DownloadResult {
|
pub struct DownloadResult {
|
||||||
@@ -179,6 +263,10 @@ pub enum DownloadError {
|
|||||||
Network(String),
|
Network(String),
|
||||||
Http(u16),
|
Http(u16),
|
||||||
FileSystem(String),
|
FileSystem(String),
|
||||||
|
/// The download was asked to stop (paused or cancelled). Not a failure: the
|
||||||
|
/// row's status already says what happened, and the partial file is kept so a
|
||||||
|
/// resume can continue from it.
|
||||||
|
Stopped,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DownloadError {
|
impl DownloadError {
|
||||||
@@ -188,8 +276,16 @@ impl DownloadError {
|
|||||||
DownloadError::Network(_) => true,
|
DownloadError::Network(_) => true,
|
||||||
DownloadError::Http(status) => *status >= 500, // Retry server errors
|
DownloadError::Http(status) => *status >= 500, // Retry server errors
|
||||||
DownloadError::FileSystem(_) => false,
|
DownloadError::FileSystem(_) => false,
|
||||||
|
// Retrying would restart the very download the user just paused.
|
||||||
|
DownloadError::Stopped => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this outcome means "the user stopped it", rather than a failure to
|
||||||
|
/// record and report.
|
||||||
|
pub fn is_stopped(&self) -> bool {
|
||||||
|
matches!(self, DownloadError::Stopped)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for DownloadError {
|
impl std::fmt::Display for DownloadError {
|
||||||
@@ -198,6 +294,7 @@ impl std::fmt::Display for DownloadError {
|
|||||||
DownloadError::Network(msg) => write!(f, "Network error: {}", msg),
|
DownloadError::Network(msg) => write!(f, "Network error: {}", msg),
|
||||||
DownloadError::Http(status) => write!(f, "HTTP error {}", status),
|
DownloadError::Http(status) => write!(f, "HTTP error {}", status),
|
||||||
DownloadError::FileSystem(msg) => write!(f, "File system error: {}", msg),
|
DownloadError::FileSystem(msg) => write!(f, "File system error: {}", msg),
|
||||||
|
DownloadError::Stopped => write!(f, "Download stopped by request"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,6 +305,64 @@ impl std::error::Error for DownloadError {}
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// The bitrate-download corruption: a transcode ignores `Range` and answers
|
||||||
|
/// `200` with the whole stream. Appending that to the bytes already on disk
|
||||||
|
/// duplicated them, so every retry grew the file past its real size and left
|
||||||
|
/// it unplayable. Only `206` promises the requested tail.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-170 | UT-164
|
||||||
|
#[test]
|
||||||
|
fn test_resume_offset_only_appends_when_the_server_honoured_the_range() {
|
||||||
|
// Nothing on disk: start at the beginning either way.
|
||||||
|
assert_eq!(resume_offset(0, 200), 0);
|
||||||
|
assert_eq!(resume_offset(0, 206), 0);
|
||||||
|
|
||||||
|
// The server agreed to the range — append to what we have.
|
||||||
|
assert_eq!(resume_offset(5_000, 206), 5_000);
|
||||||
|
|
||||||
|
// The server ignored it and is sending the whole file (a transcode).
|
||||||
|
// Restart, or the bytes are duplicated.
|
||||||
|
assert_eq!(
|
||||||
|
resume_offset(5_000, 200),
|
||||||
|
0,
|
||||||
|
"a 200 carries the whole stream; appending it corrupts the file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The regression: `with_extension` replaced the extension, so the worker
|
||||||
|
/// wrote `movie.part` while every cleanup path deleted `movie.mp4.part`.
|
||||||
|
/// Nothing matched, and partial files accumulated forever.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-055 | DR-169 | UT-163
|
||||||
|
#[test]
|
||||||
|
fn test_partial_path_appends_rather_than_replacing_the_extension() {
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
partial_path(Path::new("/media/movie.mp4")),
|
||||||
|
Path::new("/media/movie.mp4.part"),
|
||||||
|
"the cleanup paths delete \"{{file_path}}.part\"; this must produce it"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Two sources for one title must not fight over a single partial file.
|
||||||
|
assert_ne!(
|
||||||
|
partial_path(Path::new("/media/movie.mp4")),
|
||||||
|
partial_path(Path::new("/media/movie.mkv")),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extension-less targets still get a sidecar rather than being clobbered.
|
||||||
|
assert_eq!(
|
||||||
|
partial_path(Path::new("/media/track")),
|
||||||
|
Path::new("/media/track.part"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A dotted name keeps every part of its own name.
|
||||||
|
assert_eq!(
|
||||||
|
partial_path(Path::new("/media/S01.E02.episode.mkv")),
|
||||||
|
Path::new("/media/S01.E02.episode.mkv.part"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_exponential_backoff() {
|
fn test_exponential_backoff() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -231,5 +386,9 @@ mod tests {
|
|||||||
assert!(DownloadError::Http(503).is_retryable());
|
assert!(DownloadError::Http(503).is_retryable());
|
||||||
assert!(!DownloadError::Http(404).is_retryable());
|
assert!(!DownloadError::Http(404).is_retryable());
|
||||||
assert!(!DownloadError::FileSystem("disk full".to_string()).is_retryable());
|
assert!(!DownloadError::FileSystem("disk full".to_string()).is_retryable());
|
||||||
|
// Retrying a paused download would restart what the user just stopped.
|
||||||
|
assert!(!DownloadError::Stopped.is_retryable());
|
||||||
|
assert!(DownloadError::Stopped.is_stopped());
|
||||||
|
assert!(!DownloadError::Network("timeout".to_string()).is_stopped());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3269,7 +3269,13 @@ mod tests {
|
|||||||
fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
|
fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
fn get_video_download_url(&self, _: &str, _: &str, _: Option<&str>) -> String {
|
fn get_video_download_url(
|
||||||
|
&self,
|
||||||
|
_: &str,
|
||||||
|
_: &str,
|
||||||
|
_: Option<&str>,
|
||||||
|
_: Option<&str>,
|
||||||
|
) -> String {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||||
|
|||||||
@@ -120,22 +120,34 @@ pub fn webview_can_decode_audio(codec: &str) -> bool {
|
|||||||
/// delegate this decision; it knows what its own renderer can decode and must
|
/// delegate this decision; it knows what its own renderer can decode and must
|
||||||
/// apply that itself.
|
/// apply that itself.
|
||||||
///
|
///
|
||||||
/// The track that matters is the one the server will actually serve: the
|
/// The track that matters is the one the server will actually serve (see
|
||||||
/// default, or the first when none is marked. An unknown codec is left alone —
|
/// [`served_audio_codec`]). An unknown codec is left alone — forcing a transcode
|
||||||
/// forcing a transcode on a guess would burn server CPU for files that play.
|
/// on a guess would burn server CPU for files that play.
|
||||||
///
|
///
|
||||||
/// TRACES: UR-004 | DR-149 | UT-148
|
/// TRACES: UR-004 | DR-149 | UT-148
|
||||||
pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> bool {
|
pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> bool {
|
||||||
let served = streams
|
match served_audio_codec(streams) {
|
||||||
|
Some(codec) => !webview_can_decode_audio(codec),
|
||||||
|
// No audio at all, or a codec the server did not name: leave it alone.
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The codec of the audio track the server will actually serve, given the
|
||||||
|
/// source's audio streams as `(codec, is_default)` in source order: the default,
|
||||||
|
/// or the first when none is marked.
|
||||||
|
///
|
||||||
|
/// `None` means "nothing to judge" — no audio streams, or the server named no
|
||||||
|
/// codec for the one it would serve. Both callers of this rule treat that as
|
||||||
|
/// leave-well-alone, never as a licence to assume compatibility.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004, UR-071 | DR-149, DR-171 | UT-148, UT-166
|
||||||
|
pub fn served_audio_codec<'a>(streams: &[(Option<&'a str>, bool)]) -> Option<&'a str> {
|
||||||
|
streams
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(_, is_default)| *is_default)
|
.find(|(_, is_default)| *is_default)
|
||||||
.or_else(|| streams.first());
|
.or_else(|| streams.first())
|
||||||
|
.and_then(|(codec, _)| *codec)
|
||||||
match served {
|
|
||||||
Some((Some(codec), _)) => !webview_can_decode_audio(codec),
|
|
||||||
// No audio at all, or a codec the server did not name: leave it alone.
|
|
||||||
Some((None, _)) | None => false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -193,6 +205,25 @@ mod tests {
|
|||||||
assert!(!audio_forces_transcode(&[(None, true)]));
|
assert!(!audio_forces_transcode(&[(None, true)]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The download path needs the codec itself, not just the verdict, so it can
|
||||||
|
/// tell the server what to re-encode. It picks the same track the streaming
|
||||||
|
/// verdict is formed from — one rule, one place.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-171 | UT-166
|
||||||
|
#[test]
|
||||||
|
fn the_served_codec_is_the_one_the_verdict_is_formed_from() {
|
||||||
|
assert_eq!(
|
||||||
|
served_audio_codec(&[(Some("aac"), false), (Some("eac3"), true)]),
|
||||||
|
Some("eac3")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
served_audio_codec(&[(Some("eac3"), false), (Some("aac"), false)]),
|
||||||
|
Some("eac3")
|
||||||
|
);
|
||||||
|
assert_eq!(served_audio_codec(&[]), None);
|
||||||
|
assert_eq!(served_audio_codec(&[(None, true)]), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_dolby_device_does_not_advertise_dolby_for_video() {
|
fn a_dolby_device_does_not_advertise_dolby_for_video() {
|
||||||
// The bug: a Motorola reporting c2.dolby.eac3.decoder direct-played
|
// The bug: a Motorola reporting c2.dolby.eac3.decoder direct-played
|
||||||
|
|||||||
@@ -872,10 +872,11 @@ impl MediaRepository for HybridRepository {
|
|||||||
item_id: &str,
|
item_id: &str,
|
||||||
quality: &str,
|
quality: &str,
|
||||||
media_source_id: Option<&str>,
|
media_source_id: Option<&str>,
|
||||||
|
source_audio_codec: Option<&str>,
|
||||||
) -> String {
|
) -> String {
|
||||||
// Always use online URL for downloads
|
// Always use online URL for downloads
|
||||||
self.online
|
self.online
|
||||||
.get_video_download_url(item_id, quality, media_source_id)
|
.get_video_download_url(item_id, quality, media_source_id, source_audio_codec)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||||
@@ -1299,6 +1300,7 @@ mod tests {
|
|||||||
_item_id: &str,
|
_item_id: &str,
|
||||||
_quality: &str,
|
_quality: &str,
|
||||||
_media_source_id: Option<&str>,
|
_media_source_id: Option<&str>,
|
||||||
|
_source_audio_codec: Option<&str>,
|
||||||
) -> String {
|
) -> String {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
@@ -1573,6 +1575,7 @@ mod tests {
|
|||||||
_item_id: &str,
|
_item_id: &str,
|
||||||
_quality: &str,
|
_quality: &str,
|
||||||
_media_source_id: Option<&str>,
|
_media_source_id: Option<&str>,
|
||||||
|
_source_audio_codec: Option<&str>,
|
||||||
) -> String {
|
) -> String {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,14 +197,24 @@ pub trait MediaRepository: Send + Sync {
|
|||||||
format: &str,
|
format: &str,
|
||||||
) -> String;
|
) -> String;
|
||||||
|
|
||||||
/// Get video download URL (synchronous - just constructs URL)
|
/// Build the URL a video download is fetched from. Synchronous — it only
|
||||||
/// Called by frontend via Tauri invoke (getVideoDownloadUrl in VideoDownloadButton.svelte)
|
/// constructs a URL, so it stays testable without a server. Reach it through
|
||||||
|
/// [`resolve_video_download_url`] rather than calling it directly.
|
||||||
|
///
|
||||||
|
/// `source_audio_codec` is the codec of the audio track the server would
|
||||||
|
/// serve (see [`served_audio_codec`]); `None` when it is not known. At
|
||||||
|
/// `original` quality it decides whether the file can be copied byte-for-byte
|
||||||
|
/// or has to have its audio re-encoded on the way down — a downloaded file is
|
||||||
|
/// played back with no server in reach, so it has to be decodable *here*.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-171
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn get_video_download_url(
|
fn get_video_download_url(
|
||||||
&self,
|
&self,
|
||||||
item_id: &str,
|
item_id: &str,
|
||||||
quality: &str,
|
quality: &str,
|
||||||
media_source_id: Option<&str>,
|
media_source_id: Option<&str>,
|
||||||
|
source_audio_codec: Option<&str>,
|
||||||
) -> String;
|
) -> String;
|
||||||
|
|
||||||
/// Mark item as favorite
|
/// Mark item as favorite
|
||||||
@@ -323,3 +333,44 @@ pub trait MediaRepository: Send + Sync {
|
|||||||
new_index: u32,
|
new_index: u32,
|
||||||
) -> Result<(), RepoError>;
|
) -> Result<(), RepoError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The audio codec the server would serve for `item_id` — the default track, or
|
||||||
|
/// the first when none is marked, matching the track Jellyfin picks.
|
||||||
|
///
|
||||||
|
/// `None` when the item has no audio, names no codec, or cannot be fetched. A
|
||||||
|
/// caller must read that as "unknown", never as "fine": it is the input to a
|
||||||
|
/// policy that only *adds* a transcode, so an unknown codec leaves behaviour
|
||||||
|
/// exactly as it was.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-171 | UT-166
|
||||||
|
pub async fn served_audio_codec(repo: &dyn MediaRepository, item_id: &str) -> Option<String> {
|
||||||
|
let item = repo.get_item(item_id).await.ok()?;
|
||||||
|
let audio: Vec<(Option<&str>, bool)> = item
|
||||||
|
.media_streams
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.stream_type == "Audio")
|
||||||
|
.map(|s| (s.codec.as_deref(), s.is_default))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
device_profile::served_audio_codec(&audio).map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the download URL for a video, applying the audio-codec policy that
|
||||||
|
/// keeps the saved file playable offline (DR-171).
|
||||||
|
///
|
||||||
|
/// Every video download goes through here rather than calling the builder
|
||||||
|
/// directly: the builder is pure and cannot look the codec up, and a caller that
|
||||||
|
/// forgets to is exactly how the silent downloads shipped.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-171
|
||||||
|
pub async fn resolve_video_download_url(
|
||||||
|
repo: &dyn MediaRepository,
|
||||||
|
item_id: &str,
|
||||||
|
quality: &str,
|
||||||
|
media_source_id: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
let codec = served_audio_codec(repo, item_id).await;
|
||||||
|
repo.get_video_download_url(item_id, quality, media_source_id, codec.as_deref())
|
||||||
|
}
|
||||||
|
|||||||
@@ -828,6 +828,32 @@ impl OfflineRepository {
|
|||||||
/// the synced-but-not-downloaded catalog branch deliberately excluded, so it
|
/// the synced-but-not-downloaded catalog branch deliberately excluded, so it
|
||||||
/// is authoritative regardless of the process-wide catalog-browse flag.
|
/// is authoritative regardless of the process-wide catalog-browse flag.
|
||||||
///
|
///
|
||||||
|
/// Whether cached item `i` belongs to library `l`, decided by media kind.
|
||||||
|
///
|
||||||
|
/// The cache leaves `library_id`/`parent_id` NULL on every item
|
||||||
|
/// ([[offline-libraries-never-cached]]), so there is no link to follow: a
|
||||||
|
/// library's `collection_type` and an item's `item_type` are the only things
|
||||||
|
/// that can associate them. This is Jellyfin taxonomy and therefore lives in
|
||||||
|
/// Rust, never in the frontend.
|
||||||
|
///
|
||||||
|
/// It is a named constant because it is needed in two places that must agree
|
||||||
|
/// — which library *appears* in the Downloaded list, and which items appear
|
||||||
|
/// *inside* it. They disagreed: the listing query used this mapping while the
|
||||||
|
/// browse query only checked that the requested library existed, so opening
|
||||||
|
/// any library showed every downloaded top-level item on the server.
|
||||||
|
///
|
||||||
|
/// A library of some other (or unknown) type keeps everything, since there is
|
||||||
|
/// no mapping to narrow it by and hiding its contents would be worse.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-055 | DR-082, DR-167
|
||||||
|
const LIBRARY_HOLDS_ITEM: &'static str = "(
|
||||||
|
(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 IS NULL
|
||||||
|
OR l.collection_type NOT IN ('music', 'movies', 'tvshows')
|
||||||
|
)";
|
||||||
|
|
||||||
/// TRACES: UR-055 | DR-082, DR-083
|
/// TRACES: UR-055 | DR-082, DR-083
|
||||||
const DOWNLOADED_ITEMS_CTE: &'static str = "
|
const DOWNLOADED_ITEMS_CTE: &'static str = "
|
||||||
WITH downloaded_items AS (
|
WITH downloaded_items AS (
|
||||||
@@ -908,6 +934,7 @@ impl OfflineRepository {
|
|||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM libraries l
|
SELECT 1 FROM libraries l
|
||||||
WHERE l.id = ? AND l.server_id = i.server_id
|
WHERE l.id = ? AND l.server_id = i.server_id
|
||||||
|
AND {membership}
|
||||||
)
|
)
|
||||||
-- Top-level only: hide leaves whose container is downloaded.
|
-- Top-level only: hide leaves whose container is downloaded.
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
@@ -922,6 +949,7 @@ impl OfflineRepository {
|
|||||||
ORDER BY i.sort_name ASC, i.name ASC
|
ORDER BY i.sort_name ASC, i.name ASC
|
||||||
LIMIT {limit} OFFSET {start_index}",
|
LIMIT {limit} OFFSET {start_index}",
|
||||||
cte = Self::DOWNLOADED_ITEMS_CTE,
|
cte = Self::DOWNLOADED_ITEMS_CTE,
|
||||||
|
membership = Self::LIBRARY_HOLDS_ITEM,
|
||||||
);
|
);
|
||||||
|
|
||||||
let query = Query::with_params(
|
let query = Query::with_params(
|
||||||
@@ -966,7 +994,7 @@ impl OfflineRepository {
|
|||||||
// We match a library by collection_type ↔ item_type instead: any
|
// We match a library by collection_type ↔ item_type instead: any
|
||||||
// completed download of a given media kind qualifies that library.
|
// completed download of a given media kind qualifies that library.
|
||||||
let query = Query::with_params(
|
let query = Query::with_params(
|
||||||
&format!(
|
format!(
|
||||||
"{cte}
|
"{cte}
|
||||||
SELECT l.id, l.name, l.collection_type, l.image_tag
|
SELECT l.id, l.name, l.collection_type, l.image_tag
|
||||||
FROM libraries l
|
FROM libraries l
|
||||||
@@ -975,15 +1003,11 @@ impl OfflineRepository {
|
|||||||
SELECT 1 FROM items i
|
SELECT 1 FROM items i
|
||||||
INNER JOIN downloaded_items di ON i.id = di.id
|
INNER JOIN downloaded_items di ON i.id = di.id
|
||||||
WHERE i.server_id = l.server_id
|
WHERE i.server_id = l.server_id
|
||||||
AND (
|
AND {membership}
|
||||||
(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",
|
ORDER BY l.sort_order ASC, l.name ASC",
|
||||||
cte = Self::DOWNLOADED_ITEMS_CTE,
|
cte = Self::DOWNLOADED_ITEMS_CTE,
|
||||||
|
membership = Self::LIBRARY_HOLDS_ITEM,
|
||||||
),
|
),
|
||||||
vec![QueryParam::String(self.server_id.clone())],
|
vec![QueryParam::String(self.server_id.clone())],
|
||||||
);
|
);
|
||||||
@@ -1957,6 +1981,7 @@ impl MediaRepository for OfflineRepository {
|
|||||||
_item_id: &str,
|
_item_id: &str,
|
||||||
_quality: &str,
|
_quality: &str,
|
||||||
_media_source_id: Option<&str>,
|
_media_source_id: Option<&str>,
|
||||||
|
_source_audio_codec: Option<&str>,
|
||||||
) -> String {
|
) -> String {
|
||||||
// Cannot download while offline
|
// Cannot download while offline
|
||||||
String::new()
|
String::new()
|
||||||
@@ -3713,6 +3738,82 @@ mod tests {
|
|||||||
assert_eq!(track_ids, vec!["track-1", "track-2"]);
|
assert_eq!(track_ids, vec!["track-1", "track-2"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression: each downloaded library shows **only its own media**.
|
||||||
|
///
|
||||||
|
/// Cached items carry no link back to their library (`library_id`/`parent_id`
|
||||||
|
/// are NULL — [[offline-libraries-never-cached]]), and the library branch of
|
||||||
|
/// the query only asserted that the requested library *exists*, never that
|
||||||
|
/// the item belongs to it. So opening any downloaded library listed every
|
||||||
|
/// downloaded top-level item on the server: films in the music library,
|
||||||
|
/// albums under TV. The library's `collection_type` decides which item types
|
||||||
|
/// belong to it, the same mapping `get_downloaded_libraries` already uses.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-055 | DR-167 | UT-162
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_downloaded_items_library_does_not_mix_media_types() {
|
||||||
|
let db = create_test_db();
|
||||||
|
seed_library(&db, "music-lib", "music").await;
|
||||||
|
seed_library(&db, "movie-lib", "movies").await;
|
||||||
|
seed_library(&db, "tv-lib", "tvshows").await;
|
||||||
|
|
||||||
|
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, "movie-1", "Movie", None, None, None).await;
|
||||||
|
insert_item(&db, "series-1", "Series", None, None, None).await;
|
||||||
|
insert_item(&db, "episode-1", "Episode", None, Some("series-1"), None).await;
|
||||||
|
|
||||||
|
seed_completed_download(&db, "track-1", 1000).await;
|
||||||
|
seed_completed_download(&db, "movie-1", 2000).await;
|
||||||
|
seed_completed_download(&db, "episode-1", 3000).await;
|
||||||
|
|
||||||
|
let repo = make_repo(&db);
|
||||||
|
|
||||||
|
let music: Vec<String> = repo
|
||||||
|
.get_downloaded_items("music-lib", None)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|i| i.id.clone())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
music,
|
||||||
|
vec!["album-1"],
|
||||||
|
"the music library must not list films or series; got {:?}",
|
||||||
|
music
|
||||||
|
);
|
||||||
|
|
||||||
|
let movies: Vec<String> = repo
|
||||||
|
.get_downloaded_items("movie-lib", None)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|i| i.id.clone())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
movies,
|
||||||
|
vec!["movie-1"],
|
||||||
|
"the movie library must not list albums or series; got {:?}",
|
||||||
|
movies
|
||||||
|
);
|
||||||
|
|
||||||
|
let tv: Vec<String> = repo
|
||||||
|
.get_downloaded_items("tv-lib", None)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|i| i.id.clone())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
tv,
|
||||||
|
vec!["series-1"],
|
||||||
|
"the TV library must not list albums or films; got {:?}",
|
||||||
|
tv
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Regression: a downloaded TV library lists the Series, not its Seasons or
|
/// Regression: a downloaded TV library lists the Series, not its Seasons or
|
||||||
/// Episodes — the same "individual songs" bug seen for music, for TV. The
|
/// Episodes — the same "individual songs" bug seen for music, for TV. The
|
||||||
/// season and episode are still reachable by drilling into the series.
|
/// season and episode are still reachable by drilling into the series.
|
||||||
|
|||||||
@@ -1878,6 +1878,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
item_id: &str,
|
item_id: &str,
|
||||||
quality: &str,
|
quality: &str,
|
||||||
media_source_id: Option<&str>,
|
media_source_id: Option<&str>,
|
||||||
|
source_audio_codec: Option<&str>,
|
||||||
) -> String {
|
) -> String {
|
||||||
// NOTE: Jellyfin's `/Videos/{id}/download` endpoint is not universally
|
// NOTE: Jellyfin's `/Videos/{id}/download` endpoint is not universally
|
||||||
// available (returns 404 on many server configs), which silently broke
|
// available (returns 404 on many server configs), which silently broke
|
||||||
@@ -1928,10 +1929,39 @@ impl MediaRepository for OnlineRepository {
|
|||||||
params.push("audioCodec=aac".to_string());
|
params.push("audioCodec=aac".to_string());
|
||||||
params.push("allowVideoStreamCopy=false".to_string());
|
params.push("allowVideoStreamCopy=false".to_string());
|
||||||
}
|
}
|
||||||
// "original" (and any unknown value) → direct, resumable copy.
|
// "original" (and any unknown value) → direct, resumable copy —
|
||||||
_ => {
|
// unless the audio in that copy is undecodable where the file will
|
||||||
params.push("Static=true".to_string());
|
// be played back. A download is watched with no server in reach, so
|
||||||
}
|
// it has to satisfy the same constraint DR-149 applies to streams:
|
||||||
|
// the webview `<video>` element renders video on both platforms and
|
||||||
|
// decodes none of AC-3/E-AC-3/DTS/TrueHD. Copying those bytes to
|
||||||
|
// disk is what made a downloaded film play offline as picture with
|
||||||
|
// no sound while the same film had sound when streamed.
|
||||||
|
//
|
||||||
|
// Only the *audio* is re-encoded. `allowVideoStreamCopy` keeps an
|
||||||
|
// h264 source's picture byte-for-byte, so "original" still means
|
||||||
|
// original quality, and no bitrate or resolution cap is added. A
|
||||||
|
// source the webview could not have rendered anyway (HEVC) is
|
||||||
|
// re-encoded to h264 as a side effect, which is the only form of it
|
||||||
|
// that would have played.
|
||||||
|
//
|
||||||
|
// The cost of the transcode is that the response is no longer
|
||||||
|
// range-resumable, which is exactly why this is decided per item
|
||||||
|
// rather than applied to every `original` download.
|
||||||
|
//
|
||||||
|
// TRACES: UR-071, UR-004 | DR-171 | UT-166
|
||||||
|
_ => match source_audio_codec {
|
||||||
|
Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
|
||||||
|
params.push("videoCodec=h264".to_string());
|
||||||
|
params.push("allowVideoStreamCopy=true".to_string());
|
||||||
|
params.push("audioCodec=aac".to_string());
|
||||||
|
params.push("audioBitRate=384000".to_string());
|
||||||
|
}
|
||||||
|
// Decodable, or unknown: an unknown codec must not provoke a
|
||||||
|
// transcode — that would burn server CPU on a guess for files
|
||||||
|
// that play perfectly well.
|
||||||
|
_ => params.push("Static=true".to_string()),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add media source ID if provided
|
// Add media source ID if provided
|
||||||
@@ -2739,7 +2769,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_video_download_url_uses_stream_not_download_endpoint() {
|
fn test_video_download_url_uses_stream_not_download_endpoint() {
|
||||||
let repo = create_test_repository();
|
let repo = create_test_repository();
|
||||||
let url = repo.get_video_download_url("item123", "original", None);
|
let url = repo.get_video_download_url("item123", "original", None, None);
|
||||||
|
|
||||||
// Must NOT use the /download endpoint (404 on real servers).
|
// Must NOT use the /download endpoint (404 on real servers).
|
||||||
assert!(
|
assert!(
|
||||||
@@ -2757,7 +2787,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_video_download_url_original_is_static_direct_copy() {
|
fn test_video_download_url_original_is_static_direct_copy() {
|
||||||
let repo = create_test_repository();
|
let repo = create_test_repository();
|
||||||
let url = repo.get_video_download_url("item123", "original", None);
|
let url = repo.get_video_download_url("item123", "original", None, None);
|
||||||
|
|
||||||
// "original" must request a direct static copy (byte-range resumable),
|
// "original" must request a direct static copy (byte-range resumable),
|
||||||
// with no transcode params.
|
// with no transcode params.
|
||||||
@@ -2777,7 +2807,7 @@ mod tests {
|
|||||||
let repo = create_test_repository();
|
let repo = create_test_repository();
|
||||||
|
|
||||||
for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
|
for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
|
||||||
let url = repo.get_video_download_url("item123", quality, None);
|
let url = repo.get_video_download_url("item123", quality, None, None);
|
||||||
assert!(
|
assert!(
|
||||||
url.contains("/Videos/item123/stream.mp4"),
|
url.contains("/Videos/item123/stream.mp4"),
|
||||||
"{quality} must use stream.mp4: {url}"
|
"{quality} must use stream.mp4: {url}"
|
||||||
@@ -2810,7 +2840,7 @@ mod tests {
|
|||||||
let repo = create_test_repository();
|
let repo = create_test_repository();
|
||||||
|
|
||||||
for quality in ["high", "medium", "low"] {
|
for quality in ["high", "medium", "low"] {
|
||||||
let url = repo.get_video_download_url("item123", quality, None);
|
let url = repo.get_video_download_url("item123", quality, None, None);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
url.contains("videoBitRate="),
|
url.contains("videoBitRate="),
|
||||||
@@ -2844,7 +2874,7 @@ mod tests {
|
|||||||
let repo = create_test_repository();
|
let repo = create_test_repository();
|
||||||
|
|
||||||
for quality in ["high", "medium", "low"] {
|
for quality in ["high", "medium", "low"] {
|
||||||
let url = repo.get_video_download_url("item123", quality, None);
|
let url = repo.get_video_download_url("item123", quality, None, None);
|
||||||
assert!(
|
assert!(
|
||||||
url.contains("allowVideoStreamCopy=false"),
|
url.contains("allowVideoStreamCopy=false"),
|
||||||
"{quality} must forbid video stream copy: {url}"
|
"{quality} must forbid video stream copy: {url}"
|
||||||
@@ -2852,17 +2882,99 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// "original" is a deliberate direct copy — it must NOT disable copying.
|
// "original" is a deliberate direct copy — it must NOT disable copying.
|
||||||
let original = repo.get_video_download_url("item123", "original", None);
|
let original = repo.get_video_download_url("item123", "original", None, None);
|
||||||
assert!(
|
assert!(
|
||||||
!original.contains("allowVideoStreamCopy=false"),
|
!original.contains("allowVideoStreamCopy=false"),
|
||||||
"original must remain a direct copy: {original}"
|
"original must remain a direct copy: {original}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A downloaded file is played with no server in reach, so `original`
|
||||||
|
/// quality cannot mean "copy whatever the source holds" when the source
|
||||||
|
/// holds audio this device cannot decode.
|
||||||
|
///
|
||||||
|
/// `Static=true` hands back the source bytes untouched, E-AC-3/AC-3/DTS
|
||||||
|
/// track included, and video plays through the webview `<video>` element on
|
||||||
|
/// both platforms — which decodes none of them. Streaming already knows this
|
||||||
|
/// (DR-149 forces a transcode over the server's own direct-play offer); the
|
||||||
|
/// download path did not, so a downloaded film played offline as picture with
|
||||||
|
/// no sound while the very same film had sound when streamed.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071, UR-004 | DR-171 | UT-166
|
||||||
|
#[test]
|
||||||
|
fn test_video_download_url_original_transcodes_undecodable_audio() {
|
||||||
|
let repo = create_test_repository();
|
||||||
|
|
||||||
|
for codec in ["eac3", "ac3", "dts", "truehd", "EAC3"] {
|
||||||
|
let url = repo.get_video_download_url("item123", "original", None, Some(codec));
|
||||||
|
assert!(
|
||||||
|
!url.contains("Static=true"),
|
||||||
|
"{codec} cannot be decoded here, so the source must not be copied verbatim: {url}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
url.contains("audioCodec=aac"),
|
||||||
|
"{codec} must be re-encoded to aac on the way down: {url}"
|
||||||
|
);
|
||||||
|
// "Original" still has to mean original picture: the video stream is
|
||||||
|
// copied when it can be, so no bitrate or resolution cap appears.
|
||||||
|
assert!(
|
||||||
|
url.contains("allowVideoStreamCopy=true"),
|
||||||
|
"the video stream must still be copied where possible: {url}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!url.contains("videoBitRate") && !url.contains("maxHeight"),
|
||||||
|
"original must not degrade the picture to fix the audio: {url}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The converse, and the reason the policy is per-item rather than blanket:
|
||||||
|
/// audio that plays here keeps the byte-exact, range-resumable copy that the
|
||||||
|
/// download worker's resume depends on.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-171 | UT-166
|
||||||
|
#[test]
|
||||||
|
fn test_video_download_url_original_keeps_static_copy_for_playable_audio() {
|
||||||
|
let repo = create_test_repository();
|
||||||
|
|
||||||
|
for codec in ["aac", "mp3", "opus", "vorbis", "flac", "AAC"] {
|
||||||
|
let url = repo.get_video_download_url("item123", "original", None, Some(codec));
|
||||||
|
assert!(
|
||||||
|
url.contains("Static=true"),
|
||||||
|
"{codec} plays here — the download must stay a direct copy: {url}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!url.contains("audioCodec="),
|
||||||
|
"{codec} needs no transcode: {url}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown codec: the policy only ever *adds* a transcode, so an item we
|
||||||
|
// could not look up behaves exactly as it did before.
|
||||||
|
let unknown = repo.get_video_download_url("item123", "original", None, None);
|
||||||
|
assert!(unknown.contains("Static=true"), "url: {unknown}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The explicit quality presets already transcode audio to AAC, so the
|
||||||
|
/// policy has nothing to add — and must not start overriding a chosen cap.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-171 | UT-166
|
||||||
|
#[test]
|
||||||
|
fn test_video_download_url_presets_ignore_the_audio_policy() {
|
||||||
|
let repo = create_test_repository();
|
||||||
|
|
||||||
|
for quality in ["high", "medium", "low"] {
|
||||||
|
let with = repo.get_video_download_url("item123", quality, None, Some("eac3"));
|
||||||
|
let without = repo.get_video_download_url("item123", quality, None, None);
|
||||||
|
assert_eq!(with, without, "{quality} must not vary with source audio");
|
||||||
|
assert!(with.contains("audioCodec=aac"), "url: {with}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_video_download_url_passes_media_source_id() {
|
fn test_video_download_url_passes_media_source_id() {
|
||||||
let repo = create_test_repository();
|
let repo = create_test_repository();
|
||||||
let url = repo.get_video_download_url("item123", "original", Some("src-42"));
|
let url = repo.get_video_download_url("item123", "original", Some("src-42"), None);
|
||||||
assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
|
assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ pub struct Library {
|
|||||||
/// collection-type → category table any more than an item-type one. See
|
/// collection-type → category table any more than an item-type one. See
|
||||||
/// `SearchScope::for_collection_type`.
|
/// `SearchScope::for_collection_type`.
|
||||||
///
|
///
|
||||||
/// TRACES: UR-075 | DR-164
|
/// TRACES: UR-075 | DR-173
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub favorites_scope: Option<SearchScope>,
|
pub favorites_scope: Option<SearchScope>,
|
||||||
}
|
}
|
||||||
@@ -389,7 +389,7 @@ impl SearchScope {
|
|||||||
/// `All` is never returned: it is the *absence* of a category, offered
|
/// `All` is never returned: it is the *absence* of a category, offered
|
||||||
/// alongside the libraries rather than derived from one.
|
/// alongside the libraries rather than derived from one.
|
||||||
///
|
///
|
||||||
/// TRACES: UR-075 | DR-164 | UT-161
|
/// TRACES: UR-075 | DR-173 | UT-161
|
||||||
pub fn for_collection_type(collection_type: &str) -> Option<SearchScope> {
|
pub fn for_collection_type(collection_type: &str) -> Option<SearchScope> {
|
||||||
match collection_type {
|
match collection_type {
|
||||||
"movies" => Some(SearchScope::Movies),
|
"movies" => Some(SearchScope::Movies),
|
||||||
@@ -707,7 +707,7 @@ mod search_scope_tests {
|
|||||||
assert!(matches!(all.scope, Some(SearchScope::All)));
|
assert!(matches!(all.scope, Some(SearchScope::All)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: DR-164 | UT-161
|
/// TRACES: DR-173 | UT-161
|
||||||
#[test]
|
#[test]
|
||||||
fn test_collection_type_maps_to_its_favorites_scope() {
|
fn test_collection_type_maps_to_its_favorites_scope() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -728,7 +728,7 @@ mod search_scope_tests {
|
|||||||
/// than one that opens an unfiltered list. `All` is never derived from a
|
/// than one that opens an unfiltered list. `All` is never derived from a
|
||||||
/// library — it is the cross-library entry offered beside them.
|
/// library — it is the cross-library entry offered beside them.
|
||||||
///
|
///
|
||||||
/// TRACES: DR-164 | UT-161
|
/// TRACES: DR-173 | UT-161
|
||||||
#[test]
|
#[test]
|
||||||
fn test_uncategorised_collection_types_have_no_favorites_scope() {
|
fn test_uncategorised_collection_types_have_no_favorites_scope() {
|
||||||
for collection_type in ["livetv", "channels", "boxsets", "books", "unknown", ""] {
|
for collection_type in ["livetv", "channels", "boxsets", "books", "unknown", ""] {
|
||||||
@@ -740,7 +740,7 @@ mod search_scope_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: DR-164 | UT-161
|
/// TRACES: DR-173 | UT-161
|
||||||
#[test]
|
#[test]
|
||||||
fn test_library_carries_its_favorites_scope_to_the_frontend() {
|
fn test_library_carries_its_favorites_scope_to_the_frontend() {
|
||||||
let music = Library::new("1".into(), "Music".into(), "music".into(), None);
|
let music = Library::new("1".into(), "Music".into(), "music".into(), None);
|
||||||
|
|||||||
+22
-3
@@ -870,13 +870,32 @@ async getDownloads(userId: string, statusFilter: string[] | null) : Promise<Down
|
|||||||
return await TAURI_INVOKE("get_downloads", { userId, statusFilter });
|
return await TAURI_INVOKE("get_downloads", { userId, statusFilter });
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Pause a download
|
* Pause a download.
|
||||||
|
*
|
||||||
|
* Writing `status = 'paused'` is only half of it, and used to be all of it: the
|
||||||
|
* streaming task knew nothing about the row and kept running, then overwrote it
|
||||||
|
* with `completed`/`failed` when it finished. The row flicked to "paused" and
|
||||||
|
* undid itself — the reported "pause does not work". Signalling the worker is
|
||||||
|
* what actually stops the bytes; it leaves the `.part` file in place so
|
||||||
|
* [`resume_download`] can continue from it.
|
||||||
|
*
|
||||||
|
* A queued (not yet started) download has no worker to signal, and the status
|
||||||
|
* write alone is enough — the pump skips anything that is not `pending`.
|
||||||
|
*
|
||||||
|
* TRACES: UR-055 | DR-168
|
||||||
*/
|
*/
|
||||||
async pauseDownload(downloadId: number) : Promise<null> {
|
async pauseDownload(downloadId: number) : Promise<null> {
|
||||||
return await TAURI_INVOKE("pause_download", { downloadId });
|
return await TAURI_INVOKE("pause_download", { downloadId });
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Resume a paused download
|
* Resume a paused download.
|
||||||
|
*
|
||||||
|
* Flipping the row back to `pending` is likewise not enough on its own: the
|
||||||
|
* pump is not a poller, it runs when something calls it, so a resumed download
|
||||||
|
* sat untouched until some unrelated event happened to pump the queue. That is
|
||||||
|
* the other half of "resume does not work".
|
||||||
|
*
|
||||||
|
* TRACES: UR-055 | DR-168
|
||||||
*/
|
*/
|
||||||
async resumeDownload(downloadId: number) : Promise<null> {
|
async resumeDownload(downloadId: number) : Promise<null> {
|
||||||
return await TAURI_INVOKE("resume_download", { downloadId });
|
return await TAURI_INVOKE("resume_download", { downloadId });
|
||||||
@@ -2063,7 +2082,7 @@ export type Library = { id: string; name: string; collectionType: string; imageT
|
|||||||
* collection-type → category table any more than an item-type one. See
|
* collection-type → category table any more than an item-type one. See
|
||||||
* `SearchScope::for_collection_type`.
|
* `SearchScope::for_collection_type`.
|
||||||
*
|
*
|
||||||
* TRACES: UR-075 | DR-164
|
* TRACES: UR-075 | DR-173
|
||||||
*/
|
*/
|
||||||
favoritesScope?: SearchScope | null }
|
favoritesScope?: SearchScope | null }
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
* Called once the bitmap is decoded, with its intrinsic pixel size. Lets a
|
* Called once the bitmap is decoded, with its intrinsic pixel size. Lets a
|
||||||
* layout that sizes boxes from artwork (the mosaic) use the shape the image
|
* layout that sizes boxes from artwork (the mosaic) use the shape the image
|
||||||
* actually has rather than the one its item type suggests.
|
* actually has rather than the one its item type suggests.
|
||||||
* TRACES: UR-075 | DR-163
|
* TRACES: UR-075 | DR-172
|
||||||
*/
|
*/
|
||||||
onNaturalSize?: (width: number, height: number) => void;
|
onNaturalSize?: (width: number, height: number) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
arrives over a few hundred milliseconds, and re-packing on each arrival would
|
arrives over a few hundred milliseconds, and re-packing on each arrival would
|
||||||
shuffle the grid under the viewer's cursor several times over.
|
shuffle the grid under the viewer's cursor several times over.
|
||||||
|
|
||||||
TRACES: UR-075 | DR-163
|
TRACES: UR-075 | DR-172
|
||||||
-->
|
-->
|
||||||
<script lang="ts" generics="T extends { key: string; ratio: number }">
|
<script lang="ts" generics="T extends { key: string; ratio: number }">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
lines would no longer line up with its neighbours. Keeping everything inside
|
lines would no longer line up with its neighbours. Keeping everything inside
|
||||||
the box is what lets `layoutMosaic` own the geometry completely.
|
the box is what lets `layoutMosaic` own the geometry completely.
|
||||||
|
|
||||||
TRACES: UR-075 | DR-163
|
TRACES: UR-075 | DR-172
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
// `favoritesScope` (Rust: `SearchScope::for_collection_type`). This file only
|
// `favoritesScope` (Rust: `SearchScope::for_collection_type`). This file only
|
||||||
// decides what to *call* it and where to put it.
|
// decides what to *call* it and where to put it.
|
||||||
//
|
//
|
||||||
// TRACES: UR-075, UR-067 | DR-163, DR-164 | UT-162
|
// TRACES: UR-075, UR-067 | DR-172, DR-173 | UT-167
|
||||||
|
|
||||||
import type { Library } from "$lib/api/types";
|
import type { Library } from "$lib/api/types";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
//
|
//
|
||||||
// Presentation only — nothing here knows what a library or a media item is.
|
// Presentation only — nothing here knows what a library or a media item is.
|
||||||
//
|
//
|
||||||
// TRACES: UR-075 | DR-163 | UT-158, UT-159, UT-160
|
// TRACES: UR-075 | DR-172 | UT-158, UT-159, UT-160
|
||||||
|
|
||||||
/** A tile to place: an opaque key and the aspect ratio (width / height) to honour. */
|
/** A tile to place: an opaque key and the aspect ratio (width / height) to honour. */
|
||||||
export interface MosaicInput {
|
export interface MosaicInput {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function resolveFavoritesScope(raw: string | null | undefined): Favorites
|
|||||||
* than folded into "all": a caller asking "which category is this?" wants no
|
* than folded into "all": a caller asking "which category is this?" wants no
|
||||||
* answer, not the cross-category one.
|
* answer, not the cross-category one.
|
||||||
*
|
*
|
||||||
* TRACES: UR-075 | DR-164
|
* TRACES: UR-075 | DR-173
|
||||||
*/
|
*/
|
||||||
export function asFavoritesScope(
|
export function asFavoritesScope(
|
||||||
scope: SearchScope | null | undefined,
|
scope: SearchScope | null | undefined,
|
||||||
|
|||||||
@@ -118,7 +118,7 @@
|
|||||||
// The shortcut strip is a mosaic row: one height, each tile as wide as its own
|
// The shortcut strip is a mosaic row: one height, each tile as wide as its own
|
||||||
// artwork. It used to force 16:9 on everything so square music covers lined up
|
// artwork. It used to force 16:9 on everything so square music covers lined up
|
||||||
// with wide backdrops — which lined them up by cropping the covers.
|
// with wide backdrops — which lined them up by cropping the covers.
|
||||||
// TRACES: UR-075 | DR-163
|
// TRACES: UR-075 | DR-172
|
||||||
const LIBRARY_STRIP_HEIGHT = 132;
|
const LIBRARY_STRIP_HEIGHT = 132;
|
||||||
const libraryTiles = $derived(
|
const libraryTiles = $derived(
|
||||||
shortcutLibraries.map((lib) => ({ key: lib.id, ratio: assumedLibraryRatio(lib), library: lib }))
|
shortcutLibraries.map((lib) => ({ key: lib.id, ratio: assumedLibraryRatio(lib), library: lib }))
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
// The overview is a mosaic: rows of one height, tiles of their own widths, so
|
// The overview is a mosaic: rows of one height, tiles of their own widths, so
|
||||||
// a square music cover sits beside a wide backdrop without either being
|
// a square music cover sits beside a wide backdrop without either being
|
||||||
// cropped to the other's shape. Each category also gets a favourites tile of
|
// cropped to the other's shape. Each category also gets a favourites tile of
|
||||||
// its own, beside the library it belongs to. TRACES: UR-075 | DR-163, DR-164
|
// its own, beside the library it belongs to. TRACES: UR-075 | DR-172, DR-173
|
||||||
const mosaicEntries = $derived(buildLibraryMosaic(visibleLibraries));
|
const mosaicEntries = $derived(buildLibraryMosaic(visibleLibraries));
|
||||||
|
|
||||||
// Track if we've done an initial load and previous server state
|
// Track if we've done an initial load and previous server state
|
||||||
@@ -255,7 +255,7 @@
|
|||||||
category's own favourites sits beside its library — a labelled tile
|
category's own favourites sits beside its library — a labelled tile
|
||||||
at the same weight as a library is the difference between a feature
|
at the same weight as a library is the difference between a feature
|
||||||
people find and one they don't. ux-flows §5C.2.
|
people find and one they don't. ux-flows §5C.2.
|
||||||
TRACES: UR-067, UR-075 | DR-117, DR-163 -->
|
TRACES: UR-067, UR-075 | DR-117, DR-172 -->
|
||||||
<MosaicGrid items={mosaicEntries} gap={8}>
|
<MosaicGrid items={mosaicEntries} gap={8}>
|
||||||
{#snippet tile(entry)}
|
{#snippet tile(entry)}
|
||||||
{#if entry.kind === "favorites"}
|
{#if entry.kind === "favorites"}
|
||||||
|
|||||||
Reference in New Issue
Block a user