feat(player): seek strategy follows what the engine says it can do

DR-246. The strategy used to turn on `is_hls` and `use_html5`, decided in a
command handler on behalf of engines it does not own. That is how "who
renders" came to mean "how do I seek", and why a transcoded seek silently did
nothing the moment native video changed the renderer (DR-238).

Engines now declare `Capabilities::seeks_transcoded_in_place` — true for
hls.js, which seeks within the VOD playlist it was handed and lets the server
catch up; false for mpv, whose HLS demuxer cannot make the server transcode
from a new offset. The command asks whichever engine is rendering. Adding an
engine no longer means editing a shared truth table.

The item's transport is not read at the seek site any more; the compiler
flagged it unused, which is the URL-shape input finally disappearing.

A deviation from the spec, recorded deliberately: it called for the engine to
own the decision outright. It cannot. Re-negotiating a stream needs the
repository, which sits above the engine, so the engine states the ability and
the caller acts on it. That still removes the defect — nobody guesses on
another component's behalf — without pretending an engine can reach upward.

Also fixes a latent race in the conformance suite, found by running it: the
seek case asserted immediately, which passes on an engine that records the
target when it accepts a seek and races on one that waits for the decoder to
move. `Harness::await_seek` polls instead, the way the Android suite already
did. It failed with machine load rather than with the code, which is the kind
of test that teaches people to re-run until green.

  MpvPlayer     9/9
  LegacyPlayer  8/9 - still only the mute/rate gap in the old trait

789 tests, clippy -D warnings clean with and without the feature.
This commit is contained in:
2026-08-22 22:21:42 +02:00
parent 5fcf58fa78
commit 6b3d853442
11 changed files with 161 additions and 49 deletions
+23 -20
View File
@@ -1450,7 +1450,7 @@ pub async fn player_seek_video(
// Get current playing item to analyze stream characteristics
// Clone what we need to avoid holding locks across await points
let (needs_transcoding, jellyfin_item_id, is_local, transport) = {
let (needs_transcoding, jellyfin_item_id, is_local) = {
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
@@ -1466,31 +1466,34 @@ pub async fn player_seek_video(
.ok_or("Current video has no Jellyfin ID")?
.to_string();
// The URL itself is no longer read here: the seek strategy now comes
// from the item's own `transport`, not from inspecting the string.
// Neither the URL nor the item's transport is read here any more. The
// strategy turns on whether the *engine* can seek a transcode in place,
// which it declares for itself — so the container the stream happens to
// arrive in stopped being a proxy for anything (DR-246).
let is_local_file = matches!(current_item.source, MediaSource::Local { .. });
let needs_trans = current_item.needs_transcoding;
let transport = current_item.transport;
(needs_trans, jellyfin_id, is_local_file, transport)
(current_item.needs_transcoding, jellyfin_id, is_local_file)
}; // Locks are dropped here
// The transport comes from the backend's own decision, not from searching
// the URL for `.m3u8` — Rust built that URL and knows what it is. Items
// queued without one fall back to `needs_transcoding`, which is exact:
// every transcode this app requests is HLS (DR-140).
//
// TRACES: UR-004, UR-079 | DR-225, DR-230
let is_hls = match transport {
Some(crate::repository::Transport::Hls) => true,
Some(crate::repository::Transport::Progressive)
| Some(crate::repository::Transport::LocalFile) => false,
None => needs_transcoding,
// Whether a transcode can be seeked in place is asked of the engine that is
// rendering, not guessed from the URL's shape or from who is rendering.
// TRACES: UR-040, UR-079 | DR-238, DR-246
let seeks_transcoded_in_place = {
let controller = player.0.lock().await;
controller.capabilities().seeks_transcoded_in_place
};
let strategy = determine_video_seek_strategy(is_local, is_hls, needs_transcoding, use_html5);
let strategy = determine_video_seek_strategy(
is_local,
seeks_transcoded_in_place,
needs_transcoding,
use_html5,
);
info!("[player_seek_video] Stream analysis: is_local={}, is_hls={}, needs_transcoding={}, use_html5={}, strategy={:?}",
is_local, is_hls, needs_transcoding, use_html5, strategy);
info!(
"[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \
needs_transcoding={}, use_html5={}, strategy={:?}",
is_local, seeks_transcoded_in_place, needs_transcoding, use_html5, strategy
);
match strategy {
VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {