feat(search): answer search from a local index; tier downloads by lifetime
Search's instant leg read only downloaded items, so with no downloads it returned nothing and every keystroke fell through to a full Recursive=true server query. It now reads the whole synced catalog through the same availability CTE get_items uses, gated on the same include_catalog_browse flag so search and browse cannot diverge. (UR-065, DR-108) Also fixes three defects found while confirming that: - items_fts grew by a full duplicate index every catalog pass. INSERT OR REPLACE fires no AFTER DELETE trigger without recursive_triggers, so the old index row was orphaned, and a TEXT PRIMARY KEY meant the replacement took a fresh rowid and inserted a second entry. Now a real upsert, with migration 021 rebuilding existing indexes. (DR-110) - DELETE FROM items existed nowhere, so server-side deletions never propagated. Adds a post-crawl mark-and-sweep, scoped to crawled types, skipping downloaded items, and refusing to run after a partial crawl because items.parent_id cascades. (DR-110) - The index omitted MusicArtist, Playlist and People, which search groups results by. Adds them plus people_fts (migration 022). (DR-111) Re-indexing moves from a frontend startup call to a Rust background task with a 6h TTL, so a long session no longer searches a stale catalog and a restart no longer forces a crawl regardless of freshness. (DR-109, IR-030) Downloads gain a lifetime tier. Eviction selected every completed row by age with no download_source filter, so hitting the storage limit deleted the oldest download -- typically one saved deliberately for offline -- to make room for a precached track. It now reclaims only 'auto' rows, and expired ones are reclaimed first, before live cache is evicted. (DR-126, DR-127) Downloaded video and audio-only handoffs now play from disk instead of streaming; the video path had never consulted downloads at all. No transcode is involved: MPV runs video=no and ExoPlayer has no surface for an Audio item. (DR-123 in part, DR-128) FTS queries are built as quoted phrases so apostrophes, hyphens and slashes are data rather than operator syntax, and the item-type filter is bound rather than interpolated. Specs: docs/specs/catalog-index-search.md, docs/specs/read-through-media-cache.md Includes concurrently-developed favourites browsing and background-audio stream-end handling; the two workstreams share offline.rs, lib.rs and online.rs, so no subset of files builds independently.
This commit is contained in:
@@ -930,6 +930,25 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(AutoplayDecision::ResumeStream { position }) => {
|
||||
// ExoPlayer reported ENDED because the progressive transcode's
|
||||
// connection dropped, not because the episode finished. This
|
||||
// is the arm that matters while backgrounded: it needs no
|
||||
// frontend echo, so the stream re-opens even with the webview
|
||||
// suspended — and playback never parks in STATE_ENDED, where
|
||||
// the next lockscreen/Bluetooth play restarts the item at 0:00.
|
||||
log::info!(
|
||||
"[Autoplay] Decision: Resume truncated stream at {:.1}s",
|
||||
position
|
||||
);
|
||||
let ctrl = controller.lock().await;
|
||||
if let Err(e) = ctrl.resume_stream_at(position).await {
|
||||
log::error!("[Autoplay] Failed to resume truncated stream: {}", e);
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[Autoplay] Decision failed: {}", e);
|
||||
// Emit PlaybackEnded event on error
|
||||
@@ -974,11 +993,58 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
.get_string(&message)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
let recoverable = recoverable != 0;
|
||||
|
||||
// A background audio-only handoff is an mp3 the device was already decoding,
|
||||
// so a recoverable failure part-way through is the network. Surfacing it as a
|
||||
// player error stops playback for good (the frontend's handler calls
|
||||
// player_stop); re-opening the stream where it died is the "buffer and
|
||||
// resume" this actually is. Everything else keeps reporting the error.
|
||||
if recoverable {
|
||||
if let Some(controller) = PLAYER_CONTROLLER.get() {
|
||||
let controller = controller.clone();
|
||||
let message_str = message_str.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let resume = controller.lock().await.recoverable_error_resume();
|
||||
let Some((position, delay_secs)) = resume else {
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
log::warn!(
|
||||
"[ExoPlayer] Recoverable stream error ({}) — re-opening at {:.1}s in {}s",
|
||||
message_str,
|
||||
position,
|
||||
delay_secs
|
||||
);
|
||||
// Give a brief outage time to clear before asking the server for
|
||||
// the stream again; retrying instantly just burns the budget.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
|
||||
|
||||
let ctrl = controller.lock().await;
|
||||
if let Err(e) = ctrl.resume_stream_at(position).await {
|
||||
log::error!("[ExoPlayer] Failed to resume after error: {}", e);
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable: recoverable != 0,
|
||||
recoverable,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ pub enum AutoplayDecision {
|
||||
Stop,
|
||||
/// Advance to next track in queue (for audio/movies)
|
||||
AdvanceToNext,
|
||||
/// The stream ended well short of the item's runtime — the connection
|
||||
/// dropped, not the media. Re-open the same stream at `position` instead of
|
||||
/// running any end-of-item logic (UR-040).
|
||||
ResumeStream { position: f64 },
|
||||
/// Show next episode popup with countdown
|
||||
ShowNextEpisodePopup {
|
||||
current_episode: MediaItem,
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod seek;
|
||||
pub mod session;
|
||||
pub mod sleep_timer;
|
||||
pub mod state;
|
||||
pub mod stream_end;
|
||||
|
||||
#[cfg(test)]
|
||||
mod mpv_backend_test;
|
||||
@@ -54,6 +55,16 @@ pub use android::{
|
||||
set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
|
||||
};
|
||||
|
||||
/// 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
|
||||
/// covers roughly a quarter-minute of outage across the retry budget without
|
||||
/// leaving the user staring at a dead notification when the network is truly gone.
|
||||
/// Only *read* by the Android error callback (`#[cfg(android)]`), but compiled
|
||||
/// and unit-tested on the host, hence `allow(dead_code)` off-Android.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
const RESUME_BACKOFF_STEP_SECS: u64 = 2;
|
||||
|
||||
/// Metadata for the lockscreen / media notification.
|
||||
///
|
||||
/// Used to drive the Android MediaSession from Rust in remote (cast) mode, where
|
||||
@@ -168,6 +179,15 @@ pub struct PlayerController {
|
||||
// TRACES: UR-040 | DR-052
|
||||
background_audio_base: Arc<Mutex<f64>>,
|
||||
|
||||
// Budget for re-opening a stream that ended short of the item's runtime.
|
||||
//
|
||||
// A resume re-requests the same URL, so a server that is genuinely gone would
|
||||
// otherwise end → resume → end without limit. The tracker only bounds retries
|
||||
// that make no progress; a resume that plays on refills it.
|
||||
//
|
||||
// TRACES: UR-040 | DR-129
|
||||
stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
|
||||
|
||||
// Last state reported by a webview-rendered HTML5 <video>/<audio> element.
|
||||
//
|
||||
// Webview-rendered media is played by an element the native backend cannot
|
||||
@@ -201,6 +221,7 @@ impl PlayerController {
|
||||
end_reason: Arc::new(Mutex::new(None)),
|
||||
autoplay_episode_count: Arc::new(Mutex::new(0)),
|
||||
background_audio_base: Arc::new(Mutex::new(0.0)),
|
||||
stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())),
|
||||
html5_playing: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
|
||||
@@ -271,6 +292,16 @@ impl PlayerController {
|
||||
self.end_reason.lock_safe().take()
|
||||
}
|
||||
|
||||
/// Read the end reason WITHOUT consuming it.
|
||||
///
|
||||
/// `take_end_reason` has an owner: on Android the JNI ended-callback consumes
|
||||
/// the `NewTrackLoaded` every load sets, and the frontend's echoed call is the
|
||||
/// one that sees `None` and decides. The truncated-stream check runs in both
|
||||
/// calls and must not disturb that hand-off, so it peeks.
|
||||
fn peek_end_reason(&self) -> Option<EndReason> {
|
||||
*self.end_reason.lock_safe()
|
||||
}
|
||||
|
||||
/// Record that playback is being stopped by an expiring sleep timer.
|
||||
///
|
||||
/// Stopping the backend makes it fire its ended callback (ExoPlayer does on
|
||||
@@ -1034,6 +1065,15 @@ impl PlayerController {
|
||||
/// Only triggers autoplay if the track finished naturally (EndReason::Finished or None).
|
||||
/// If EndReason is NewTrackLoaded, UserStop, UserSkip, or Error, returns Stop without autoplay.
|
||||
pub async fn on_playback_ended(&self) -> Result<AutoplayDecision, String> {
|
||||
// A truncated stream is not an end at all, so this is decided BEFORE the
|
||||
// end-reason gate below — which returns early for the `NewTrackLoaded`
|
||||
// that every load sets, and would therefore swallow the whole question on
|
||||
// Android's JNI callback: the one call guaranteed to run while the app is
|
||||
// backgrounded and the webview cannot echo anything back.
|
||||
if let Some(position) = self.truncated_stream_resume_position() {
|
||||
return Ok(AutoplayDecision::ResumeStream { position });
|
||||
}
|
||||
|
||||
// Check why playback ended
|
||||
let end_reason = self.take_end_reason();
|
||||
|
||||
@@ -1248,6 +1288,194 @@ impl PlayerController {
|
||||
self.start_autoplay_countdown(next_episode, countdown_seconds);
|
||||
}
|
||||
|
||||
/// A video item played through the native *audio* path — i.e. the background
|
||||
/// audio-only handoff, the only place a length-less progressive transcode is
|
||||
/// used. Jellyfin's item-type taxonomy stays in Rust (CLAUDE.md).
|
||||
fn is_audio_only_video(item: &MediaItem) -> bool {
|
||||
item.media_type == MediaType::Audio
|
||||
&& matches!(item.item_type.as_deref(), Some("Episode") | Some("Movie"))
|
||||
}
|
||||
|
||||
/// Claim a resume attempt for the current stream, returning the absolute
|
||||
/// position to re-open at and the 1-based attempt number. `None` when the
|
||||
/// current item cannot meaningfully be re-requested, or when retrying at this
|
||||
/// position has stopped helping.
|
||||
///
|
||||
/// Only `Remote` sources qualify. A downloaded file cannot fail because of
|
||||
/// the network, so re-opening one would paper over a real read error; a
|
||||
/// `DirectUrl` is a plugin's endpoint with no Jellyfin item behind it.
|
||||
///
|
||||
/// The player's position is relative to the stream's own zero (the handoff
|
||||
/// URL's `StartTimeTicks`), so the base is added back to get an absolute one.
|
||||
/// It is zero for everything else, where positions are already absolute.
|
||||
///
|
||||
/// TRACES: UR-040, UR-004 | DR-129 | UT-117
|
||||
fn claim_stream_resume(&self) -> Option<(f64, u32)> {
|
||||
let current = {
|
||||
let queue = self.queue.lock_safe();
|
||||
queue.current().cloned()
|
||||
}?;
|
||||
if !matches!(current.source, MediaSource::Remote { .. }) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let base = *self.background_audio_base.lock_safe();
|
||||
let absolute = (base + self.position()).max(0.0);
|
||||
|
||||
match self.stream_resume.lock_safe().allow_attempt(absolute) {
|
||||
Some(attempt) => Some((absolute, attempt)),
|
||||
None => {
|
||||
warn!(
|
||||
"[PlayerController] Stream for {} keeps failing at {:.1}s — giving up on resuming",
|
||||
current.id, absolute
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The absolute position to re-open the current stream at, when the reported
|
||||
/// end was really a dropped connection — `None` when the end looks genuine,
|
||||
/// when this is not an audio-only handoff, or when retrying has stopped
|
||||
/// helping.
|
||||
///
|
||||
/// TRACES: UR-040 | DR-129 | UT-117
|
||||
fn truncated_stream_resume_position(&self) -> Option<f64> {
|
||||
// An explicit user intent already explains the end; never resume over it.
|
||||
if matches!(
|
||||
self.peek_end_reason(),
|
||||
Some(EndReason::UserStop) | Some(EndReason::UserSkip) | Some(EndReason::Error)
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// One lock at a time — `position()` reaches into the backend, and nesting
|
||||
// that inside the queue lock would invent a lock order nothing else here
|
||||
// takes.
|
||||
let item_duration = {
|
||||
let queue = self.queue.lock_safe();
|
||||
let current = queue.current()?;
|
||||
if !Self::is_audio_only_video(current) {
|
||||
return None;
|
||||
}
|
||||
current.duration
|
||||
};
|
||||
let base = *self.background_audio_base.lock_safe();
|
||||
let absolute = (base + self.position()).max(0.0);
|
||||
|
||||
// Only spend a resume attempt once the runtime says this really was cut
|
||||
// short — a genuine end must stay a genuine end.
|
||||
if !stream_end::is_truncated_end(
|
||||
absolute,
|
||||
item_duration,
|
||||
stream_end::TRUNCATED_STREAM_TOLERANCE_SECS,
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.claim_stream_resume().map(|(position, _)| position)
|
||||
}
|
||||
|
||||
/// Where to re-open the current stream after a *recoverable* playback error,
|
||||
/// plus how many seconds to wait first.
|
||||
///
|
||||
/// The media was decoding fine a moment ago, so a mid-playback failure on a
|
||||
/// server stream is the network — and stopping the player (the previous
|
||||
/// behaviour, via the frontend's error handler) turns a hiccup into "playback
|
||||
/// just died". Applies to every streamed item, not only the audio-only
|
||||
/// handoff: music and video reach here instead of the truncation path because
|
||||
/// their streams declare a length, so a cut connection surfaces as an error
|
||||
/// rather than a phantom end.
|
||||
///
|
||||
/// The wait grows with the attempt number so a short outage has time to
|
||||
/// clear, and the shared budget stops the retries when it doesn't.
|
||||
///
|
||||
/// Only *called* from the Android error callback (`#[cfg(android)]`), but
|
||||
/// compiled and unit-tested on the host, hence `allow(dead_code)` off-Android.
|
||||
///
|
||||
/// TRACES: UR-040, UR-004 | DR-129 | UT-117
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn recoverable_error_resume(&self) -> Option<(f64, u64)> {
|
||||
self.claim_stream_resume()
|
||||
.map(|(position, attempt)| (position, attempt as u64 * RESUME_BACKOFF_STEP_SECS))
|
||||
}
|
||||
|
||||
/// Re-open the current stream at `position` after the network cut it short.
|
||||
///
|
||||
/// Single place every dispatcher agrees on, for the same reason
|
||||
/// `auto_advance_to_next_episode` is: the Android JNI callbacks and the
|
||||
/// frontend-invoked command must not disagree about what a failed stream
|
||||
/// means. None of them emits `PlaybackEnded` for this, so nothing downstream
|
||||
/// clears the queue or tears the session down — from the outside this is a
|
||||
/// buffering hiccup, which is what it actually was.
|
||||
///
|
||||
/// Reloads the item **in place** rather than through `play_item`, which
|
||||
/// replaces the queue with a single item: recovering a track that way would
|
||||
/// throw away the rest of the album, turning a network blip into lost state.
|
||||
///
|
||||
/// Two shapes of stream, two ways back to `position`:
|
||||
///
|
||||
/// - The audio-only handoff's `/Audio/{id}/universal` transcode is chunked
|
||||
/// with no length, so it cannot be seeked. Its URL is rewritten to start at
|
||||
/// the position instead — edited, not rebuilt from the repository, since it
|
||||
/// already carries the user's audio track and media source and recovering
|
||||
/// from a network failure must not itself need a network round-trip.
|
||||
/// - Everything else (a static file with byte ranges, an HLS playlist)
|
||||
/// declares its whole timeline, so re-preparing the URL it already has and
|
||||
/// seeking lands in the right place — and leaves any transcode session
|
||||
/// behind it alone.
|
||||
///
|
||||
/// TRACES: UR-040, UR-004 | DR-129 | UT-117
|
||||
pub async fn resume_stream_at(&self, position: f64) -> Result<(), String> {
|
||||
let current = {
|
||||
let queue = self.queue.lock_safe();
|
||||
queue.current().cloned()
|
||||
}
|
||||
.ok_or_else(|| "No current item to resume".to_string())?;
|
||||
|
||||
let MediaSource::Remote { stream_url, .. } = ¤t.source else {
|
||||
return Err(format!(
|
||||
"Cannot resume a non-remote source for {}",
|
||||
current.id
|
||||
));
|
||||
};
|
||||
|
||||
info!(
|
||||
"[PlayerController] Stream for {} failed — re-opening at {:.1}s",
|
||||
current.id, position
|
||||
);
|
||||
|
||||
if !Self::is_audio_only_video(¤t) {
|
||||
self.load_and_play(¤t).map_err(|e| e.to_string())?;
|
||||
if position > 0.5 {
|
||||
self.seek(position).map_err(|e| e.to_string())?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let restarted_url = stream_end::with_start_time(stream_url, position);
|
||||
{
|
||||
let queue_arc = self.queue.clone();
|
||||
let mut queue = queue_arc.lock_safe();
|
||||
if !queue.update_current_stream_url(restarted_url) {
|
||||
return Err(format!("Failed to update stream URL for {}", current.id));
|
||||
}
|
||||
}
|
||||
let resumed = {
|
||||
let queue = self.queue.lock_safe();
|
||||
queue.current().cloned()
|
||||
}
|
||||
.ok_or_else(|| "Current item vanished mid-resume".to_string())?;
|
||||
|
||||
// The re-opened stream's timeline starts at `position` (StartTimeTicks),
|
||||
// so that is its zero: the exit-to-foreground maths and the lockscreen
|
||||
// scrubber both read absolute positions off this base.
|
||||
self.set_background_audio_base(position);
|
||||
let _ = set_lockscreen_position_offset(position.max(0.0));
|
||||
|
||||
self.load_and_play(&resumed).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Advance to the next episode while playing audio-only in the background.
|
||||
///
|
||||
/// The normal autoplay-next path navigates the frontend to `/player/<id>`,
|
||||
@@ -1322,6 +1550,9 @@ impl PlayerController {
|
||||
// back to the foreground) and the lockscreen scrubber's matching shift.
|
||||
self.set_background_audio_base(0.0);
|
||||
let _ = set_lockscreen_position_offset(0.0);
|
||||
// Different stream entirely: whatever was stuck about the last one is not
|
||||
// this one's problem.
|
||||
self.stream_resume.lock_safe().reset();
|
||||
|
||||
self.play_item(media_item).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -2862,6 +3093,13 @@ mod tests {
|
||||
async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_favorites(
|
||||
&self,
|
||||
_: repo_types::SearchScope,
|
||||
_: Option<repo_types::GetItemsOptions>,
|
||||
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -3018,6 +3256,7 @@ mod tests {
|
||||
item_type: Some("Episode".to_string()),
|
||||
media_type: MediaType::Audio, // audio-only handoff, not Video
|
||||
series_id: Some("series1".to_string()),
|
||||
duration: Some(180.0),
|
||||
source: MediaSource::Remote {
|
||||
stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
|
||||
jellyfin_item_id: "ep2".to_string(),
|
||||
@@ -3026,6 +3265,8 @@ mod tests {
|
||||
};
|
||||
controller.play_queue(vec![episode], 0).unwrap();
|
||||
|
||||
// Played through to the end — a natural finish, not a stream cut short.
|
||||
controller.seek(180.0).unwrap();
|
||||
// Clear the NewTrackLoaded reason to simulate natural track end.
|
||||
controller.take_end_reason();
|
||||
|
||||
@@ -3158,6 +3399,328 @@ mod tests {
|
||||
assert!(controller.current_is_audio_episode());
|
||||
}
|
||||
|
||||
/// Build the audio-only episode the background handoff loads: a video item
|
||||
/// played through the native audio path, with a known runtime and a stream
|
||||
/// URL carrying the handoff position.
|
||||
fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
|
||||
MediaItem {
|
||||
id: "ep2".to_string(),
|
||||
item_type: Some("Episode".to_string()),
|
||||
media_type: MediaType::Audio,
|
||||
series_id: Some("series1".to_string()),
|
||||
duration: Some(runtime_seconds),
|
||||
source: MediaSource::Remote {
|
||||
stream_url:
|
||||
"http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=0"
|
||||
.to_string(),
|
||||
jellyfin_item_id: "ep2".to_string(),
|
||||
},
|
||||
..create_test_items(1).remove(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A flaky connection truncates the progressive mp3 transcode that carries
|
||||
/// 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
|
||||
/// twenty-five minute episode — indistinguishable, to the player, from the
|
||||
/// real end.
|
||||
///
|
||||
/// Treating that as "the episode finished" is what the user experiences as
|
||||
/// the episode randomly restarting: playback parks in STATE_ENDED and the
|
||||
/// next play intent (lockscreen, notification, Bluetooth reconnect) seeks an
|
||||
/// ended player to position 0 before playing. The runtime we already know
|
||||
/// says the stream died early, so the decision must be to resume it.
|
||||
#[tokio::test]
|
||||
async fn test_truncated_background_audio_stream_resumes_instead_of_ending() {
|
||||
let controller = PlayerController::default();
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||
|
||||
controller
|
||||
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
||||
.unwrap();
|
||||
// The connection dropped 10 minutes into a 25-minute episode.
|
||||
controller.seek(600.0).unwrap();
|
||||
controller.take_end_reason();
|
||||
|
||||
let decision = controller.on_playback_ended().await.unwrap();
|
||||
|
||||
match decision {
|
||||
AutoplayDecision::ResumeStream { position } => {
|
||||
assert_eq!(position, 600.0, "must resume where the stream died");
|
||||
}
|
||||
other => panic!(
|
||||
"a stream that ended 15 minutes short of the runtime must resume, \
|
||||
not run end-of-episode logic; got {:?}",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The handoff stream's timeline starts at the handoff position, so the
|
||||
/// player reports a *relative* position. The runtime it is compared against
|
||||
/// is absolute — the base has to be added back, or every handoff looks like a
|
||||
/// truncation.
|
||||
#[tokio::test]
|
||||
async fn test_truncated_check_uses_the_absolute_position() {
|
||||
let controller = PlayerController::default();
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||
|
||||
controller
|
||||
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
||||
.unwrap();
|
||||
// Handed off at 24:00; the stream then played its last 56 seconds out.
|
||||
controller.set_background_audio_base(1440.0);
|
||||
controller.seek(56.0).unwrap();
|
||||
controller.take_end_reason();
|
||||
|
||||
let decision = controller.on_playback_ended().await.unwrap();
|
||||
|
||||
assert!(
|
||||
matches!(decision, AutoplayDecision::ShowNextEpisodePopup { .. }),
|
||||
"24:56 of a 25:00 episode is the real end, not a truncation; got {:?}",
|
||||
decision
|
||||
);
|
||||
}
|
||||
|
||||
/// The resume re-opens the same URL, so a server that is actually gone would
|
||||
/// otherwise end → resume → end forever. After the budget runs out the
|
||||
/// decision falls back to normal end-of-item handling.
|
||||
#[tokio::test]
|
||||
async fn test_repeated_truncation_at_the_same_position_gives_up() {
|
||||
let controller = PlayerController::default();
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||
|
||||
controller
|
||||
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
||||
.unwrap();
|
||||
controller.seek(600.0).unwrap();
|
||||
|
||||
for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
|
||||
controller.take_end_reason();
|
||||
let decision = controller.on_playback_ended().await.unwrap();
|
||||
assert!(
|
||||
matches!(decision, AutoplayDecision::ResumeStream { .. }),
|
||||
"attempt {} should still resume, got {:?}",
|
||||
attempt,
|
||||
decision
|
||||
);
|
||||
}
|
||||
|
||||
controller.take_end_reason();
|
||||
let decision = controller.on_playback_ended().await.unwrap();
|
||||
assert!(
|
||||
!matches!(decision, AutoplayDecision::ResumeStream { .. }),
|
||||
"a stream stuck at the same position must stop retrying, got {:?}",
|
||||
decision
|
||||
);
|
||||
}
|
||||
|
||||
/// Ordinary music is not covered: its streams are not the length-less
|
||||
/// progressive transcode this guards, and a short track legitimately ends
|
||||
/// well before a stale duration would suggest.
|
||||
#[tokio::test]
|
||||
async fn test_truncation_check_does_not_touch_plain_audio_tracks() {
|
||||
let controller = PlayerController::default();
|
||||
|
||||
let mut items = create_test_items(2);
|
||||
items[0].duration = Some(1500.0);
|
||||
controller.play_queue(items, 0).unwrap();
|
||||
controller.seek(60.0).unwrap();
|
||||
controller.take_end_reason();
|
||||
|
||||
let decision = controller.on_playback_ended().await.unwrap();
|
||||
assert!(
|
||||
matches!(decision, AutoplayDecision::AdvanceToNext),
|
||||
"plain queue audio must keep advancing, got {:?}",
|
||||
decision
|
||||
);
|
||||
}
|
||||
|
||||
/// Music and video stream from URLs that declare their own length (a static
|
||||
/// file with byte ranges, an HLS playlist), so a truncation reaches the
|
||||
/// player as an *error* rather than a phantom end. It is the same network
|
||||
/// failure, and the same recovery applies — the previous behaviour turned it
|
||||
/// into `playerStop()` and silence.
|
||||
#[tokio::test]
|
||||
async fn test_recoverable_error_resumes_a_music_track() {
|
||||
let controller = PlayerController::default();
|
||||
|
||||
let mut items = create_test_items(3);
|
||||
for item in &mut items {
|
||||
item.source = MediaSource::Remote {
|
||||
stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
|
||||
jellyfin_item_id: item.id.clone(),
|
||||
};
|
||||
}
|
||||
controller.play_queue(items, 1).unwrap();
|
||||
controller.seek(45.0).unwrap();
|
||||
|
||||
let (position, _) = controller
|
||||
.recoverable_error_resume()
|
||||
.expect("a streamed music track must be resumable after a network error");
|
||||
assert_eq!(position, 45.0);
|
||||
}
|
||||
|
||||
/// The resume must reload the failed track IN PLACE. `play_item` replaces the
|
||||
/// whole queue with a single item, so recovering a track that way would throw
|
||||
/// away the rest of the album — turning a network blip into lost state.
|
||||
#[tokio::test]
|
||||
async fn test_resume_keeps_the_rest_of_the_queue() {
|
||||
let controller = PlayerController::default();
|
||||
|
||||
let mut items = create_test_items(3);
|
||||
for item in &mut items {
|
||||
item.source = MediaSource::Remote {
|
||||
stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
|
||||
jellyfin_item_id: item.id.clone(),
|
||||
};
|
||||
}
|
||||
controller.play_queue(items, 1).unwrap();
|
||||
|
||||
controller
|
||||
.resume_stream_at(45.0)
|
||||
.await
|
||||
.expect("resume should succeed");
|
||||
|
||||
let queue = controller.queue.lock_safe();
|
||||
assert_eq!(queue.items().len(), 3, "the queue must survive a resume");
|
||||
assert_eq!(queue.current_index(), Some(1), "still on the same track");
|
||||
assert_eq!(queue.current().unwrap().id, "item_1");
|
||||
}
|
||||
|
||||
/// A seekable stream is re-opened by re-preparing the URL it already has and
|
||||
/// seeking — its timeline is intact, and rewriting the URL would restart a
|
||||
/// transcode session for no reason.
|
||||
#[tokio::test]
|
||||
async fn test_resume_seeks_a_seekable_stream_rather_than_rewriting_its_url() {
|
||||
let controller = PlayerController::default();
|
||||
|
||||
let mut items = create_test_items(1);
|
||||
items[0].source = MediaSource::Remote {
|
||||
stream_url: "http://s/Audio/item_0/stream?Static=true".to_string(),
|
||||
jellyfin_item_id: "item_0".to_string(),
|
||||
};
|
||||
controller.play_queue(items, 0).unwrap();
|
||||
|
||||
controller.resume_stream_at(45.0).await.unwrap();
|
||||
|
||||
match &controller.queue.lock_safe().current().unwrap().source {
|
||||
MediaSource::Remote { stream_url, .. } => {
|
||||
assert_eq!(
|
||||
stream_url, "http://s/Audio/item_0/stream?Static=true",
|
||||
"a seekable stream's URL must be left alone"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Remote source, got {:?}", other),
|
||||
}
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
45.0,
|
||||
"and it must land at the position"
|
||||
);
|
||||
}
|
||||
|
||||
/// Downloaded media cannot fail from the network, and re-opening a local file
|
||||
/// would paper over a real read error.
|
||||
#[tokio::test]
|
||||
async fn test_recoverable_error_ignores_local_media() {
|
||||
let controller = PlayerController::default();
|
||||
|
||||
let mut items = create_test_items(1);
|
||||
items[0].source = MediaSource::Local {
|
||||
file_path: "/music/track.flac".into(),
|
||||
jellyfin_item_id: Some("item_0".to_string()),
|
||||
};
|
||||
controller.play_queue(items, 0).unwrap();
|
||||
controller.seek(45.0).unwrap();
|
||||
|
||||
assert!(controller.recoverable_error_resume().is_none());
|
||||
}
|
||||
|
||||
/// A recoverable error during background audio-only playback is the network,
|
||||
/// not the media — the previous behaviour (surface it, frontend stops the
|
||||
/// player) turned a hiccup into silence. Retrying must also back off, or the
|
||||
/// three attempts are spent inside a second and the outage outlives them.
|
||||
#[tokio::test]
|
||||
async fn test_recoverable_error_during_audio_only_resumes_with_backoff() {
|
||||
let controller = PlayerController::default();
|
||||
|
||||
controller
|
||||
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
||||
.unwrap();
|
||||
controller.seek(600.0).unwrap();
|
||||
|
||||
let mut waits = Vec::new();
|
||||
for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
|
||||
let (position, delay) = controller
|
||||
.recoverable_error_resume()
|
||||
.unwrap_or_else(|| panic!("attempt {} should still retry", attempt));
|
||||
assert_eq!(position, 600.0);
|
||||
waits.push(delay);
|
||||
}
|
||||
assert_eq!(waits, vec![2, 4, 6], "the wait must grow between attempts");
|
||||
assert!(
|
||||
controller.recoverable_error_resume().is_none(),
|
||||
"a stream that keeps failing at the same spot must surface the error"
|
||||
);
|
||||
}
|
||||
|
||||
/// Plugin/channel `DirectUrl` sources are somebody else's endpoint with no
|
||||
/// Jellyfin item behind them, so the resume has nothing to re-request.
|
||||
#[tokio::test]
|
||||
async fn test_recoverable_error_ignores_direct_url_playback() {
|
||||
let controller = PlayerController::default();
|
||||
controller.play_queue(create_test_items(2), 0).unwrap();
|
||||
|
||||
assert!(controller.recoverable_error_resume().is_none());
|
||||
}
|
||||
|
||||
/// Re-opening the stream must land where it died and keep playing, with the
|
||||
/// handoff base moved to the new stream's zero so returning to the
|
||||
/// foreground still resolves an absolute position.
|
||||
#[tokio::test]
|
||||
async fn test_resume_truncated_stream_reloads_at_position() {
|
||||
let controller = PlayerController::default();
|
||||
|
||||
controller
|
||||
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
||||
.unwrap();
|
||||
controller.set_background_audio_base(0.0);
|
||||
|
||||
controller
|
||||
.resume_stream_at(600.0)
|
||||
.await
|
||||
.expect("resume should succeed");
|
||||
|
||||
let current = controller
|
||||
.queue
|
||||
.lock_safe()
|
||||
.current()
|
||||
.cloned()
|
||||
.expect("the same item should still be loaded");
|
||||
assert_eq!(current.id, "ep2", "resume must not change the item");
|
||||
match ¤t.source {
|
||||
MediaSource::Remote { stream_url, .. } => {
|
||||
assert!(
|
||||
stream_url.contains("StartTimeTicks=6000000000"),
|
||||
"stream must re-open at 600s, got {}",
|
||||
stream_url
|
||||
);
|
||||
assert!(
|
||||
stream_url.contains("AudioStreamIndex=2"),
|
||||
"the selected audio track must survive the resume, got {}",
|
||||
stream_url
|
||||
);
|
||||
}
|
||||
other => panic!("expected Remote source, got {:?}", other),
|
||||
}
|
||||
assert_eq!(
|
||||
controller.take_background_audio_base(),
|
||||
600.0,
|
||||
"the re-opened stream's zero is the resume position"
|
||||
);
|
||||
}
|
||||
|
||||
/// Foreground video playback keeps the countdown-driven advance: the frontend
|
||||
/// owns the navigation there, so the backend must NOT load the next episode
|
||||
/// itself (that would race the page transition and double-start playback).
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Telling a *finished* stream apart from a *truncated* one.
|
||||
//!
|
||||
//! TRACES: UR-040 | DR-129 | UT-117
|
||||
//!
|
||||
//! Background audio-only playback of a video item streams a **progressive mp3
|
||||
//! transcode over plain HTTP** (see
|
||||
//! `OnlineRepository::build_audio_only_stream_url_for_video`). That response has
|
||||
//! no reliable length — a live transcode is chunked — so when the connection
|
||||
//! drops mid-episode the data source simply sees end-of-input. ExoPlayer cannot
|
||||
//! distinguish that from the real end of the media and reports
|
||||
//! `Player.STATE_ENDED`, which the app then treats as "the episode finished".
|
||||
//!
|
||||
//! The user-visible damage is not the missed advance itself. Playback parks in
|
||||
//! ExoPlayer's `STATE_ENDED`, and the next play intent from the lockscreen,
|
||||
//! notification or a Bluetooth reconnect goes through media3's
|
||||
//! `Util.handlePlayButtonAction`, which seeks an ENDED player to its default
|
||||
//! position before playing — so **the episode starts over from 0:00**. On a
|
||||
//! flaky connection that reads as "it randomly restarts the episode".
|
||||
//!
|
||||
//! The player itself has no way to know; the *duration* does. Jellyfin gives us
|
||||
//! the item's real runtime, so an end reported well short of it is a truncation,
|
||||
//! not a finish — and the right response is to re-open the stream where it died,
|
||||
//! which is the "buffer and resume" the user expects.
|
||||
|
||||
/// How far short of the item's runtime a stream may end and still count as a
|
||||
/// natural finish.
|
||||
///
|
||||
/// Sized to swallow the two sources of slack in the comparison — the position
|
||||
/// poll is up to 250 ms stale, and Jellyfin's reported runtime can disagree with
|
||||
/// the transcoded output by a second or two — while staying far below the
|
||||
/// minutes-long gap a dropped connection leaves. Erring long is the safe
|
||||
/// direction: a false "finished" is the bug we are fixing, whereas a false
|
||||
/// "truncated" only re-opens the stream for its last few seconds and then ends
|
||||
/// again normally.
|
||||
pub const TRUNCATED_STREAM_TOLERANCE_SECS: f64 = 10.0;
|
||||
|
||||
/// Consecutive resume attempts allowed at the same position before giving up.
|
||||
///
|
||||
/// A resume re-opens the same URL, so a server that is genuinely gone would
|
||||
/// otherwise end → resume → end forever. Progress past the last attempt resets
|
||||
/// the budget (see [`ResumeTracker`]), so this only bounds *stuck* retries.
|
||||
pub const MAX_STALLED_RESUME_ATTEMPTS: u32 = 3;
|
||||
|
||||
/// Position change that counts as "this is a different playback context" —
|
||||
/// either the resume made progress, or a different item is loaded.
|
||||
const RESUME_PROGRESS_EPSILON_SECS: f64 = 1.0;
|
||||
|
||||
/// Did this end-of-stream happen far enough short of the item's runtime to be a
|
||||
/// truncation rather than a finish?
|
||||
///
|
||||
/// `position` and `duration` must be on the same timeline — for a handoff stream
|
||||
/// built with `StartTimeTicks`, that means the *absolute* position (handoff base
|
||||
/// + the player's relative position) against the item's full runtime.
|
||||
///
|
||||
/// An unknown or non-positive `duration` answers `false`: with nothing to
|
||||
/// compare against, the reported end is taken at face value (previous behaviour).
|
||||
pub fn is_truncated_end(position: f64, duration: Option<f64>, tolerance: f64) -> bool {
|
||||
let Some(duration) = duration else {
|
||||
return false;
|
||||
};
|
||||
if duration <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
position.max(0.0) + tolerance < duration
|
||||
}
|
||||
|
||||
/// Rewrite an audio-only stream URL to start at `position_seconds`.
|
||||
///
|
||||
/// Resuming re-opens *the stream we were already playing*, so the URL is edited
|
||||
/// in place rather than rebuilt from the repository: every other parameter —
|
||||
/// `AudioStreamIndex` (the track the user picked in the video player),
|
||||
/// `MediaSourceId`, `api_key` — is carried over untouched, and no network call
|
||||
/// is needed to recover from a network failure.
|
||||
pub fn with_start_time(url: &str, position_seconds: f64) -> String {
|
||||
let ticks = (position_seconds.max(0.0) * 10_000_000.0) as i64;
|
||||
let param = format!("StartTimeTicks={}", ticks);
|
||||
|
||||
let (base, query) = match url.split_once('?') {
|
||||
Some((base, query)) => (base, query),
|
||||
// No query string at all: the URL was not built by us, but appending the
|
||||
// parameter is still the correct request to make.
|
||||
None => return format!("{}?{}", url, param),
|
||||
};
|
||||
|
||||
let mut replaced = false;
|
||||
let mut parts: Vec<String> = query
|
||||
.split('&')
|
||||
.map(|part| {
|
||||
if part.split('=').next() == Some("StartTimeTicks") {
|
||||
replaced = true;
|
||||
param.clone()
|
||||
} else {
|
||||
part.to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !replaced {
|
||||
parts.push(param);
|
||||
}
|
||||
|
||||
format!("{}?{}", base, parts.join("&"))
|
||||
}
|
||||
|
||||
/// Budget for consecutive resume attempts that make no progress.
|
||||
///
|
||||
/// Held by the player controller across ends of the *same* stream. Any position
|
||||
/// change larger than [`RESUME_PROGRESS_EPSILON_SECS`] — the resume played on,
|
||||
/// or a different item was loaded — is a fresh context and refills the budget.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ResumeTracker {
|
||||
last_position: Option<f64>,
|
||||
attempts: u32,
|
||||
}
|
||||
|
||||
impl ResumeTracker {
|
||||
/// Record an attempt at `position`, returning its 1-based number — or `None`
|
||||
/// once the budget is spent. Callers use the number to back off: a stream
|
||||
/// that failed twice at the same spot is waiting on something slower than an
|
||||
/// immediate retry can outrun.
|
||||
pub fn allow_attempt(&mut self, position: f64) -> Option<u32> {
|
||||
let progressed = match self.last_position {
|
||||
Some(last) => (position - last).abs() > RESUME_PROGRESS_EPSILON_SECS,
|
||||
None => true,
|
||||
};
|
||||
if progressed {
|
||||
self.attempts = 0;
|
||||
}
|
||||
self.last_position = Some(position);
|
||||
self.attempts += 1;
|
||||
(self.attempts <= MAX_STALLED_RESUME_ATTEMPTS).then_some(self.attempts)
|
||||
}
|
||||
|
||||
/// Forget the budget — a new item is playing, so nothing is stuck.
|
||||
pub fn reset(&mut self) {
|
||||
self.last_position = None;
|
||||
self.attempts = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_end_near_duration_is_a_natural_finish() {
|
||||
// Episode runtime 25:00, stream ended at 24:56 — that is the end.
|
||||
assert!(!is_truncated_end(
|
||||
1496.0,
|
||||
Some(1500.0),
|
||||
TRUNCATED_STREAM_TOLERANCE_SECS
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_end_far_short_of_duration_is_truncated() {
|
||||
// Episode runtime 25:00, stream died at 10:00 — the connection dropped.
|
||||
assert!(is_truncated_end(
|
||||
600.0,
|
||||
Some(1500.0),
|
||||
TRUNCATED_STREAM_TOLERANCE_SECS
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_duration_is_taken_at_face_value() {
|
||||
// Nothing to compare against: keep the previous end-of-track behaviour
|
||||
// rather than resuming a stream that may really have finished.
|
||||
assert!(!is_truncated_end(
|
||||
600.0,
|
||||
None,
|
||||
TRUNCATED_STREAM_TOLERANCE_SECS
|
||||
));
|
||||
assert!(!is_truncated_end(
|
||||
600.0,
|
||||
Some(0.0),
|
||||
TRUNCATED_STREAM_TOLERANCE_SECS
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tolerance_boundary() {
|
||||
// Exactly one tolerance short still counts as finished, so poll staleness
|
||||
// and runtime rounding never fabricate a truncation.
|
||||
assert!(!is_truncated_end(1490.0, Some(1500.0), 10.0));
|
||||
assert!(is_truncated_end(1489.0, Some(1500.0), 10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_start_time_replaces_existing_ticks() {
|
||||
let url = "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=1200000000&Container=mp3";
|
||||
let out = with_start_time(url, 600.0);
|
||||
assert_eq!(
|
||||
out,
|
||||
"http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=6000000000&Container=mp3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_start_time_appends_when_absent() {
|
||||
// The next-episode stream is built without StartTimeTicks.
|
||||
let url = "http://s/Audio/ep3/universal?api_key=k&AudioStreamIndex=0";
|
||||
let out = with_start_time(url, 90.0);
|
||||
assert_eq!(
|
||||
out,
|
||||
"http://s/Audio/ep3/universal?api_key=k&AudioStreamIndex=0&StartTimeTicks=900000000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_start_time_preserves_selected_audio_track() {
|
||||
// The whole point of editing the URL instead of rebuilding it: the track
|
||||
// the user chose in the video player survives the resume.
|
||||
let url = "http://s/Audio/ep2/universal?AudioStreamIndex=3&MediaSourceId=src-1";
|
||||
let out = with_start_time(url, 10.0);
|
||||
assert!(out.contains("AudioStreamIndex=3"));
|
||||
assert!(out.contains("MediaSourceId=src-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_start_time_without_query() {
|
||||
assert_eq!(
|
||||
with_start_time("http://s/Audio/ep2/universal", 1.0),
|
||||
"http://s/Audio/ep2/universal?StartTimeTicks=10000000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resume_tracker_bounds_stalled_retries() {
|
||||
let mut tracker = ResumeTracker::default();
|
||||
// Same position over and over: the stream is not recovering.
|
||||
for n in 1..=MAX_STALLED_RESUME_ATTEMPTS {
|
||||
assert_eq!(
|
||||
tracker.allow_attempt(600.0),
|
||||
Some(n),
|
||||
"attempts are numbered so callers can back off"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
tracker.allow_attempt(600.0),
|
||||
None,
|
||||
"a stream that ends at the same position every time must stop retrying"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resume_tracker_refills_after_progress() {
|
||||
let mut tracker = ResumeTracker::default();
|
||||
for _ in 0..MAX_STALLED_RESUME_ATTEMPTS {
|
||||
tracker.allow_attempt(600.0);
|
||||
}
|
||||
assert_eq!(tracker.allow_attempt(600.0), None);
|
||||
// The next drop happened further in — the resumes are working, so the
|
||||
// budget must not be exhausted by earlier trouble.
|
||||
assert_eq!(tracker.allow_attempt(900.0), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resume_tracker_reset() {
|
||||
let mut tracker = ResumeTracker::default();
|
||||
for _ in 0..=MAX_STALLED_RESUME_ATTEMPTS {
|
||||
tracker.allow_attempt(600.0);
|
||||
}
|
||||
tracker.reset();
|
||||
assert_eq!(tracker.allow_attempt(600.0), Some(1));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user