fix(player,reporting): report real positions, and count an audio-only episode as watched

Returning to the foreground before the background-audio stream had started
playing handed the frontend 0.0s, so the video reloaded at StartTimeTicks=0 —
the episode restarted from the beginning — and the stop report that followed
wrote that zero to Jellyfin as the resume point. Caught on device: locked at
18.4s, unlocked 3.5s later with ExoPlayer still IDLE.

The base that turns a handoff's relative timeline into the episode's is applied
once at the native tick boundary (DR-159), so before the first tick nothing has
applied it. The same blind spot covers webview-rendered media, where nothing is
loaded into the native backend at all and its position is a permanent 0 — which
is why 14 of 14 stop reports in a 35-minute trace were zeroes, one landing 40s
after the frontend had correctly reported 15:22 for the same episode.

- absolute_position(): the maximum of the backend's reading, the last position
  webview media reported, and the handoff base. Exact rather than heuristic —
  at most one term is ever meaningful, and the base is a floor the stream
  cannot physically be behind. duration() gains the same fallback.
- Withhold zero-position stop reports. A zero is never information, and
  Jellyfin stores the reported position as the resume point, so sending one
  only ever destroys a real one.
- Report progress from the controller's own position ticks, through the 30s
  throttler it already shared with the native audio path.
  /Sessions/Playing/Progress was previously requested zero times in 35 minutes.
- Report a finished audio-only episode stopped at its runtime before advancing,
  so Jellyfin's 90% rule marks it played. Nothing else can: the webview is
  suspended and its <video> was torn down at the handoff.
- Split the handoff by source — a downloaded file takes no base and a real
  seek, a stream keeps its StartTimeTicks base and no seek — and stop routing a
  downloaded handoff's absolute seek through the stream rebuild, which refuses
  a non-remote source outright.

Reports go through a PlaybackReportSink, which also collapses three copies of
spawn-a-task-and-hope into one and is what let each of these be written as a
failing test first.

TRACES: UR-005, UR-025, UR-040, UR-071 | DR-178, DR-179, DR-180 |
        UT-176, UT-177, UT-178, UT-179, UT-180, UT-181
This commit is contained in:
2026-08-16 10:23:00 +02:00
parent 5096c01960
commit de1c13e72f
4 changed files with 853 additions and 129 deletions
+119 -12
View File
@@ -454,6 +454,49 @@ pub(super) fn background_audio_source(
} }
} }
/// How a background-audio handoff must start playback, given where its audio
/// actually begins.
///
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
pub(super) struct BackgroundAudioPlan {
/// The position the stream's own zero corresponds to, recorded as the
/// handoff base so later readings can be shifted back to the episode's
/// timeline.
pub base_seconds: f64,
/// Where to seek after loading, if the source does not already start there.
pub seek_to: Option<f64>,
}
/// Decide the base and the seek for a handoff at `position_seconds`.
///
/// The two sources start in different places. An audio-only **stream** is built
/// with `StartTimeTicks`, so the server makes the handoff point that stream's
/// zero: the base is the handoff position, and seeking would skip *past* the
/// content by that much again. A downloaded **file** has no such parameter and
/// begins at the episode's own zero, so it needs the opposite — no base, and a
/// real seek. Treating a file like a stream is why backgrounding a downloaded
/// episode restarted it from 0:00 while the lockscreen showed the right time.
///
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
pub(super) fn background_audio_plan(
is_local_file: bool,
position_seconds: f64,
) -> BackgroundAudioPlan {
let position = position_seconds.max(0.0);
if is_local_file {
BackgroundAudioPlan {
base_seconds: 0.0,
seek_to: (position > 0.0).then_some(position),
}
} else {
BackgroundAudioPlan {
base_seconds: position,
seek_to: None,
}
}
}
/// Resolve the on-disk file backing a completed download, if there is one. /// Resolve the on-disk file backing a completed download, if there is one.
/// ///
/// A `downloads` row is not proof of a file: it can outlive the bytes (manual /// A `downloads` row is not proof of a file: it can outlive the bytes (manual
@@ -710,6 +753,9 @@ pub async fn player_enter_background_audio(
item.id item.id
); );
} }
// A downloaded file starts at the episode's zero; a stream starts at the
// handoff point. Only one of them has a base, and only the other needs a seek.
let plan = background_audio_plan(local_path.is_some(), position_seconds);
let source = background_audio_source(local_path, item.stream_url, &item.id); let source = background_audio_source(local_path, item.stream_url, &item.id);
// Build an AUDIO media item pointing at the audio-only stream. We do not use // Build an AUDIO media item pointing at the audio-only stream. We do not use
@@ -753,21 +799,28 @@ pub async fn player_enter_background_audio(
// Same base offset drives the lockscreen scrubber: ExoPlayer reports position // Same base offset drives the lockscreen scrubber: ExoPlayer reports position
// relative to the stream's StartTimeTicks zero, but the metadata duration is // relative to the stream's StartTimeTicks zero, but the metadata duration is
// absolute, so shift the reported position back to absolute for the scrubber. // absolute, so shift the reported position back to absolute for the scrubber.
let _ = crate::player::set_lockscreen_position_offset(position_seconds.max(0.0)); let _ = crate::player::set_lockscreen_position_offset(plan.base_seconds);
let controller = player.0.lock().await; let controller = player.0.lock().await;
// Remember where the video was: the audio stream's zero == this position // Remember where the video was: for a stream the audio's zero == this
// (the URL was built with StartTimeTicks=position_seconds), so on exit we add // position (the URL was built with StartTimeTicks=position_seconds), so on
// this base to the native player's relative position to get the absolute one. // exit we add this base to the native player's relative position to get the
// The controller owns it so a backend-driven advance to the next episode // absolute one. The controller owns it so a backend-driven advance to the
// clears it along with the stream it described. // next episode clears it along with the stream it described.
controller.enter_background_audio(position_seconds); controller.enter_background_audio(plan.base_seconds);
controller controller
.play_item(media_item) .play_item(media_item)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// NOTE: do NOT seek here. The audio-only URL already starts at the handoff // Seek ONLY a local file. The audio-only URL already starts at the handoff
// position via StartTimeTicks; the stream's timeline begins at 0 == that // position via StartTimeTicks — its timeline begins at 0 == that point — so
// point, so an extra seek(position_seconds) would jump PAST the content. // seeking a stream would jump PAST the content by the handoff position again.
if let Some(seek_to) = plan.seek_to {
info!(
"player_enter_background_audio: seeking the downloaded file to {:.1}s",
seek_to
);
controller.seek(seek_to).map_err(|e| e.to_string())?;
}
controller.emit_queue_changed(); controller.emit_queue_changed();
if let Some(emitter) = controller.event_emitter() { if let Some(emitter) = controller.event_emitter() {
@@ -801,7 +854,14 @@ pub async fn player_exit_background_audio(
// moment it matters most. Capturing into a `let` before stop() is also the // moment it matters most. Capturing into a `let` before stop() is also the
// lock discipline from CLAUDE.md: never hold work across a re-entrant call. // lock discipline from CLAUDE.md: never hold work across a re-entrant call.
// (DR-159) // (DR-159)
let absolute = controller.position(); //
// `absolute_position` rather than `position`, because a tick that has not
// landed *yet* is the same hazard from the other side: returning to the
// foreground while the audio-only transcode is still opening read 0.0, and
// the video reloaded at StartTimeTicks=0 — the episode restarting from the
// beginning. Flooring at the handoff base cannot overshoot: the stream is
// physically incapable of being behind its own starting point. (DR-178)
let absolute = controller.absolute_position();
// Now safe to tear the handoff down, native side first. // Now safe to tear the handoff down, native side first.
let _ = crate::player::set_lockscreen_position_offset(0.0); let _ = crate::player::set_lockscreen_position_offset(0.0);
@@ -1846,7 +1906,11 @@ pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
PlayerStatus { PlayerStatus {
state: controller.state(), state: controller.state(),
position: controller.position(), // The position on the item's timeline, whichever of the three paths is
// rendering it — the native backend answers for only one of them, and
// reads 0 for webview video and for a handoff that has not ticked yet.
// TRACES: UR-005 | DR-178
position: controller.absolute_position(),
duration: controller.duration(), duration: controller.duration(),
volume: controller.volume(), volume: controller.volume(),
muted: controller.muted(), muted: controller.muted(),
@@ -2838,6 +2902,49 @@ mod tests {
} }
} }
/// The two sources start in different places, so the handoff cannot treat
/// them alike.
///
/// An audio-only *stream* is built with `StartTimeTicks`, so the server makes
/// the handoff point that stream's zero: the base is the handoff position and
/// seeking would jump past the content. A *downloaded file* has no such
/// parameter — it starts at the episode's own zero — so basing it at the
/// handoff position claims 18 minutes of audio that is about to play from the
/// beginning. That is the downloaded-episode version of "it restarts when the
/// screen sleeps", and it needs the opposite treatment: no base, and a seek.
///
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
#[test]
fn test_background_audio_plan_seeks_a_file_and_bases_a_stream() {
use super::background_audio_plan;
let local = background_audio_plan(true, 1104.0);
assert_eq!(local.base_seconds, 0.0);
assert_eq!(local.seek_to, Some(1104.0));
let streamed = background_audio_plan(false, 1104.0);
assert_eq!(streamed.base_seconds, 1104.0);
assert_eq!(
streamed.seek_to, None,
"the URL already starts at the handoff point; seeking again skips past it"
);
}
/// Handing off at the very start has nothing to seek to and nothing to base:
/// both sources are already where they need to be.
///
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
#[test]
fn test_background_audio_plan_at_the_start_neither_seeks_nor_bases() {
use super::background_audio_plan;
for local in [true, false] {
let plan = background_audio_plan(local, 0.0);
assert_eq!(plan.base_seconds, 0.0);
assert_eq!(plan.seek_to, None);
}
}
/// A downloaded item must resolve to its file, and a `downloads` row whose /// A downloaded item must resolve to its file, and a `downloads` row whose
/// file has gone must resolve to `None` so the caller falls back to /// file has gone must resolve to `None` so the caller falls back to
/// streaming instead of handing the player a path that cannot be opened. /// streaming instead of handing the player a path that cannot be opened.
+703 -107
View File
@@ -55,6 +55,75 @@ pub use android::{
set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler, set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
}; };
/// Where the player's playback reports go.
///
/// The controller's side of reporting is "send this, don't make me wait": a slow
/// or failing sync must never stall playback, so every send is fire-and-forget.
/// Production wires this to [`PlaybackReporter`] (local DB, server sync, offline
/// queueing); tests capture the operations instead of standing up a database and
/// an HTTP client, which is what let the missing reports below be written as
/// failing tests rather than found on a device.
///
/// TRACES: UR-025 | DR-179
pub trait PlaybackReportSink: Send + Sync {
/// Deliver `operation`. Must not block the caller.
fn send(&self, operation: PlaybackOperation);
}
/// The production sink: hands each operation to the `PlaybackReporter`.
///
/// Reports originate on whatever thread playback ended or ticked on — including
/// JNI callbacks with no Tokio runtime attached — so the spawn falls back to a
/// throwaway runtime on its own thread rather than assuming one is current.
struct ReporterSink {
reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
}
impl PlaybackReportSink for ReporterSink {
fn send(&self, operation: PlaybackOperation) {
let reporter = self.reporter.clone();
let task = async move {
let guard = reporter.lock().await;
let Some(reporter) = guard.as_ref() else {
warn!("[PlayerController] PlaybackReporter not initialized; dropping report");
return;
};
// `report` decides local-vs-server and queues for sync itself.
if let Err(e) = reporter.report(operation, true).await {
log::error!("[PlayerController] Failed to report playback: {}", e);
}
};
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(task);
} else {
std::thread::spawn(move || match tokio::runtime::Runtime::new() {
Ok(rt) => rt.block_on(task),
Err(e) => log::error!(
"[PlayerController] No runtime available to report playback: {}",
e
),
});
}
}
}
/// The position to report when a stream ends naturally.
///
/// The item's runtime when we know it, because the point of the report is to say
/// the episode *finished* and Jellyfin decides that by percentage — the last
/// position actually observed can be seconds short, and on a handoff whose ticks
/// stopped early it can be nowhere near the end. Without a runtime the best
/// available answer is where playback got to.
///
/// TRACES: UR-025, UR-040 | DR-179 | UT-179
fn completion_report_position(runtime: Option<f64>, last_position: f64) -> f64 {
match runtime {
Some(runtime) if runtime > 0.0 => runtime,
_ => last_position.max(0.0),
}
}
/// Seconds added per attempt before retrying a stream that failed with an error. /// Seconds added per attempt before retrying a stream that failed with an error.
/// ///
/// Attempt 1 waits this long, attempt 2 twice as long, and so on — a spread that /// Attempt 1 waits this long, attempt 2 twice as long, and so on — a spread that
@@ -127,6 +196,7 @@ use crate::playback_reporting::{
}; };
use crate::repository::MediaRepository; use crate::repository::MediaRepository;
use crate::settings::AudioSettings; use crate::settings::AudioSettings;
use crate::utils::conversions::seconds_to_ticks;
/// Central player controller that coordinates playback /// Central player controller that coordinates playback
pub struct PlayerController { pub struct PlayerController {
@@ -153,9 +223,12 @@ pub struct PlayerController {
// Playback reporting (dual sync: local DB + server) // Playback reporting (dual sync: local DB + server)
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>, playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
// Event throttler to prevent spam from position updates // Where playback reports go. Swappable so tests can assert on what the
// Will be used when position update hooks are added to backends // player tells Jellyfin. See `PlaybackReportSink`.
#[allow(dead_code)] reports: Arc<Mutex<Arc<dyn PlaybackReportSink>>>,
// Bounds progress reports to one per item per 30s. Position ticks arrive
// four times a second; the server needs a resume point, not a firehose.
position_throttler: Arc<EventThrottler>, position_throttler: Arc<EventThrottler>,
// End reason tracking for autoplay decision making // End reason tracking for autoplay decision making
@@ -209,6 +282,19 @@ pub struct PlayerController {
// competing intents to take opposing actions. `None` means no webview media // competing intents to take opposing actions. `None` means no webview media
// is active and the native backend is authoritative. See DR-097. // is active and the native backend is authoritative. See DR-097.
html5_playing: Arc<Mutex<Option<bool>>>, html5_playing: Arc<Mutex<Option<bool>>>,
// Last position/duration reported by webview-rendered media.
//
// On the webview path the `<video>` element IS the player: nothing is loaded
// into the native backend, so `backend.position()` is a permanent 0. Those
// reports used to be re-emitted to the frontend and then dropped, which is
// why every position the *backend* sent to Jellyfin — including the stop
// report that sets the resume point — was zero, overwriting the correct one
// the frontend had just sent. Storing them here makes
// `absolute_position()` answer for both rendering paths.
//
// TRACES: UR-005, UR-025 | DR-178
reported_time: Arc<Mutex<stream_end::ObservedTime>>,
} }
impl PlayerController { impl PlayerController {
@@ -217,6 +303,9 @@ impl PlayerController {
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>, playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
position_throttler: Arc<EventThrottler>, position_throttler: Arc<EventThrottler>,
) -> Self { ) -> Self {
let reports: Arc<dyn PlaybackReportSink> = Arc::new(ReporterSink {
reporter: playback_reporter.clone(),
});
let controller = Self { let controller = Self {
backend: Arc::new(Mutex::new(backend)), backend: Arc::new(Mutex::new(backend)),
queue: Arc::new(Mutex::new(QueueManager::new())), queue: Arc::new(Mutex::new(QueueManager::new())),
@@ -228,6 +317,7 @@ impl PlayerController {
event_emitter: Arc::new(Mutex::new(None)), event_emitter: Arc::new(Mutex::new(None)),
countdown_cancel: Arc::new(Mutex::new(None)), countdown_cancel: Arc::new(Mutex::new(None)),
playback_reporter, playback_reporter,
reports: Arc::new(Mutex::new(reports)),
position_throttler, position_throttler,
end_reason: Arc::new(Mutex::new(None)), end_reason: Arc::new(Mutex::new(None)),
autoplay_episode_count: Arc::new(Mutex::new(0)), autoplay_episode_count: Arc::new(Mutex::new(0)),
@@ -235,6 +325,7 @@ impl PlayerController {
background_audio_active: Arc::new(Mutex::new(false)), background_audio_active: Arc::new(Mutex::new(false)),
stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())), stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())),
html5_playing: Arc::new(Mutex::new(None)), html5_playing: Arc::new(Mutex::new(None)),
reported_time: Arc::new(Mutex::new(stream_end::ObservedTime::default())),
}; };
// Start background timer thread for sleep timer countdown // Start background timer thread for sleep timer countdown
@@ -394,6 +485,12 @@ impl PlayerController {
); );
self.reset_autoplay_count(); self.reset_autoplay_count();
// A different item is current; the last one's reported position must not
// be reported against it. This path is how webview-rendered video is
// queued (no backend load at all), so it is exactly where a stale
// reading would otherwise survive.
// TRACES: UR-005 | DR-178
self.clear_reported_time();
let mut queue = self.queue.lock_safe(); let mut queue = self.queue.lock_safe();
queue.set_queue(vec![item], 0); queue.set_queue(vec![item], 0);
@@ -414,11 +511,12 @@ impl PlayerController {
backend.play()?; backend.play()?;
drop(backend); drop(backend);
// A different item is loading; the last one's reported position must not
// be attributed to it.
self.clear_reported_time();
// Report playback start using PlaybackReporter (dual sync: local DB + server) // Report playback start using PlaybackReporter (dual sync: local DB + server)
if let Some(jellyfin_id) = item.jellyfin_id() { if let Some(jellyfin_id) = item.jellyfin_id() {
let jellyfin_id = jellyfin_id.to_string();
let reporter = self.playback_reporter.clone();
// Build playback context from item metadata // Build playback context from item metadata
let context = if item.album_id.is_some() { let context = if item.album_id.is_some() {
Some(PlaybackContext { Some(PlaybackContext {
@@ -429,53 +527,23 @@ impl PlayerController {
None None
}; };
// Spawn on Tokio runtime if available, otherwise use a new thread // Where this stream actually begins. Zero for an ordinary load, but a
if let Ok(handle) = tokio::runtime::Handle::try_current() { // background-audio handoff loads a stream whose zero is the handoff
handle.spawn(async move { // point — telling the server the session started at 0:00 there both
let reporter_guard = reporter.lock().await; // misreports the session and, being a position, competes with the
if let Some(reporter_instance) = reporter_guard.as_ref() { // real one.
log::info!("[PlayerController] Reporting playback start via PlaybackReporter: {}", jellyfin_id); let position = self.absolute_position();
let operation = PlaybackOperation::Start { log::info!(
item_id: jellyfin_id.clone(), "[PlayerController] Reporting playback start: {} @ {:.1}s",
position_ticks: 0, jellyfin_id,
position
);
self.report(PlaybackOperation::Start {
item_id: jellyfin_id.to_string(),
position_ticks: seconds_to_ticks(position),
context, context,
};
// Note: PlaybackReporter internally checks connectivity
// We pass is_online=true as default; reporter will check actual status
match reporter_instance.report(operation, true).await {
Ok(_) => log::info!("[PlayerController] Successfully reported playback start (local DB + server sync)"),
Err(e) => log::error!("[PlayerController] Failed to report playback start: {}", e),
}
} else {
log::warn!("[PlayerController] PlaybackReporter not initialized - using fallback JellyfinClient");
// Fallback to legacy JellyfinClient (will be removed after full migration)
}
}); });
} else {
// Fallback: spawn in a new thread with its own runtime
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async move {
let reporter_guard = reporter.lock().await;
if let Some(reporter_instance) = reporter_guard.as_ref() {
log::info!("[PlayerController] Reporting playback start via PlaybackReporter: {}", jellyfin_id);
let operation = PlaybackOperation::Start {
item_id: jellyfin_id.clone(),
position_ticks: 0,
context,
};
match reporter_instance.report(operation, true).await {
Ok(_) => log::info!("[PlayerController] Successfully reported playback start"),
Err(e) => log::error!("[PlayerController] Failed to report playback start: {}", e),
}
}
});
});
}
} }
Ok(()) Ok(())
@@ -634,66 +702,56 @@ impl PlayerController {
.and_then(|item| item.jellyfin_id().map(|s| s.to_string())) .and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
}; };
let position_ticks = { // Read across every rendering path BEFORE stopping: the backend zeroes
let backend = self.backend.lock_safe(); // its position on stop, and the element that was reporting is gone.
(backend.position() * 10_000_000.0) as i64 let position = self.absolute_position();
};
let mut backend = self.backend.lock_safe(); let mut backend = self.backend.lock_safe();
backend.stop()?; backend.stop()?;
drop(backend); drop(backend);
self.clear_reported_time();
// Report playback stopped using PlaybackReporter (dual sync: local DB + server)
if let Some(jellyfin_id) = jellyfin_id { if let Some(jellyfin_id) = jellyfin_id {
let reporter = self.playback_reporter.clone(); self.report_stopped_at(jellyfin_id, position);
// Spawn on Tokio runtime if available, otherwise use a new thread
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let reporter_guard = reporter.lock().await;
if let Some(reporter_instance) = reporter_guard.as_ref() {
log::info!("[PlayerController] Reporting playback stopped via PlaybackReporter: {}", jellyfin_id);
let operation = PlaybackOperation::Stopped {
item_id: jellyfin_id.clone(),
position_ticks,
};
match reporter_instance.report(operation, true).await {
Ok(_) => log::info!("[PlayerController] Successfully reported playback stopped (local DB + server sync)"),
Err(e) => log::error!("[PlayerController] Failed to report playback stopped: {}", e),
}
} else {
log::warn!("[PlayerController] PlaybackReporter not initialized");
}
});
} else {
// Fallback: spawn in a new thread with its own runtime
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async move {
let reporter_guard = reporter.lock().await;
if let Some(reporter_instance) = reporter_guard.as_ref() {
log::info!("[PlayerController] Reporting playback stopped via PlaybackReporter: {}", jellyfin_id);
let operation = PlaybackOperation::Stopped {
item_id: jellyfin_id.clone(),
position_ticks,
};
match reporter_instance.report(operation, true).await {
Ok(_) => log::info!("[PlayerController] Successfully reported playback stopped"),
Err(e) => log::error!("[PlayerController] Failed to report playback stopped: {}", e),
}
}
});
});
}
} }
Ok(()) Ok(())
} }
/// Tell Jellyfin playback stopped at `position`, unless that position is
/// zero.
///
/// Jellyfin stores the reported position as the resume point, so a zero is
/// not a harmless no-op — it is an instruction to forget where the viewer
/// was. And it is never *information*: nobody watched zero seconds of
/// anything, so every zero this app ever sent came from asking a player that
/// was not rendering the media (webview video, or a handoff whose first tick
/// had not landed). On a device trace, 14 of 14 stop reports in 35 minutes
/// were zeroes, one of them 40s after the frontend had correctly reported
/// 15:22 for the same episode.
///
/// TRACES: UR-025, UR-005 | DR-179 | UT-178
fn report_stopped_at(&self, jellyfin_id: String, position: f64) {
if position <= 0.0 {
debug!(
"[PlayerController] Withholding zero-position stop report for {} \
(nothing played; reporting it would clear the resume point)",
jellyfin_id
);
return;
}
log::info!(
"[PlayerController] Reporting playback stopped: {} @ {:.1}s",
jellyfin_id,
position
);
self.report(PlaybackOperation::Stopped {
item_id: jellyfin_id,
position_ticks: seconds_to_ticks(position),
});
}
/// Skip to next track /// Skip to next track
/// ///
/// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay /// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
@@ -783,11 +841,18 @@ impl PlayerController {
/// ///
/// TRACES: UR-040, UR-005 | DR-159 | UT-155 /// TRACES: UR-040, UR-005 | DR-159 | UT-155
pub async fn seek_absolute(&self, position: f64) -> Result<(), String> { pub async fn seek_absolute(&self, position: f64) -> Result<(), String> {
// Only a *streamed* handoff needs the rebuild. A downloaded file seeks
// like any other file — and `resume_stream_at` refuses a non-remote
// source, so sending one through here fails the seek outright.
// TRACES: UR-071 | DR-180 | UT-181
let rebuild = self.is_background_audio_active() && { let rebuild = self.is_background_audio_active() && {
let queue = self.queue.lock_safe(); let queue = self.queue.lock_safe();
queue queue
.current() .current()
.map(Self::is_audio_only_video) .map(|item| {
Self::is_audio_only_video(item)
&& matches!(item.source, MediaSource::Remote { .. })
})
.unwrap_or(false) .unwrap_or(false)
}; };
@@ -825,9 +890,97 @@ impl PlayerController {
self.backend.lock_safe().position() self.backend.lock_safe().position()
} }
/// Get duration /// The position on the **item's own timeline**, whatever is rendering it.
///
/// This is what every outbound position must be taken from — the resume point
/// sent to Jellyfin, the point the video reloads at when a handoff ends, the
/// truncation comparison. `position()` alone answers for exactly one of the
/// three ways this app plays media, and reads 0 for the other two:
///
/// - **Webview `<video>`/`<audio>`**: nothing is loaded into the native
/// backend, so its position is a permanent 0. The element's own reports are
/// the only reading there is.
/// - **Background-audio handoff**: the audio-only stream's zero is the
/// handoff point, and the base is added at the native tick boundary
/// (DR-159) — so before the first tick lands, nothing has applied it.
/// Flooring at the base is exact rather than approximate: the stream cannot
/// physically be behind its own starting point.
/// - **Native playback**: the backend is authoritative and both other terms
/// are zero, so the max is its own value.
///
/// Returning to the foreground during that pre-first-tick window is what
/// restarted an episode from 0:00 and wiped its server-side resume point.
///
/// TRACES: UR-040, UR-005, UR-025 | DR-178 | UT-176, UT-177
pub fn absolute_position(&self) -> f64 {
let native = self.backend.lock_safe().position().max(0.0);
let reported = self.reported_time.lock_safe().last_position();
let base = if self.is_background_audio_active() {
*self.background_audio_base.lock_safe()
} else {
0.0
};
native.max(reported).max(base)
}
/// The duration last reported by webview-rendered media, if any.
///
/// TRACES: UR-005 | DR-178 | UT-177
pub fn observed_duration(&self) -> Option<f64> {
self.reported_time.lock_safe().last_duration()
}
/// Forget what webview-rendered media reported.
///
/// Called wherever that element stops being the player — it was torn down,
/// a handoff took over, or a different item is loading. A stale position
/// outliving its element would be reported against whatever plays next.
///
/// TRACES: UR-005 | DR-178 | UT-177
fn clear_reported_time(&self) {
self.reported_time.lock_safe().reset();
}
/// Replace the sink playback reports go to. Tests capture; production wires
/// the `PlaybackReporter` at construction and never swaps it.
///
/// TRACES: UR-025 | DR-179
#[cfg_attr(not(test), allow(dead_code))]
pub fn set_report_sink(&self, sink: Arc<dyn PlaybackReportSink>) {
*self.reports.lock_safe() = sink;
}
/// Send a playback report. Fire-and-forget by contract, so callers can do
/// this while holding nothing and waiting for nothing.
///
/// TRACES: UR-025 | DR-179
fn report(&self, operation: PlaybackOperation) {
let sink = self.reports.lock_safe().clone();
sink.send(operation);
}
/// The Jellyfin id of whatever is currently queued, if it has one.
fn current_jellyfin_id(&self) -> Option<String> {
let queue = self.queue.lock_safe();
queue
.current()
.and_then(|item| item.jellyfin_id().map(|id| id.to_string()))
}
/// Get duration.
///
/// Falls back to what webview-rendered media reported for the same reason
/// [`absolute_position`](Self::absolute_position) does: on that path nothing
/// is loaded into the native backend, so its duration is `None` and the
/// element's report is the only one there is.
///
/// TRACES: UR-005 | DR-178
pub fn duration(&self) -> Option<f64> { pub fn duration(&self) -> Option<f64> {
self.backend.lock_safe().duration() self.backend
.lock_safe()
.duration()
.or_else(|| self.observed_duration())
} }
/// Get queue reference /// Get queue reference
@@ -1063,7 +1216,7 @@ impl PlayerController {
// "stopped"/"idle" mean the element is gone, so hand authority back to // "stopped"/"idle" mean the element is gone, so hand authority back to
// the native backend — otherwise music playback would keep emitting // the native backend — otherwise music playback would keep emitting
// ControlCommands at a element that no longer exists. // ControlCommands at a element that no longer exists.
{ let element_gone = {
let mut tracked = self.html5_playing.lock_safe(); let mut tracked = self.html5_playing.lock_safe();
*tracked = match state.as_str() { *tracked = match state.as_str() {
"playing" => Some(true), "playing" => Some(true),
@@ -1074,6 +1227,12 @@ impl PlayerController {
// "stopped"/"idle": element is gone, native backend resumes authority. // "stopped"/"idle": element is gone, native backend resumes authority.
_ => None, _ => None,
}; };
tracked.is_none()
};
// Its last position goes with it: whatever plays next is loaded into the
// native backend, and a stale reading would be reported against that.
if element_gone {
self.clear_reported_time();
} }
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() { if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::StateChanged { state, media_id }); emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
@@ -1090,9 +1249,50 @@ impl PlayerController {
if self.is_background_audio_active() { if self.is_background_audio_active() {
return; return;
} }
// The element is the player on this path, so this tick is the position —
// for the resume point, the stop report and everything else that asks.
self.reported_time.lock_safe().record(position, duration);
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() { if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration }); emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
} }
self.report_progress_throttled(position);
}
/// Send a throttled progress report to Jellyfin.
///
/// Progress is what makes a position survive anything other than a clean
/// exit — a crash, a swipe-away, a battery death — and lets another device
/// resume mid-episode. Webview-rendered media reported none: the frontend
/// service writes progress to the local DB only, and Rust had no position
/// for it to report. A device trace covering 35 minutes of playback hit
/// `/Sessions/Playing/Progress` exactly zero times.
///
/// The throttler is the one the controller already owned for this purpose
/// (30s per item), so ticks arriving four times a second cost one request
/// per half-minute.
///
/// TRACES: UR-005, UR-025 | DR-179 | UT-180
fn report_progress_throttled(&self, position: f64) {
if position <= 0.0 {
return;
}
let Some(item_id) = self.current_jellyfin_id() else {
return;
};
if !self.position_throttler.should_report(&item_id) {
return;
}
self.report(PlaybackOperation::Progress {
item_id: item_id.clone(),
position_ticks: seconds_to_ticks(position),
// Ticks only arrive while the element is playing; a pause is carried
// by the state report, not by a position that stopped moving.
is_paused: false,
});
self.position_throttler.mark_reported(&item_id);
} }
/// Report that the HTML5 <video> element finished loading and knows its /// Report that the HTML5 <video> element finished loading and knows its
@@ -1186,6 +1386,11 @@ impl PlayerController {
return Ok(AutoplayDecision::Stop); return Ok(AutoplayDecision::Stop);
}; };
// The item is genuinely finished (a truncated stream returned above), so
// report it before anything advances — after an advance the queue's
// current item is the *next* episode and this one is unreachable.
self.report_completion(&current);
// Check sleep timer state // Check sleep timer state
let timer_mode = { let timer_mode = {
let timer = self.sleep_timer.lock_safe(); let timer = self.sleep_timer.lock_safe();
@@ -1293,6 +1498,43 @@ impl PlayerController {
} }
} }
/// Report an item that just finished as stopped at its runtime, so Jellyfin
/// marks it played.
///
/// Jellyfin decides "watched" from the `PlaybackStopped` report and its
/// position — no report, no completion, however much of the episode was
/// actually heard. In the foreground the frontend sends one when the
/// `<video>` ends. In background audio-only mode there is nobody: the webview
/// is suspended and its element was torn down at the handoff, while the
/// backend drove the advance to the next episode and said nothing about the
/// one that ended. An episode listened to end-to-end on the lockscreen
/// therefore never counted, and (before DR-179) was often reset to 0 by the
/// stop report that followed.
///
/// Scoped to the audio-only handoff — the case the frontend provably cannot
/// report — so foreground playback keeps its single existing report rather
/// than gaining a second one. Music tracks ending natively remain
/// unreported; that is the same gap through a different door and wants its
/// own change.
///
/// TRACES: UR-040, UR-025 | DR-179 | UT-179
fn report_completion(&self, item: &MediaItem) {
if !Self::is_audio_only_video(item) {
return;
}
let Some(jellyfin_id) = item.jellyfin_id().map(|id| id.to_string()) else {
return;
};
let position = completion_report_position(item.duration, self.absolute_position());
log::info!(
"[PlayerController] Audio-only {} finished — reporting complete at {:.1}s",
jellyfin_id,
position
);
self.report_stopped_at(jellyfin_id, position);
}
/// Record the base offset of a background-audio handoff (the position the /// Record the base offset of a background-audio handoff (the position the
/// video was handed off at, which is the audio stream's zero). /// video was handed off at, which is the audio stream's zero).
/// ///
@@ -1315,6 +1557,9 @@ impl PlayerController {
self.set_background_audio_base(position); self.set_background_audio_base(position);
*self.background_audio_active.lock_safe() = true; *self.background_audio_active.lock_safe() = true;
*self.html5_playing.lock_safe() = None; *self.html5_playing.lock_safe() = None;
// The element is being torn down; its last position describes a video
// that is no longer playing, and the base describes the one that is.
self.clear_reported_time();
} }
/// Leave a background-audio handoff, returning the base offset to add to the /// Leave a background-audio handoff, returning the base offset to add to the
@@ -1422,10 +1667,11 @@ impl PlayerController {
return None; return None;
} }
// Already absolute: the Android position tick shifts by the handoff base // The Android position tick shifts by the handoff base before anything
// before anything sees the value, so adding it again here would // sees the value, so adding it again here would double-count it
// double-count it. (DR-159) // (DR-159) — `absolute_position` floors at the base instead, which is
let absolute = self.position().max(0.0); // what a stream that died before its first tick needs. (DR-178)
let absolute = self.absolute_position();
match self.stream_resume.lock_safe().allow_attempt(absolute) { match self.stream_resume.lock_safe().allow_attempt(absolute) {
Some(attempt) => Some((absolute, attempt)), Some(attempt) => Some((absolute, attempt)),
@@ -1938,6 +2184,57 @@ mod tests {
} }
} }
/// Captures what the controller reports to Jellyfin, so tests can assert on
/// the operations themselves rather than on a database and an HTTP client.
struct CapturingReports {
operations: std::sync::Mutex<Vec<PlaybackOperation>>,
}
impl CapturingReports {
fn new() -> Self {
Self {
operations: std::sync::Mutex::new(Vec::new()),
}
}
/// Every `Stopped` report as `(item_id, position_seconds)`.
fn stops(&self) -> Vec<(String, f64)> {
self.operations
.lock_safe()
.iter()
.filter_map(|op| match op {
PlaybackOperation::Stopped {
item_id,
position_ticks,
} => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
_ => None,
})
.collect()
}
/// Every `Progress` report as `(item_id, position_seconds)`.
fn progress(&self) -> Vec<(String, f64)> {
self.operations
.lock_safe()
.iter()
.filter_map(|op| match op {
PlaybackOperation::Progress {
item_id,
position_ticks,
..
} => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
_ => None,
})
.collect()
}
}
impl PlaybackReportSink for CapturingReports {
fn send(&self, operation: PlaybackOperation) {
self.operations.lock_safe().push(operation);
}
}
#[test] #[test]
fn test_report_html5_state_emits_state_changed() { fn test_report_html5_state_emits_state_changed() {
let controller = PlayerController::default(); let controller = PlayerController::default();
@@ -3613,6 +3910,39 @@ mod tests {
} }
} }
/// The same episode handed off from a **downloaded file** — the handoff's
/// other source, which starts at the episode's own zero rather than at the
/// handoff point.
fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem {
MediaItem {
source: MediaSource::Local {
file_path: std::path::PathBuf::from("/downloads/ep2.mkv"),
jellyfin_item_id: Some("ep2".to_string()),
},
..audio_only_episode(runtime_seconds)
}
}
/// A file seeks like a file. The rebuild path exists 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 through it fails the seek instead of doing it.
///
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
#[tokio::test]
async fn test_seek_absolute_on_a_downloaded_handoff_is_an_ordinary_seek() {
let controller = PlayerController::default();
controller
.play_queue(vec![local_audio_only_episode(1500.0)], 0)
.unwrap();
// A downloaded handoff claims no base: the file's zero is the episode's.
controller.enter_background_audio(0.0);
controller.seek_absolute(900.0).await.unwrap();
assert_eq!(controller.position(), 900.0);
}
/// A flaky connection truncates the progressive mp3 transcode that carries /// A flaky connection truncates the progressive mp3 transcode that carries
/// background audio-only playback. ExoPlayer sees end-of-input on a stream /// background audio-only playback. ExoPlayer sees end-of-input on a stream
/// with no reliable length, so it reports STATE_ENDED ten minutes into a /// with no reliable length, so it reports STATE_ENDED ten minutes into a
@@ -3723,6 +4053,272 @@ mod tests {
); );
} }
// ===== Position authority and reporting (DR-178, DR-179) =====
//
// Every position that leaves the app — the resume point Jellyfin stores, the
// point the video reloads at on the way back from a handoff, the truncation
// maths — is read off the controller. The device trace showed all of them
// reading 0: the native backend is not the player on the webview path, and
// during a handoff its base is only applied once ExoPlayer has ticked, which
// it has not while the audio-only transcode is still opening.
/// Returning to the foreground before the audio-only stream has started
/// playing hands back the handoff's own starting point, never zero.
///
/// Observed on device: locked at 18.4s, unlocked 3.5s later with ExoPlayer
/// still `IDLE`, `player_exit_background_audio` returned `0.0`, and the video
/// reloaded with `StartTimeTicks=0` — the episode restarted from the
/// beginning, and the `Stopped` report that followed wiped the server's
/// resume point too.
///
/// TRACES: UR-040 | DR-178 | UT-176
#[test]
fn test_absolute_position_floors_at_the_handoff_base() {
let controller = PlayerController::default();
controller.enter_background_audio(18.4);
// No tick has landed, so nothing has applied the base yet.
assert_eq!(controller.position(), 0.0);
assert_eq!(
controller.absolute_position(),
18.4,
"the audio stream's zero IS the handoff point, so the position can \
never legitimately read below it"
);
}
/// Once ticks are flowing the base has already been applied at the native
/// boundary (DR-159), so flooring must not add it a second time.
///
/// TRACES: UR-040 | DR-178 | UT-176
#[test]
fn test_absolute_position_does_not_double_count_the_handoff_base() {
let controller = PlayerController::default();
controller.enter_background_audio(18.4);
// What the real backend reports after a tick: already absolute.
controller.seek(120.0).unwrap();
assert_eq!(controller.absolute_position(), 120.0);
}
/// On the webview path the `<video>` element is the player and the native
/// backend holds nothing, so the position it reports is the only one there
/// is. It used to be re-emitted to the frontend and then dropped, leaving
/// every backend-side report at 0.
///
/// TRACES: UR-005, UR-025 | DR-178 | UT-177
#[test]
fn test_webview_position_reports_become_the_controllers_position() {
let controller = PlayerController::default();
controller.report_html5_position(253.4, 2640.0);
assert_eq!(controller.absolute_position(), 253.4);
assert_eq!(controller.observed_duration(), Some(2640.0));
}
/// A torn-down element's last position must not outlive it: the next thing
/// to play is loaded into the native backend, and a stale 253s would be
/// reported against it.
///
/// TRACES: UR-005 | DR-178 | UT-177
#[test]
fn test_webview_teardown_clears_the_observed_position() {
let controller = PlayerController::default();
controller.report_html5_position(253.4, 2640.0);
controller.report_html5_state("stopped".to_string(), None);
assert_eq!(controller.absolute_position(), 0.0);
}
/// Entering a handoff tears the element down, so its position stops being
/// the answer at that exact moment — the native audio player's does.
///
/// TRACES: UR-040 | DR-178 | UT-177
#[test]
fn test_entering_a_handoff_drops_the_torn_down_elements_position() {
let controller = PlayerController::default();
controller.report_html5_position(253.4, 2640.0);
controller.enter_background_audio(18.4);
assert_eq!(
controller.absolute_position(),
18.4,
"the video element is gone; only the handoff base describes the \
stream that is now playing"
);
}
/// Nobody ever watched zero seconds of anything. A `Stopped` at 0 carries no
/// information and Jellyfin stores it as the resume point, so the only thing
/// it can do is destroy one — which is what the device trace caught it doing
/// 14 times in 35 minutes, including 40s after the frontend had correctly
/// reported 922s for the same episode.
///
/// TRACES: UR-025 | DR-179 | UT-178
#[tokio::test]
async fn test_a_stop_at_zero_is_never_reported() {
let controller = PlayerController::default();
let reports = Arc::new(CapturingReports::new());
controller.set_report_sink(reports.clone());
controller
.play_queue(vec![audio_only_episode(1500.0)], 0)
.unwrap();
// Nothing ever played: the backend is at 0 and no element reported in.
controller.stop().unwrap();
assert!(
reports.stops().is_empty(),
"a zero-position stop must be withheld, not sent; got {:?}",
reports.stops()
);
}
/// A real position is still reported, so withholding zero cannot be
/// mistaken for withholding everything — from either rendering path.
///
/// The webview half is the one that was broken: the element reports 253s, the
/// native backend holds nothing, and the stop report went out as 0 and
/// overwrote the resume point the frontend had just written correctly.
///
/// TRACES: UR-025 | DR-178, DR-179 | UT-178
#[tokio::test]
async fn test_a_stop_reports_the_position_actually_reached() {
// Webview-rendered: the element is the only thing that knows.
let webview = PlayerController::default();
let webview_reports = Arc::new(CapturingReports::new());
webview.set_report_sink(webview_reports.clone());
webview
.play_queue(vec![audio_only_episode(1500.0)], 0)
.unwrap();
webview.report_html5_position(253.0, 1500.0);
webview.stop().unwrap();
assert_eq!(webview_reports.stops(), vec![("ep2".to_string(), 253.0)]);
// Natively rendered: the backend is authoritative and still is.
let native = PlayerController::default();
let native_reports = Arc::new(CapturingReports::new());
native.set_report_sink(native_reports.clone());
native
.play_queue(vec![audio_only_episode(1500.0)], 0)
.unwrap();
native.seek(253.0).unwrap();
native.stop().unwrap();
assert_eq!(native_reports.stops(), vec![("ep2".to_string(), 253.0)]);
}
/// An episode listened to end-to-end on the lockscreen must count as
/// watched. Jellyfin decides that on the `PlaybackStopped` report — no
/// report, no completion — and in background audio-only mode there is
/// nobody else to send one: the webview is suspended and its `<video>` was
/// torn down at the handoff, so the frontend's end-of-playback reporting
/// cannot run. The backend advanced to the next episode and said nothing
/// about the one that finished.
///
/// TRACES: UR-040, UR-025 | DR-179 | UT-179
#[tokio::test]
async fn test_a_finished_audio_only_episode_is_reported_complete() {
let controller = PlayerController::default();
let reports = Arc::new(CapturingReports::new());
controller.set_report_sink(reports.clone());
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
controller
.play_queue(vec![audio_only_episode(1500.0)], 0)
.unwrap();
// Played out to the end of the 25-minute episode.
controller.seek(1499.0).unwrap();
controller.take_end_reason();
controller.on_playback_ended().await.unwrap();
assert_eq!(
reports.stops(),
vec![("ep2".to_string(), 1500.0)],
"the finished episode must be reported stopped at its runtime, or \
Jellyfin's ≥90% rule never marks it played"
);
}
/// The completion report is for ends that are really ends. A truncated
/// stream is about to be re-opened and the episode is nowhere near over, so
/// reporting it stopped would tell Jellyfin the opposite of the truth.
///
/// TRACES: UR-040, UR-025 | DR-179 | UT-179
#[tokio::test]
async fn test_a_truncated_stream_reports_no_completion() {
let controller = PlayerController::default();
let reports = Arc::new(CapturingReports::new());
controller.set_report_sink(reports.clone());
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
controller
.play_queue(vec![audio_only_episode(1500.0)], 0)
.unwrap();
controller.seek(600.0).unwrap();
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
assert!(matches!(decision, AutoplayDecision::ResumeStream { .. }));
assert!(
reports.stops().is_empty(),
"a dropped connection is not a finished episode; got {:?}",
reports.stops()
);
}
/// Position ticks reach Jellyfin while playback is still going, so closing
/// the app — or losing it to a crash — cannot cost the whole session. The
/// device trace requested `/Sessions/Playing/Progress` exactly zero times in
/// 35 minutes: the frontend service writes progress to the local DB only,
/// and nothing on the Rust side reported it for webview-rendered media.
///
/// TRACES: UR-005, UR-025 | DR-179 | UT-180
#[tokio::test]
async fn test_webview_position_ticks_report_progress_to_the_server() {
let controller = PlayerController::default();
let reports = Arc::new(CapturingReports::new());
controller.set_report_sink(reports.clone());
controller
.play_queue(vec![audio_only_episode(1500.0)], 0)
.unwrap();
controller.report_html5_position(253.4, 1500.0);
assert_eq!(reports.progress(), vec![("ep2".to_string(), 253.4)]);
}
/// Ticks arrive four times a second; reports must not. The throttler the
/// controller already owns bounds them to one per item per 30s.
///
/// TRACES: UR-005 | DR-179 | UT-180
#[tokio::test]
async fn test_progress_reports_are_throttled_not_sent_per_tick() {
let controller = PlayerController::default();
let reports = Arc::new(CapturingReports::new());
controller.set_report_sink(reports.clone());
controller
.play_queue(vec![audio_only_episode(1500.0)], 0)
.unwrap();
for tick in 0..12 {
controller.report_html5_position(250.0 + tick as f64 * 0.25, 1500.0);
}
assert_eq!(
reports.progress().len(),
1,
"twelve ticks inside one throttle window are one report"
);
}
/// The truncation check compares the position against the item's runtime, so /// The truncation check compares the position against the item's runtime, so
/// both must be on the same timeline. /// both must be on the same timeline.
/// ///
+12
View File
@@ -144,6 +144,18 @@ impl ObservedTime {
live.filter(|p| *p >= 0.0).unwrap_or(self.position) live.filter(|p| *p >= 0.0).unwrap_or(self.position)
} }
/// The last observed position, with no live reading to prefer — the case
/// where the *reporter* is the only source there is (webview-rendered media,
/// which the native backend cannot see at all).
pub fn last_position(&self) -> f64 {
self.position
}
/// The last observed duration, if one was ever established.
pub fn last_duration(&self) -> Option<f64> {
self.duration
}
/// The live reading if there is one, else the last observed value. /// The live reading if there is one, else the last observed value.
pub fn duration_or_last(&self, live: Option<f64>) -> Option<f64> { pub fn duration_or_last(&self, live: Option<f64>) -> Option<f64> {
live.filter(|d| *d > 0.0).or(self.duration) live.filter(|d| *d > 0.0).or(self.duration)
+17 -8
View File
@@ -13,12 +13,15 @@ import { commands } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
/** /**
* Report playback start to Jellyfin (or queue if offline) * Record the start of playback **locally**, with the context it started from.
* *
* The Rust backend handles both local DB updates and server reporting, * The server is told by Rust: loading an item into the controller reports the
* automatically queueing for sync if the server is unreachable. * start, carrying the position the stream actually begins at (zero for an
* ordinary play, the handoff point for a background-audio stream). What this
* adds is the context which album or series the play came from which only
* the local DB keeps.
* *
* TRACES: UR-005, UR-025 | DR-028 * TRACES: UR-005, UR-025 | DR-028, DR-179
*/ */
export async function reportPlaybackStart( export async function reportPlaybackStart(
itemId: string, itemId: string,
@@ -50,12 +53,18 @@ export async function reportPlaybackStart(
} }
/** /**
* Report playback progress to Jellyfin (or queue if offline) * Record playback progress **locally**.
* *
* Note: Progress reports are frequent and are not queued for sync. * The server's copy is not sent from here. Position ticks already flow into Rust
* The final position is captured by reportPlaybackStopped. * through the player adapter (`player_report_position`), and the controller
* reports them onward on a 30s throttle one place that covers webview video,
* native audio and the background-audio handoff alike, instead of a second
* frequent IPC path racing it.
* *
* TRACES: UR-005 | DR-028 * This function is therefore the *local* half only, which is what its caller
* needs for resume points that work offline.
*
* TRACES: UR-005 | DR-028, DR-179
*/ */
export async function reportPlaybackProgress( export async function reportPlaybackProgress(
itemId: string, itemId: string,