fix(player): resume a transcoded video by seeking, not by asking for a stream that starts mid-item
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m30s
Traceability Validation / Check Requirement Traces (push) Successful in 19s

A resumed transcode played nothing at all: every segment came back 400, hls.js
exhausted its retries and gave up, while the same episode from the beginning was
fine.

Jellyfin builds each segment URI by echoing the master playlist's query string
into it, and its segment handler opens by rejecting any request carrying
StartTimeTicks > 0 (ArgumentException → 400). So one resume position on the
playlist is copied onto every hls1/main/N.ts and 400s all of them — the `> 0`
being exactly why starting from the beginning survived.

HLS does not need the parameter: a playlist spans the whole item and asking for
segment N *is* the seek. It is removed from the URL builder entirely rather than
conditionalised — the builder cannot know whether its response will be
segmented — and the position becomes a seek issued once the player has loaded.
The progressive /Audio/universal builder behind the background-audio handoff has
no segments and keeps its StartTimeTicks, which is why audio-only handoffs
resumed correctly and video ones did not.

Completing that across the boundary, since the URL no longer starts where the
caller asked:

- reloadSource(url, position) now means "reload and resume AT this absolute
  position": it seeks the element once the source is playable and clears the
  transcode offset to zero. It previously set the offset to the position and
  seeked nothing, which was correct only while the URL itself began there —
  left in place it would have shown 20:00 on the scrubber while the opening
  titles played, with no seek ever happening.
- The transcoded resume path in the player page collapses into the same
  "seek after load" branch direct streams already used.
- VideoPlayer's background-audio return does the same: no base, seek to the
  absolute position.
- The stale test asserting StartTimeTicks is present is rewritten to keep its
  other half (an HLS master playlist, never a progressive stream.mp4, carrying
  the chosen source and audio track).

TRACES: UR-004, UR-005, UR-019, UR-021, UR-074 | DR-181 | UT-182, UT-183
This commit is contained in:
2026-08-16 11:08:42 +02:00
parent 521acc75fd
commit c0c6c5023e
15 changed files with 2553 additions and 2265 deletions
+5 -2
View File
@@ -339,6 +339,7 @@ Internal architecture, components, and application logic.
| DR-178 | Every position that leaves the app is read from the controller, not from a backend that may not be playing anything. `PlayerController::position()` forwards to the native backend, which is authoritative for exactly one of the three ways this app renders media. On the **webview** path — the shipping default for video on both platforms — nothing is loaded into that backend at all: the `<video>` element is the player, its ticks were re-emitted to the frontend and then dropped, and the backend answered 0 forever. During a **background-audio handoff** the base that converts the stream's relative timeline to the episode's is applied once at the native tick boundary (DR-159), so before ExoPlayer's first tick nothing has applied it and the reading is 0 there too. Both holes surfaced as the same user-visible bug through different doors: returning to the foreground while the audio-only transcode was still opening handed the frontend `0.0`, and the video reloaded at `StartTimeTicks=0` — the episode restarting from the beginning — while the `Stopped` report that followed wrote that zero to Jellyfin as the resume point. `absolute_position()` answers for all three paths: the maximum of the backend's reading, the last position webview-rendered media reported, and the handoff base. The maximum is exact rather than a heuristic, because at most one term is ever meaningful at a time and the base is a floor the stream cannot physically be behind. `duration()` gains the same fallback for the same reason. The element's reading is cleared wherever it stops being the player — teardown, a handoff taking over, a different item loading — so it can never be attributed to what plays next | Player | UR-005, UR-025, UR-040 | Done (pending device verification) |
| DR-179 | Jellyfin is told what was played: progress while it plays, and a stop when it ends. A device trace of 35 minutes' playback requested `/Sessions/Playing/Progress` **zero** times and sent 14 `Stopped` reports, every one of them at position 0. Three faults, one subject. *Progress never left the device*: the frontend service writes it to the local DB by design, and nothing on the Rust side reported it for webview-rendered media — so the server learned a position only when the player was closed, and a crash or a swipe-away cost the session. It is now reported from the controller's own position ticks, through the 30s throttler it already owned and shares with the native audio path, which covers all three rendering paths in one place instead of adding a second frequent IPC caller. *Zero-position stops were sent*: Jellyfin stores the reported position as the resume point, so a zero does not merely fail to inform, it instructs the server to forget — and no zero was ever real, each one coming from asking a player that was not rendering the media (see DR-178). They are withheld; one landed 40s after the frontend had correctly reported 15:22 for the same episode, overwriting it. *A finished episode reported nothing at all*: Jellyfin decides "watched" from the stop report and its percentage, and in background audio-only mode nobody sends one — the webview is suspended and its element was torn down at the handoff, while the backend advances to the next episode without a word about the one that ended, so an episode listened to end-to-end on the lockscreen never counted as watched. `on_playback_ended` now reports it stopped at its **runtime** (not the last tick, which can be seconds short or, on a handoff whose ticks stopped early, nowhere near the end) before any advance, since after one the queue's current item is the next episode. Scoped to the audio-only handoff, the case the frontend provably cannot cover, so foreground playback keeps its single existing report; music ending natively remains unreported and wants its own change. The reporting seam is a `PlaybackReportSink` the controller sends to, which also collapses three copies of the spawn-a-task-and-hope block into one and is what let all of this be written as failing tests rather than found on a device a second time | Player | UR-025, UR-005, UR-040 | Done (pending device verification) |
| DR-180 | A background-audio handoff of a **downloaded** episode starts where the video left off. The handoff prefers a local file over the audio-only stream (DR-128), but the two begin in different places and were treated alike: a stream is built with `StartTimeTicks`, so the server makes the handoff point that stream's zero and the base is the handoff position with no seek — while a file has no such parameter and begins at the episode's own zero, so basing it at the handoff position claimed minutes of audio that were about to play from the beginning. Backgrounding a downloaded episode therefore restarted it while the lockscreen scrubber, dutifully adding the base, showed the position it should have been at. `background_audio_plan` splits the two: a file gets no base and a real seek, a stream keeps the base and no seek (seeking one would skip *past* the content by the handoff position again). The same distinction settles an inbound seek — `seek_absolute` re-opens a *streamed* handoff at the requested position because a chunked length-less transcode cannot honour a seek, which is not true of local media, and `resume_stream_at` refuses a non-remote source outright, so routing a lockscreen scrub of a downloaded episode through it failed the seek rather than performing it | Player | UR-040, UR-071 | Done (pending device verification) |
| DR-181 | A resumed transcode plays. Every video stream URL carried the resume position as `StartTimeTicks`, which is correct for a progressive response and fatal for an HLS one: Jellyfin builds each segment URI by echoing the **master playlist's** query string into it, and its segment handler opens by rejecting any request carrying `StartTimeTicks > 0` (`ArgumentException``400`). One position on the playlist therefore 400s every `hls1/main/N.ts` behind it, so hls.js exhausted its retries and gave up — presenting as an episode that will not resume while the same episode from the beginning is fine, the `> 0` being exactly why the beginning survived. The parameter is also unnecessary there: a playlist spans the whole item and asking for segment N *is* the seek, which the server transcodes from. So it is removed from the URL builder entirely rather than conditionalised — the builder has one caller shape and no way to know whether the response will be segmented — and the position becomes what it always was for HLS, a seek issued once the player has loaded: the seek path reloads at zero and seeks the element, and the resume path lets the player seek itself. The progressive `/Audio/universal` builder used by the background-audio handoff is a different endpoint with no segments and keeps its `StartTimeTicks`, which is why an audio-only handoff resumes correctly and a video one did not | Playback | UR-004, UR-074 | 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) |
| 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 |
@@ -361,7 +362,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, DR-171, DR-176, DR-177 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181 |
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
@@ -430,7 +431,7 @@ Internal architecture, components, and application logic.
| 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, DR-180 |
| UR-072 | - | DR-156 |
| UR-073 | - | DR-158 |
| UR-074 | - | DR-162, DR-177 |
| UR-074 | - | DR-162, DR-177, DR-181 |
| UR-075 | - | DR-174, DR-175 |
---
@@ -616,6 +617,8 @@ Internal architecture, components, and application logic.
| UT-145 | The frontend's subtitle payload survives the IPC hop: a camelCase `PlayItemRequest` carrying `subtitles` deserializes, `create_media_item` lands them on `MediaItem.subtitles` in the order sent, and a request without the field still defaults to empty | UR-020, IR-016 | Done |
| UT-146 | The subtitle JSON serialized across the JNI boundary uses the keys `JellyTauPlayer.load()` reads — `url`, `language`, `label` and `mime_type`, never `mimeType` | UR-020, IR-016, JA-008 | Done |
| UT-147 | The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps `mime_type` and stream order, `playerPlayItem` actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number | UR-020, IR-016 | Done |
| UT-182 | An HLS video URL never carries `StartTimeTicks` — with a position supplied or not — while the master playlist, codec, media source and chosen audio track still ride on it | DR-181 | Done |
| UT-183 | A reloaded stream is resumed by seeking the element to the absolute position with the transcode offset cleared to zero — never by carrying the position as an offset base, which since DR-181 would display the position while playing the item from its start — and a reload to 0:00 waits for no seek | DR-181 | Done |
### Integration Tests
+2295 -2169
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -175,8 +175,8 @@ describe("live requirements.md", () => {
expect(defined.UR).toBe(75);
expect(defined.IR).toBe(32);
expect(defined.DR).toBe(171);
expect(defined.DR).toBe(172);
expect(defined.JA).toBe(35);
expect(defined.total).toBe(313);
expect(defined.total).toBe(314);
});
});
+21 -7
View File
@@ -1392,7 +1392,6 @@ pub async fn player_seek_video(
.get_video_stream_url(
&jellyfin_item_id,
media_source_id.as_deref(),
Some(position),
audio_stream_index,
)
.await
@@ -1403,6 +1402,13 @@ pub async fn player_seek_video(
position
);
// `seek_offset` carries the position to RESUME AT, not a base to add
// to the element's clock. The reloaded stream starts at the item's
// zero — a position on an HLS playlist makes the server 400 every
// segment behind it (DR-181) — so the adapter reaches the position by
// seeking the element and leaves the transcode offset at zero. The
// field keeps its name only because renaming it means regenerating
// the specta bindings; `reloadSource` documents the contract.
Ok(VideoSeekResponse::ReloadStream {
new_url,
seek_offset: position,
@@ -1416,7 +1422,6 @@ pub async fn player_seek_video(
.get_video_stream_url(
&jellyfin_item_id,
media_source_id.as_deref(),
Some(position),
audio_stream_index,
)
.await
@@ -1454,6 +1459,11 @@ pub async fn player_seek_video(
} else {
return Err("No current item after URL update".to_string());
}
// The re-opened stream begins at zero — the position cannot ride
// along in the URL without 400ing every segment (DR-181) — so the
// seek that the reload was asked for happens here.
controller.seek(position).map_err(|e| e.to_string())?;
}
info!(
@@ -1504,12 +1514,13 @@ pub async fn player_switch_audio_track(
.to_string()
};
// Get new stream URL with selected audio track
// Get new stream URL with selected audio track. It starts at zero — an
// HLS playlist cannot carry a position (DR-181) — and `position` below
// tells the frontend where to seek the reloaded element back to.
let new_url = repository
.get_video_stream_url(
&jellyfin_item_id,
media_source_id.as_deref(),
current_position,
Some(stream_index),
)
.await
@@ -1600,7 +1611,6 @@ pub async fn player_set_stream_quality(
.get_video_stream_url(
&jellyfin_item_id,
media_source_id.as_deref(),
current_position,
audio_stream_index,
)
.await
@@ -1612,8 +1622,9 @@ pub async fn player_set_stream_quality(
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
// new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
// The URL already carries `StartTimeTicks`, so the reloaded stream begins at
// the current position rather than at zero.
// The re-opened stream begins at zero (an HLS playlist cannot carry a start
// position without 400ing every segment — DR-181), so it is seeked back to
// where the picture was.
{
let controller = player.0.lock().await;
controller.stop().map_err(|e| e.to_string())?;
@@ -1631,6 +1642,9 @@ pub async fn player_set_stream_quality(
controller
.load_and_play(updated_item)
.map_err(|e| e.to_string())?;
if position > 0.0 {
controller.seek(position).map_err(|e| e.to_string())?;
}
}
Ok(StreamQualityResponse::Native { position })
+8 -8
View File
@@ -571,7 +571,13 @@ pub async fn repository_get_playback_info(
.map_err(|e| format!("{:?}", e))
}
/// Get video stream URL with optional seeking support
/// Get a video stream URL.
///
/// There is no start-position parameter on purpose: the URL is an HLS playlist
/// covering the whole item, and a position on it makes the server reject every
/// segment with `400` (DR-181). Callers resume by seeking after load.
///
/// TRACES: UR-004 | DR-181 | UT-182
#[tauri::command]
#[specta::specta]
pub async fn repository_get_video_stream_url(
@@ -579,17 +585,11 @@ pub async fn repository_get_video_stream_url(
handle: String,
item_id: String,
media_source_id: Option<String>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_video_stream_url(
&item_id,
media_source_id.as_deref(),
start_time_seconds,
audio_stream_index,
)
.get_video_stream_url(&item_id, media_source_id.as_deref(), audio_stream_index)
.await
.map_err(|e| format!("{:?}", e))
}
+7 -9
View File
@@ -79,22 +79,20 @@ impl HybridRepository {
self.online.get_jray_actors(item_id, t).await
}
/// Get video stream URL with optional seeking support.
/// This method is online-only since offline playback uses local file paths.
/// Get video stream URL. This method is online-only since offline playback
/// uses local file paths.
///
/// Takes no start position: the URL is an HLS playlist spanning the whole
/// item, and a position on it would 400 every segment — see
/// `OnlineRepository::get_video_stream_url`. Resume by seeking after load.
pub async fn get_video_stream_url(
&self,
item_id: &str,
media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
self.online
.get_video_stream_url(
item_id,
media_source_id,
start_time_seconds,
audio_stream_index,
)
.get_video_stream_url(item_id, media_source_id, audio_stream_index)
.await
}
+76 -26
View File
@@ -494,33 +494,39 @@ impl OnlineRepository {
}
}
/// Get a video stream URL for playback at an arbitrary position (resume,
/// transcoded seeking, audio-track switching).
/// Get a video stream URL (initial play, resume, transcoded seeking,
/// audio-track switching).
///
/// Returns an HLS master playlist (`/Videos/{id}/master.m3u8`) transcoded to
/// h264/aac. HLS is used rather than a progressive `stream.mp4` because the
/// HTML5 `<video>` element (via HLS.js) starts playing within seconds and can
/// seek within the stream, whereas a progressive MP4 transcode of HEVC source
/// forces the server to transcode the whole file before playback can begin —
/// which manifests as playback never starting. `StartTimeTicks` makes the
/// server begin the transcode at the requested position.
/// which manifests as playback never starting.
///
/// **There is deliberately no start-position parameter.** A playlist covers
/// the whole item and asking for segment N *is* the seek, so a position would
/// be redundant — and actively fatal: Jellyfin builds every segment URI by
/// echoing this playlist's query string into it, while its segment handler
/// rejects `StartTimeTicks > 0` outright (`ArgumentException` → `400`). One
/// resume position here therefore 400s every segment of the stream, which
/// presents as a resumed episode that simply never plays while the same
/// episode from the beginning is fine. Resume by seeking the player once it
/// has loaded. (The progressive `/Audio/universal` builder below has no
/// segments and keeps its `StartTimeTicks`.)
///
/// The stream is built against the current [`streaming_quality`] ceiling:
/// `MaxStreamingBitrate`/`VideoBitrate`/`AudioBitrate`, plus a `MaxHeight`
/// that suits the budget. `Original` keeps the historical 20/18 Mbps
/// allowance, which is a transcode ceiling rather than a user-facing limit.
///
/// TRACES: UR-004, UR-074 | DR-140, DR-162, DR-177 | UT-130, UT-156, UT-173
/// TRACES: UR-004, UR-074 | DR-140, DR-162, DR-177, DR-181 | UT-130, UT-156, UT-173, UT-182
pub async fn get_video_stream_url(
&self,
item_id: &str,
media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
// Convert seconds to ticks (10,000,000 ticks per second)
let start_time_ticks = start_time_seconds.map(|seconds| (seconds * 10_000_000.0) as i64);
let quality = streaming_quality();
// `Original` is uncapped as a *user* setting, but a transcode still needs
// a ceiling to encode against — keep the values this endpoint has always
@@ -589,10 +595,6 @@ impl OnlineRepository {
params.push(("MediaSourceId", source_id.to_string()));
}
if let Some(ticks) = start_time_ticks {
params.push(("StartTimeTicks", ticks.to_string()));
}
// Build query string (values are already safe, no encoding needed)
let query = params
.iter()
@@ -1745,7 +1747,7 @@ impl MediaRepository for OnlineRepository {
"[PlaybackInfo] Server offered direct play for audio the webview cannot decode ({:?}) — forcing an HLS transcode",
audio_streams.first().and_then(|(codec, _)| *codec)
);
self.get_video_stream_url(item_id, Some(&source.id), None, None)
self.get_video_stream_url(item_id, Some(&source.id), None)
.await?
} else {
// Fall back to direct stream URL. No audioStreamIndex: static=true
@@ -2680,7 +2682,7 @@ mod tests {
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", None, None, None)
.get_video_stream_url("vid-1", None, None)
.await
.unwrap();
@@ -2702,7 +2704,7 @@ mod tests {
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", None, None, None)
.get_video_stream_url("vid-1", None, None)
.await
.unwrap();
@@ -2740,16 +2742,25 @@ mod tests {
assert!(url.contains("MaxStreamingBitrate=384000"), "url: {url}");
}
/// Transcoded video must be an HLS master playlist, not a progressive
/// `stream.mp4`: a progressive transcode of an HEVC source makes the server
/// convert the whole file before serving a byte, which presents as playback
/// that never starts. The chosen source and audio track ride along with it.
///
/// This is the surviving half of the old
/// `test_get_video_stream_url_returns_hls_with_position`, whose other half
/// asserted the `StartTimeTicks` that DR-181 removed — the position now
/// belongs to a seek after load, never to this URL, so the assertion for it
/// is gone rather than inverted (its inverse is UT-182's own test).
///
/// TRACES: UR-004 | DR-140, DR-181 | UT-130
#[tokio::test]
async fn test_get_video_stream_url_returns_hls_with_position() {
// Transcoded video resume/seek must produce an HLS master playlist with
// StartTimeTicks, not a progressive stream.mp4 (which never starts playing
// for HEVC sources). See get_video_stream_url docs.
async fn test_get_video_stream_url_returns_an_hls_master_playlist() {
let _fixture = QualityFixture::set(StreamingQuality::Original);
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", Some("source-1"), Some(193.0), Some(1))
.get_video_stream_url("vid-1", Some("source-1"), Some(1))
.await
.unwrap();
@@ -2760,18 +2771,57 @@ mod tests {
assert!(url.contains("VideoCodec=h264"));
assert!(url.contains("MediaSourceId=source-1"));
assert!(url.contains("AudioStreamIndex=1"));
// 193.0 seconds * 10_000_000 ticks/sec
assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
assert!(!url.contains("stream.mp4"));
}
/// Resuming a transcoded video played nothing at all: every segment came back
/// `400`, hls.js exhausted its retries and gave up. Starting the same episode
/// from the beginning was fine.
///
/// Jellyfin builds each segment URI by echoing the *master playlist's* query
/// string into it (`CreateMainPlaylistRequest(… Request.QueryString …)`), and
/// its segment handler opens with
///
/// ```csharp
/// if ((streamingRequest.StartTimeTicks ?? 0) > 0)
/// throw new ArgumentException("StartTimeTicks is not allowed.");
/// ```
///
/// so a resume position put on the playlist is copied onto every
/// `hls1/main/N.ts` and makes all of them 400. `> 0` is exactly why playing
/// from the beginning survived.
///
/// HLS does not need the parameter: the playlist spans the whole item, and
/// asking for segment N *is* the seek — the server transcodes from there. So
/// the position never belongs in this URL; the player seeks after load. The
/// sibling progressive `/Audio/universal` builder is a different endpoint with
/// no segments, and keeps its `StartTimeTicks`.
///
/// TRACES: UR-004, UR-074 | DR-181 | UT-182
#[tokio::test]
async fn test_video_stream_url_never_carries_start_time_ticks() {
let _fixture = QualityFixture::set(StreamingQuality::Original);
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", Some("source-1"), Some(1))
.await
.unwrap();
assert!(
!url.contains("StartTimeTicks"),
"an HLS playlist must never carry StartTimeTicks — the server copies it \
onto every segment URI and then rejects each one with 400: {url}"
);
}
#[tokio::test]
async fn test_get_video_stream_url_omits_position_when_absent() {
let _fixture = QualityFixture::set(StreamingQuality::Original);
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", None, None, None)
.get_video_stream_url("vid-1", None, None)
.await
.unwrap();
@@ -2804,7 +2854,7 @@ mod tests {
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", None, None, None)
.get_video_stream_url("vid-1", None, None)
.await
.unwrap();
@@ -2833,7 +2883,7 @@ mod tests {
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", Some("source-1"), Some(12.0), Some(1))
.get_video_stream_url("vid-1", Some("source-1"), Some(1))
.await
.unwrap();
+9 -3
View File
@@ -1514,10 +1514,16 @@ async repositoryGetPlaybackInfo(handle: string, itemId: string) : Promise<Playba
return await TAURI_INVOKE("repository_get_playback_info", { handle, itemId });
},
/**
* Get video stream URL with optional seeking support
* Get a video stream URL.
*
* There is no start-position parameter on purpose: the URL is an HLS playlist
* covering the whole item, and a position on it makes the server reject every
* segment with `400` (DR-181). Callers resume by seeking after load.
*
* TRACES: UR-004 | DR-181 | UT-182
*/
async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId: string | null, startTimeSeconds: number | null, audioStreamIndex: number | null) : Promise<string> {
return await TAURI_INVOKE("repository_get_video_stream_url", { handle, itemId, mediaSourceId, startTimeSeconds, audioStreamIndex });
async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<string> {
return await TAURI_INVOKE("repository_get_video_stream_url", { handle, itemId, mediaSourceId, audioStreamIndex });
},
/**
* Get audio stream URL for a track
+7 -4
View File
@@ -409,23 +409,26 @@ describe("RepositoryClient", () => {
handle: "test-handle-123",
itemId: "item123",
mediaSourceId: null,
startTimeSeconds: null,
audioStreamIndex: null,
});
});
/**
* There is no start-position argument: a position on the HLS playlist makes
* the server reject every segment behind it with 400, so resume and seek are
* performed by seeking the player after load (DR-181).
*/
it("should get video stream URL with options", async () => {
const mockUrl = "https://server.com/Videos/item123/stream.mp4?start=300&api_key=token";
const mockUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token";
(invoke as any).mockResolvedValueOnce(mockUrl);
const url = await client.getVideoStreamUrl("item123", "source456", 300, 0);
const url = await client.getVideoStreamUrl("item123", "source456", 0);
expect(url).toBe(mockUrl);
expect(invoke).toHaveBeenCalledWith("repository_get_video_stream_url", {
handle: "test-handle-123",
itemId: "item123",
mediaSourceId: "source456",
startTimeSeconds: 300,
audioStreamIndex: 0,
});
});
+10 -2
View File
@@ -213,17 +213,25 @@ export class RepositoryClient {
return commands.repositoryGetAudioStreamUrl(this.ensureHandle(), itemId);
}
/**
* A video stream URL, which always begins at the **start of the item**.
*
* There is deliberately no position parameter: the URL is an HLS playlist, and
* a start position on it makes Jellyfin reject every segment behind it with
* `400` (DR-181). Resume and transcoded seeking are performed by seeking the
* player once the stream has loaded.
*
* TRACES: UR-004 | DR-181 | UT-182
*/
async getVideoStreamUrl(
itemId: string,
mediaSourceId?: string,
startTimeSeconds?: number,
audioStreamIndex?: number
): Promise<string> {
return commands.repositoryGetVideoStreamUrl(
this.ensureHandle(),
itemId,
mediaSourceId ?? null,
startTimeSeconds ?? null,
audioStreamIndex ?? null
);
}
+11 -6
View File
@@ -1612,14 +1612,19 @@
// Determine the target URL + how the element/offset should be positioned.
let targetUrl: string;
if (needsTranscoding && onSeek) {
// Transcoded HLS can't seek by setting currentTime — the stream must be
// rebuilt at the new position (StartTimeTicks). onSeek returns that URL.
// The reloaded segment's timeline starts at 0, so seekOffset carries the
// absolute base and the element seeks to 0 (handled on canplay).
// Transcoded HLS is rebuilt rather than seeked in place, but the rebuilt
// stream starts at the BEGINNING of the item, not at `pos`: a start
// position on an HLS playlist is copied onto every segment URI and
// rejected with 400 (DR-181). So there is no base to carry — the element
// is seeked to the absolute position on canplay, exactly like a direct
// stream. This previously set seekOffset = pos, which paired with a URL
// that really did start there; leaving it would now display `pos` while
// playing the opening titles.
// TRACES: UR-040, UR-004 | DR-181
targetUrl = await onSeek(pos, selectedAudioTrackIndex ?? undefined);
seekOffset = pos;
seekOffset = 0;
currentTime = pos;
pendingForegroundSeek = 0;
pendingForegroundSeek = pos;
} else {
// Direct stream: reload the original URL and seek the element to pos.
targetUrl = streamUrl;
+46 -1
View File
@@ -192,13 +192,57 @@ describe("Html5PlayerAdapter", () => {
// Allow the internal 100ms settle delay, then fire canplay to resume.
await new Promise((r) => setTimeout(r, 110));
expect(bridge.setSeekOffset).toHaveBeenCalledWith(120);
expect(bridge.setStreamUrl).toHaveBeenCalledWith("http://new/master.m3u8");
video._fire("canplay");
video._fire("seeked");
await p;
expect(video.play).toHaveBeenCalled(); // resumed because it was playing
});
/**
* The reload lands the viewer at the position they asked for by *seeking*,
* with no transcode offset left over.
*
* This used to be inverted: the offset was set to the position and nothing
* seeked, which was right only while the reloaded URL itself began there via
* `StartTimeTicks`. DR-181 removes that parameter, because on an HLS playlist
* the server copies it onto every segment URI and then rejects each one with
* `400`. With the URL starting at the item's zero, the old arithmetic leaves
* `currentTime = offset + 0` the scrubber reading 20:00 over the opening
* titles, and the seek silently never happening.
*
* TRACES: UR-004, UR-005 | DR-181 | UT-183
*/
it("reloadSource() seeks to the position and clears the transcode offset", async () => {
video.paused = false;
const p = adapter.reloadSource("http://new/master.m3u8", 1200);
await new Promise((r) => setTimeout(r, 110));
expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
expect(bridge.setSeekOffset).not.toHaveBeenCalledWith(1200);
// Nothing may seek before the new source is playable — the element drops it.
expect(video.currentTime).not.toBe(1200);
video._fire("canplay");
await new Promise((r) => setTimeout(r, 0));
expect(video.currentTime).toBe(1200);
video._fire("seeked");
await p;
expect(video.play).toHaveBeenCalled();
});
/** A reload to the very start has nothing to seek to; it must not stall. */
it("reloadSource() at position 0 does not wait for a seek", async () => {
video.paused = false;
const p = adapter.reloadSource("http://new/master.m3u8", 0);
await new Promise((r) => setTimeout(r, 110));
video._fire("canplay");
await p; // resolves without any "seeked" event
expect(video.play).toHaveBeenCalled();
});
/**
* A reload that never becomes playable must be reported as a failure. It used
* to resolve on the timeout, so a quality switch whose new stream the server
@@ -227,6 +271,7 @@ describe("Html5PlayerAdapter", () => {
const p = adapter.reloadSource("http://new/master.m3u8", 30);
await new Promise((r) => setTimeout(r, 110));
video._fire("canplay");
video._fire("seeked");
await p;
expect(video.play).not.toHaveBeenCalled();
});
+28 -7
View File
@@ -150,16 +150,29 @@ export class Html5PlayerAdapter implements PlayerAdapter {
}
/**
* PRIMITIVE: compound reload the invariant HTML5 sequence to swap the source
* and resume at `offset`. Contains NO strategy decision; the backend already
* decided to reload and supplied the url/offset. Preserves the hard-won
* dual-audio teardown and canplay wait.
* PRIMITIVE: compound reload swap the source and resume at
* `positionSeconds`, an **absolute** position on the item's own timeline.
* Contains NO strategy decision; the backend already decided to reload and
* supplied the url/position. Preserves the hard-won dual-audio teardown and
* canplay wait.
*
* The position is reached by *seeking the element*, and the transcode offset
* is cleared to zero. It used to be the other way round the offset was set
* to the position and nothing seeked which was correct only while the
* reloaded URL itself began there, via `StartTimeTicks`. DR-181 removes that
* parameter (on an HLS playlist it makes the server reject every segment with
* `400`), so a reloaded stream now always starts at the beginning of the item.
* Leaving the old arithmetic in place would have left `currentTime` reading
* `offset + 0` the scrubber showing 20:00 while the opening titles play, and
* no seek ever happening.
*
* TRACES: UR-004, UR-005 | DR-181 | UT-183
*/
async reloadSource(url: string, offset: number): Promise<void> {
async reloadSource(url: string, positionSeconds: number): Promise<void> {
const el = this.element;
if (!el) {
// Still update the stream URL so the component's HLS $effect can pick it up.
this.bridge.setSeekOffset(offset);
this.bridge.setSeekOffset(0);
this.bridge.setStreamUrl(url);
return;
}
@@ -171,7 +184,8 @@ export class Html5PlayerAdapter implements PlayerAdapter {
el.load();
}
await new Promise((r) => setTimeout(r, 100));
this.bridge.setSeekOffset(offset);
// The reloaded stream begins at the item's zero, so there is no base to add.
this.bridge.setSeekOffset(0);
this.bridge.setStreamUrl(url);
// A source that never becomes playable is a failed reload, not a slow one:
// the caller (quality switch, transcoded seek) has to know so it can revert
@@ -181,6 +195,13 @@ export class Html5PlayerAdapter implements PlayerAdapter {
if (!ready) {
throw new Error(`Reloaded stream never fired "canplay" within 10000ms`);
}
// Now that the new source is playable, put it where the caller asked for.
// Seeking before `canplay` is dropped by the element, which is why this
// follows the wait rather than riding along with the URL swap.
if (positionSeconds > 0) {
el.currentTime = positionSeconds;
await this.waitForEvent(el, "seeked", 2000);
}
if (wasPlaying) await el.play();
}
+3
View File
@@ -152,6 +152,9 @@ async function seekVideo(
)) as any;
// Serde keeps these snake_case (only the "strategy" tag is camelCase).
if (response.strategy === "reloadStream") {
// `seek_offset` is the ABSOLUTE position to resume at, not a base to add to
// the element's clock: the reloaded stream starts at the item's zero since
// DR-181, so reloadSource seeks there. (The name is the wire field's.)
await adapter.reloadSource(response.new_url ?? "", response.seek_offset ?? positionSeconds);
} else {
await adapter.seekElement(response.position ?? positionSeconds, 0);
+25 -19
View File
@@ -334,22 +334,20 @@
: `loadAndPlay: Using stream URL: ${streamUrl}`
);
// Set initial position for video player to seek to after load
// Use explicit startPosition, or fall back to retrieved progress from database
// For transcoded content, we need to request a new stream with StartTimeTicks
// Set initial position for the video player to seek to after load.
// Use explicit startPosition, or fall back to retrieved progress.
//
// Transcoded streams resume the same way direct ones do — by seeking
// after load. Asking the server for a stream that *starts* at the
// position is what DR-181 removed: on an HLS playlist that position is
// copied onto every segment URI and the server then rejects each one
// with 400, so a resumed episode played nothing at all while the same
// episode from the beginning was fine.
// TRACES: UR-004, UR-019 | DR-181
const effectivePosition = startPosition ?? retrievedProgressSeconds ?? 0;
if (effectivePosition > 0) {
if (videoNeedsTranscoding) {
// For transcoded streams, get a new URL starting at the position
console.log("loadAndPlay: Getting transcoded stream starting at:", effectivePosition);
streamUrl = await repo.getVideoStreamUrl(id, mediaSourceId ?? undefined, effectivePosition);
} else {
// For direct streams, we'll seek after load
videoInitialPosition = effectivePosition;
console.log("loadAndPlay: Will seek to position after load:", videoInitialPosition);
}
} else {
videoInitialPosition = 0;
videoInitialPosition = effectivePosition > 0 ? effectivePosition : 0;
if (videoInitialPosition > 0) {
console.log("loadAndPlay: Will seek to position after load:", videoInitialPosition);
}
} else {
// For audio, use MPV backend
@@ -531,14 +529,22 @@
}
/**
* Handle video seeking by requesting a new stream URL starting at the given position.
* Transcoded streams don't support native seeking, so we restart from a new position.
* Rebuild the stream for a transcoded seek or an audio-track switch.
*
* The returned URL starts at the beginning of the item, not at
* `positionSeconds`: a start position on an HLS playlist is copied onto every
* segment URI and rejected with 400 (DR-181). The caller seeks the reloaded
* element to the position — `positionSeconds` is kept in the signature because
* VideoPlayer's seek contract passes it, and the audio-track switch needs the
* same rebuild.
*
* TRACES: UR-004, UR-005, UR-021 | DR-181
*/
async function handleVideoSeek(positionSeconds: number, audioStreamIndex?: number): Promise<string> {
async function handleVideoSeek(_positionSeconds: number, audioStreamIndex?: number): Promise<string> {
const repo = auth.getRepository();
const id = itemId;
if (!id) throw new Error("No item ID");
return repo.getVideoStreamUrl(id, mediaSourceId ?? undefined, positionSeconds, audioStreamIndex);
return repo.getVideoStreamUrl(id, mediaSourceId ?? undefined, audioStreamIndex);
}
// Playback reporting callbacks