Compare commits
4
Commits
d49d027020
...
v0.5.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99ceeadb83 | ||
|
|
7387f35c7e | ||
|
|
ac4fccd499 | ||
|
|
a5535f2941 |
+13
-3
@@ -323,6 +323,11 @@ 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-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-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. The codec set judged against is the **webview's**, not the platform's, even though DR-161 made ExoPlayer the Android default: `experimentalNativeVideo` is a user setting, a downloaded file outlives whatever it was set to when the file arrived, and the narrow list is the only one that holds on both sides of it — at the cost of a Dolby-licensed device re-encoding a track its ExoPlayer could have played. `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-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) |
|
||||
@@ -346,7 +351,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-001 | IR-001, IR-002 | - |
|
||||
| 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-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-006 | IR-005, IR-006, IR-007, IR-008 | - |
|
||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||||
@@ -397,7 +402,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 |
|
||||
| UR-053 | IR-029 | DR-074 |
|
||||
| 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-057 | - | DR-086 |
|
||||
| UR-058 | - | DR-087, DR-142 |
|
||||
@@ -412,7 +417,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-068 | - | DR-119 |
|
||||
| UR-069 | - | DR-113, DR-114, DR-120 |
|
||||
| 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-073 | - | DR-158 |
|
||||
| UR-074 | - | DR-162 |
|
||||
@@ -566,6 +571,11 @@ 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-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-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-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 |
|
||||
|
||||
+4850
-4037
File diff suppressed because it is too large
Load Diff
@@ -173,10 +173,10 @@ describe("live requirements.md", () => {
|
||||
);
|
||||
const defined = countDefinedRequirements(md);
|
||||
|
||||
expect(defined.UR).toBe(73);
|
||||
expect(defined.UR).toBe(74);
|
||||
expect(defined.IR).toBe(32);
|
||||
expect(defined.DR).toBe(156);
|
||||
expect(defined.DR).toBe(162);
|
||||
expect(defined.JA).toBe(35);
|
||||
expect(defined.total).toBe(296);
|
||||
expect(defined.total).toBe(303);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -631,8 +631,6 @@ pub async fn resume_queued_downloads(
|
||||
) -> Result<ResumeQueuedResult, String> {
|
||||
use crate::repository::MediaRepository;
|
||||
|
||||
use crate::repository::HybridRepository;
|
||||
|
||||
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
|
||||
@@ -683,12 +681,13 @@ pub async fn resume_queued_downloads(
|
||||
async move {
|
||||
if media_type == "video" {
|
||||
Some(
|
||||
<HybridRepository as MediaRepository>::get_video_download_url(
|
||||
crate::repository::resolve_video_download_url(
|
||||
repo.as_ref(),
|
||||
&item_id,
|
||||
&quality,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
match repo.get_audio_stream_url(&item_id).await {
|
||||
|
||||
@@ -845,7 +845,19 @@ pub async fn get_downloads(
|
||||
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]
|
||||
#[specta::specta]
|
||||
pub async fn pause_download(
|
||||
@@ -858,19 +870,34 @@ pub async fn pause_download(
|
||||
};
|
||||
|
||||
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)],
|
||||
);
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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]
|
||||
#[specta::specta]
|
||||
pub async fn resume_download(
|
||||
app: tauri::AppHandle,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
download_manager: State<'_, DownloadManagerWrapper>,
|
||||
download_id: i64,
|
||||
) -> Result<(), String> {
|
||||
let db_service = {
|
||||
@@ -879,11 +906,22 @@ pub async fn resume_download(
|
||||
};
|
||||
|
||||
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)],
|
||||
);
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -923,6 +961,13 @@ pub async fn cancel_download(
|
||||
.await
|
||||
.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)
|
||||
{
|
||||
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 {
|
||||
let partial_path = format!("{}.part", path);
|
||||
let _ = std::fs::remove_file(&partial_path); // Ignore errors
|
||||
let target = std::path::PathBuf::from(&path);
|
||||
let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1244,8 +1291,6 @@ pub async fn enqueue_video_downloads(
|
||||
download_ids: Vec<i64>,
|
||||
target_dir: String,
|
||||
) -> Result<(), String> {
|
||||
use crate::repository::MediaRepository;
|
||||
|
||||
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||
|
||||
let db_service = {
|
||||
@@ -1270,10 +1315,12 @@ pub async fn enqueue_video_downloads(
|
||||
}
|
||||
};
|
||||
|
||||
// Build the transcode URL (pure URL builder, no server round-trip).
|
||||
let stream_url = repo
|
||||
.as_ref()
|
||||
.get_video_download_url(&item_id, &quality, None);
|
||||
// Build the download URL, resolving the source's audio codec first so a
|
||||
// track this device cannot decode is re-encoded on the way down rather
|
||||
// than saved as a silent file (DR-167).
|
||||
let stream_url =
|
||||
crate::repository::resolve_video_download_url(repo.as_ref(), &item_id, &quality, None)
|
||||
.await;
|
||||
|
||||
let update_query = Query::with_params(
|
||||
"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 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.
|
||||
if let Ok(mut active) = active_downloads.lock() {
|
||||
@@ -1601,6 +1652,17 @@ fn spawn_download_worker(
|
||||
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) => {
|
||||
error!("Download failed: {:?}", e);
|
||||
|
||||
@@ -1848,17 +1910,26 @@ pub async fn clear_stale_downloads(
|
||||
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(
|
||||
"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())],
|
||||
);
|
||||
|
||||
let file_paths: Vec<String> = db_service
|
||||
.query_many(file_query, |row| row.get(0))
|
||||
let stale: Vec<(i64, String)> = db_service
|
||||
.query_many(file_query, |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.await
|
||||
.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)
|
||||
let delete_query = Query::with_params(
|
||||
"DELETE FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
|
||||
@@ -1870,10 +1941,12 @@ pub async fn clear_stale_downloads(
|
||||
.await
|
||||
.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 {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.part", path));
|
||||
let target = std::path::PathBuf::from(&path);
|
||||
let _ = std::fs::remove_file(&target);
|
||||
let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
|
||||
}
|
||||
|
||||
Ok(deleted_count as i64)
|
||||
|
||||
@@ -804,7 +804,7 @@ pub fn repository_get_subtitle_url(
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
#[allow(dead_code)]
|
||||
pub fn repository_get_video_download_url(
|
||||
pub async fn repository_get_video_download_url(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
@@ -812,9 +812,16 @@ pub fn repository_get_video_download_url(
|
||||
media_source_id: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
Ok(repo
|
||||
.as_ref()
|
||||
.get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
|
||||
// Async because the audio-codec policy has to know what the source's audio
|
||||
// is before it can decide whether the file may be copied verbatim (DR-171).
|
||||
// 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
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
pub mod cache;
|
||||
pub mod events;
|
||||
pub mod network;
|
||||
pub mod stop;
|
||||
pub mod worker;
|
||||
|
||||
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
|
||||
|
||||
use log::warn;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
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>(
|
||||
&self,
|
||||
task: &DownloadTask,
|
||||
stop: &AtomicBool,
|
||||
on_progress: F,
|
||||
) -> Result<DownloadResult, DownloadError>
|
||||
where
|
||||
@@ -43,7 +52,10 @@ impl DownloadWorker {
|
||||
let mut retries = 0;
|
||||
|
||||
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),
|
||||
Err(e) if retries < self.max_retries && e.is_retryable() => {
|
||||
retries += 1;
|
||||
@@ -63,6 +75,7 @@ impl DownloadWorker {
|
||||
async fn try_download<F>(
|
||||
&self,
|
||||
task: &DownloadTask,
|
||||
stop: &AtomicBool,
|
||||
on_progress: &F,
|
||||
) -> Result<DownloadResult, DownloadError>
|
||||
where
|
||||
@@ -76,7 +89,7 @@ impl DownloadWorker {
|
||||
}
|
||||
|
||||
// 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() {
|
||||
fs::metadata(&temp_path).await.map(|m| m.len()).unwrap_or(0)
|
||||
} else {
|
||||
@@ -100,22 +113,32 @@ impl DownloadWorker {
|
||||
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
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_LENGTH)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.map(|len| {
|
||||
if existing_bytes > 0 {
|
||||
len + existing_bytes
|
||||
} else {
|
||||
len
|
||||
}
|
||||
});
|
||||
.map(|len| len + resume_from);
|
||||
|
||||
// Open file for appending
|
||||
let mut file = if existing_bytes > 0 {
|
||||
// Append only when resuming a range the server agreed to; otherwise
|
||||
// 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
|
||||
} else {
|
||||
fs::File::create(&temp_path).await
|
||||
@@ -123,11 +146,22 @@ impl DownloadWorker {
|
||||
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
||||
|
||||
// Stream download with progress tracking
|
||||
let mut downloaded = existing_bytes;
|
||||
let mut downloaded = resume_from;
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut last_progress_emit = std::time::Instant::now();
|
||||
|
||||
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()))?;
|
||||
|
||||
file.write_all(&chunk)
|
||||
@@ -138,7 +172,7 @@ impl DownloadWorker {
|
||||
|
||||
// Emit progress every 500ms or every MB
|
||||
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();
|
||||
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
|
||||
#[derive(Debug)]
|
||||
pub struct DownloadResult {
|
||||
@@ -179,6 +263,10 @@ pub enum DownloadError {
|
||||
Network(String),
|
||||
Http(u16),
|
||||
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 {
|
||||
@@ -188,8 +276,16 @@ impl DownloadError {
|
||||
DownloadError::Network(_) => true,
|
||||
DownloadError::Http(status) => *status >= 500, // Retry server errors
|
||||
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 {
|
||||
@@ -198,6 +294,7 @@ impl std::fmt::Display for DownloadError {
|
||||
DownloadError::Network(msg) => write!(f, "Network error: {}", msg),
|
||||
DownloadError::Http(status) => write!(f, "HTTP error {}", status),
|
||||
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 {
|
||||
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]
|
||||
fn test_exponential_backoff() {
|
||||
assert_eq!(
|
||||
@@ -231,5 +386,9 @@ mod tests {
|
||||
assert!(DownloadError::Http(503).is_retryable());
|
||||
assert!(!DownloadError::Http(404).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 {
|
||||
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!()
|
||||
}
|
||||
async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
|
||||
@@ -65,10 +65,18 @@ const FALLBACK_AUDIO_CODEC: &str = "aac";
|
||||
/// play the **video** can decode.
|
||||
///
|
||||
/// The platform list comes from `MediaCodecList`, which describes ExoPlayer —
|
||||
/// but video does not play through ExoPlayer. Both Android and Linux render it
|
||||
/// in a webview `<video>` element, and Chromium/WebKit decode a much smaller set
|
||||
/// than the platform does. Advertising the raw list makes Jellyfin direct-play a
|
||||
/// track the webview cannot decode, and the user gets picture with no sound.
|
||||
/// but the webview `<video>` element may be what renders the video, and
|
||||
/// Chromium/WebKit decode a much smaller set than the platform does. Advertising
|
||||
/// the raw list makes Jellyfin direct-play a track the webview cannot decode, and
|
||||
/// the user gets picture with no sound.
|
||||
///
|
||||
/// Which renderer gets it is not fixed: Linux is always the element, and Android
|
||||
/// follows `experimentalNativeVideo`, which took ExoPlayer as its default in
|
||||
/// DR-161 but is a user setting either way. So the *narrow* list is the only one
|
||||
/// that holds on both sides of that switch. The cost is a Dolby-licensed Android
|
||||
/// device transcoding an E-AC-3 track its ExoPlayer could have direct-played;
|
||||
/// the alternative is silence for everyone the switch lands the other way, which
|
||||
/// is the bug this exists to prevent.
|
||||
///
|
||||
/// The gap is widest on devices whose vendor licenses Dolby: a phone with
|
||||
/// `c2.dolby.eac3.decoder` reports `eac3`, so it — and only it — gets a silent
|
||||
@@ -120,22 +128,34 @@ pub fn webview_can_decode_audio(codec: &str) -> bool {
|
||||
/// delegate this decision; it knows what its own renderer can decode and must
|
||||
/// apply that itself.
|
||||
///
|
||||
/// The track that matters is the one the server will actually serve: the
|
||||
/// default, or the first when none is marked. An unknown codec is left alone —
|
||||
/// forcing a transcode on a guess would burn server CPU for files that play.
|
||||
/// The track that matters is the one the server will actually serve (see
|
||||
/// [`served_audio_codec`]). An unknown codec is left alone — forcing a transcode
|
||||
/// on a guess would burn server CPU for files that play.
|
||||
///
|
||||
/// TRACES: UR-004 | DR-149 | UT-148
|
||||
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()
|
||||
.find(|(_, is_default)| *is_default)
|
||||
.or_else(|| streams.first());
|
||||
|
||||
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,
|
||||
}
|
||||
.or_else(|| streams.first())
|
||||
.and_then(|(codec, _)| *codec)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -193,6 +213,25 @@ mod tests {
|
||||
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]
|
||||
fn a_dolby_device_does_not_advertise_dolby_for_video() {
|
||||
// The bug: a Motorola reporting c2.dolby.eac3.decoder direct-played
|
||||
|
||||
@@ -872,10 +872,11 @@ impl MediaRepository for HybridRepository {
|
||||
item_id: &str,
|
||||
quality: &str,
|
||||
media_source_id: Option<&str>,
|
||||
source_audio_codec: Option<&str>,
|
||||
) -> String {
|
||||
// Always use online URL for downloads
|
||||
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> {
|
||||
@@ -1299,6 +1300,7 @@ mod tests {
|
||||
_item_id: &str,
|
||||
_quality: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
_source_audio_codec: Option<&str>,
|
||||
) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1573,6 +1575,7 @@ mod tests {
|
||||
_item_id: &str,
|
||||
_quality: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
_source_audio_codec: Option<&str>,
|
||||
) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -197,14 +197,24 @@ pub trait MediaRepository: Send + Sync {
|
||||
format: &str,
|
||||
) -> String;
|
||||
|
||||
/// Get video download URL (synchronous - just constructs URL)
|
||||
/// Called by frontend via Tauri invoke (getVideoDownloadUrl in VideoDownloadButton.svelte)
|
||||
/// Build the URL a video download is fetched from. Synchronous — it only
|
||||
/// 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)]
|
||||
fn get_video_download_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
quality: &str,
|
||||
media_source_id: Option<&str>,
|
||||
source_audio_codec: Option<&str>,
|
||||
) -> String;
|
||||
|
||||
/// Mark item as favorite
|
||||
@@ -323,3 +333,44 @@ pub trait MediaRepository: Send + Sync {
|
||||
new_index: u32,
|
||||
) -> 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
|
||||
/// 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
|
||||
const DOWNLOADED_ITEMS_CTE: &'static str = "
|
||||
WITH downloaded_items AS (
|
||||
@@ -908,6 +934,7 @@ impl OfflineRepository {
|
||||
EXISTS (
|
||||
SELECT 1 FROM libraries l
|
||||
WHERE l.id = ? AND l.server_id = i.server_id
|
||||
AND {membership}
|
||||
)
|
||||
-- Top-level only: hide leaves whose container is downloaded.
|
||||
AND NOT EXISTS (
|
||||
@@ -922,6 +949,7 @@ impl OfflineRepository {
|
||||
ORDER BY i.sort_name ASC, i.name ASC
|
||||
LIMIT {limit} OFFSET {start_index}",
|
||||
cte = Self::DOWNLOADED_ITEMS_CTE,
|
||||
membership = Self::LIBRARY_HOLDS_ITEM,
|
||||
);
|
||||
|
||||
let query = Query::with_params(
|
||||
@@ -966,7 +994,7 @@ impl OfflineRepository {
|
||||
// We match a library by collection_type ↔ item_type instead: any
|
||||
// completed download of a given media kind qualifies that library.
|
||||
let query = Query::with_params(
|
||||
&format!(
|
||||
format!(
|
||||
"{cte}
|
||||
SELECT l.id, l.name, l.collection_type, l.image_tag
|
||||
FROM libraries l
|
||||
@@ -975,15 +1003,11 @@ impl OfflineRepository {
|
||||
SELECT 1 FROM items i
|
||||
INNER JOIN downloaded_items di ON i.id = di.id
|
||||
WHERE i.server_id = l.server_id
|
||||
AND (
|
||||
(l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
|
||||
OR (l.collection_type = 'movies' AND i.item_type = 'Movie')
|
||||
OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
|
||||
OR (l.collection_type NOT IN ('music', 'movies', 'tvshows'))
|
||||
)
|
||||
AND {membership}
|
||||
)
|
||||
ORDER BY l.sort_order ASC, l.name ASC",
|
||||
cte = Self::DOWNLOADED_ITEMS_CTE,
|
||||
membership = Self::LIBRARY_HOLDS_ITEM,
|
||||
),
|
||||
vec![QueryParam::String(self.server_id.clone())],
|
||||
);
|
||||
@@ -1959,6 +1983,7 @@ impl MediaRepository for OfflineRepository {
|
||||
_item_id: &str,
|
||||
_quality: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
_source_audio_codec: Option<&str>,
|
||||
) -> String {
|
||||
// Cannot download while offline
|
||||
String::new()
|
||||
@@ -3720,6 +3745,82 @@ mod tests {
|
||||
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
|
||||
/// Episodes — the same "individual songs" bug seen for music, for TV. The
|
||||
/// season and episode are still reachable by drilling into the series.
|
||||
|
||||
@@ -1876,6 +1876,7 @@ impl MediaRepository for OnlineRepository {
|
||||
item_id: &str,
|
||||
quality: &str,
|
||||
media_source_id: Option<&str>,
|
||||
source_audio_codec: Option<&str>,
|
||||
) -> String {
|
||||
// NOTE: Jellyfin's `/Videos/{id}/download` endpoint is not universally
|
||||
// available (returns 404 on many server configs), which silently broke
|
||||
@@ -1926,10 +1927,39 @@ impl MediaRepository for OnlineRepository {
|
||||
params.push("audioCodec=aac".to_string());
|
||||
params.push("allowVideoStreamCopy=false".to_string());
|
||||
}
|
||||
// "original" (and any unknown value) → direct, resumable copy.
|
||||
_ => {
|
||||
params.push("Static=true".to_string());
|
||||
}
|
||||
// "original" (and any unknown value) → direct, resumable copy —
|
||||
// unless the audio in that copy is undecodable where the file will
|
||||
// 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
|
||||
@@ -2737,7 +2767,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_video_download_url_uses_stream_not_download_endpoint() {
|
||||
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).
|
||||
assert!(
|
||||
@@ -2755,7 +2785,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_video_download_url_original_is_static_direct_copy() {
|
||||
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),
|
||||
// with no transcode params.
|
||||
@@ -2775,7 +2805,7 @@ mod tests {
|
||||
let repo = create_test_repository();
|
||||
|
||||
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!(
|
||||
url.contains("/Videos/item123/stream.mp4"),
|
||||
"{quality} must use stream.mp4: {url}"
|
||||
@@ -2808,7 +2838,7 @@ mod tests {
|
||||
let repo = create_test_repository();
|
||||
|
||||
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!(
|
||||
url.contains("videoBitRate="),
|
||||
@@ -2842,7 +2872,7 @@ mod tests {
|
||||
let repo = create_test_repository();
|
||||
|
||||
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!(
|
||||
url.contains("allowVideoStreamCopy=false"),
|
||||
"{quality} must forbid video stream copy: {url}"
|
||||
@@ -2850,17 +2880,99 @@ mod tests {
|
||||
}
|
||||
|
||||
// "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!(
|
||||
!original.contains("allowVideoStreamCopy=false"),
|
||||
"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]
|
||||
fn test_video_download_url_passes_media_source_id() {
|
||||
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}");
|
||||
}
|
||||
|
||||
|
||||
+21
-2
@@ -870,13 +870,32 @@ async getDownloads(userId: string, statusFilter: string[] | null) : Promise<Down
|
||||
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> {
|
||||
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> {
|
||||
return await TAURI_INVOKE("resume_download", { downloadId });
|
||||
|
||||
@@ -120,10 +120,12 @@
|
||||
* Keep native's picture-in-picture state in step with the `<video>` element.
|
||||
*
|
||||
* PiP is driven by the Activity, and it only ever knew about the native
|
||||
* ExoPlayer surface — a path behind `experimentalNativeVideo`, which defaults
|
||||
* to off. So in the shipping configuration nothing satisfied its "is a video
|
||||
* playing?" check and the PiP button did nothing at all. Reporting the element
|
||||
* gives it a surface it can shrink into. (UR-041, DR-160)
|
||||
* ExoPlayer surface — a path behind `experimentalNativeVideo`, which at the
|
||||
* time defaulted to off. So in the then-shipping configuration nothing
|
||||
* satisfied its "is a video playing?" check and the PiP button did nothing at
|
||||
* all. Reporting the element gives it a surface it can shrink into, and still
|
||||
* has to: the flag defaults to on now (DR-161) but a user who turns it off is
|
||||
* back on the element. (UR-041, DR-160, DR-161)
|
||||
*/
|
||||
function reportPipVideoState() {
|
||||
if (!useHtml5Element || !videoElement) {
|
||||
|
||||
@@ -73,7 +73,11 @@ function createExperimentalNativeVideoStore() {
|
||||
};
|
||||
}
|
||||
|
||||
/** User opt-in for the native Android video path. Default off. */
|
||||
/**
|
||||
* User opt-out for the native Android video path. **Defaults to on** since
|
||||
* DR-161 — see `load()`. The name still says "experimental" because the flag
|
||||
* remains a suppressor of Rust's backend choice, not a promoter of it.
|
||||
*/
|
||||
export const experimentalNativeVideo = createExperimentalNativeVideoStore();
|
||||
|
||||
function createNativeVideoActiveStore() {
|
||||
|
||||
@@ -89,14 +89,18 @@ export function setAutoEnterEnabled(enabled: boolean): void {
|
||||
/**
|
||||
* Tell native that a WebView `<video>` is (or is no longer) the playback surface.
|
||||
*
|
||||
* This is what makes PiP work at all in the shipping configuration. The native
|
||||
* side only ever knew about the ExoPlayer surface, and that path is behind
|
||||
* `experimentalNativeVideo`, which defaults to off — so `canEnterPip` was always
|
||||
* This is what makes PiP work on the HTML5 path. The native side only ever knew
|
||||
* about the ExoPlayer surface, and that path is behind `experimentalNativeVideo`,
|
||||
* which defaulted to off when this was written — so `canEnterPip` was always
|
||||
* false and pressing the button did nothing. Reporting the element's state gives
|
||||
* native a surface it can legitimately shrink into, plus the intrinsic size it
|
||||
* needs for the PiP window's aspect ratio and the play state for its play/pause
|
||||
* action.
|
||||
*
|
||||
* The flag defaults to **on** now (DR-161), so Android normally shrinks the real
|
||||
* ExoPlayer surface instead; this remains the path for Linux and for anyone who
|
||||
* turned the flag off.
|
||||
*
|
||||
* Pass `active: false` when the element goes away, or PiP would be offered over a
|
||||
* video that is no longer there.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user