fix(player): play on past the end of a season

Autoplay listed the episodes of the current season and stopped dead at the
last one, so the end of a season returned AutoplayDecision::Stop. On the
Android background-audio handoff that is felt as playback simply pausing
mid-binge with the screen locked and no UI to un-pause it -- the device log
reads "Current episode is the last in the season" and then "Decision: Stop
playback", one episode after a mid-season boundary the backend advanced
through by itself.

The lookup now walks the series' seasons and takes the first episode of the
next one that has any. Seasons are sorted client-side by index number
because the offline repository ignores sort_by, empty seasons are skipped
rather than read as the end of the series, and Specials are never rolled
into: Jellyfin numbers them 0 so they sort ahead of season 1, but a server
that leaves the index unset sorts them last, exactly where the walk would
otherwise land.

Every autoplay entry point shares this lookup, so foreground video and the
Android native path cross the boundary too. It stays below the sleep-timer
gate in on_playback_ended, so a timer set to end-of-episode or a remaining
episode count still stops at the boundary instead of being carried past it.

TRACES: UR-023, UR-040 | DR-263 | UT-238
This commit is contained in:
2026-08-25 23:48:15 +02:00
parent ad05dcd484
commit bbccc8567c
2 changed files with 257 additions and 18 deletions
+253 -16
View File
@@ -2251,6 +2251,12 @@ impl PlayerController {
return Ok(Some((current_repo_item, next.clone())));
} else {
log::info!("[PlayerController] Current episode is the last in the season");
if let Some(next) = self
.first_episode_of_next_season(&current_repo_item, repo)
.await
{
return Ok(Some((current_repo_item, next)));
}
}
} else {
log::info!(
@@ -2266,6 +2272,97 @@ impl PlayerController {
Ok(None)
}
/// The first episode of the season after this one, if the series has one.
///
/// A season boundary is not the end of a series, and stopping there is felt
/// most sharply on the background-audio path (UR-040): the screen is locked,
/// nothing shows a "next" button, and playback simply stops mid-binge. Every
/// other autoplay entry point shares this lookup, so foreground video and
/// the Android native path cross the boundary too.
///
/// Lookup failures degrade to `None` rather than an error: the episode has
/// already finished, and the caller's only alternative is to stop anyway.
///
/// TRACES: UR-023, UR-040 | DR-263 | UT-238
async fn first_episode_of_next_season(
&self,
current: &crate::repository::types::MediaItem,
repo: &Arc<dyn crate::repository::MediaRepository>,
) -> Option<crate::repository::types::MediaItem> {
use crate::repository::types::GetItemsOptions;
let series_id = current.series_id.as_deref()?;
let season_id = current.season_id.as_deref()?;
let season_options = GetItemsOptions {
sort_by: Some("IndexNumber".to_string()),
sort_order: Some("Ascending".to_string()),
limit: Some(500),
include_item_types: Some(vec!["Season".to_string()]),
..Default::default()
};
let mut seasons = match repo.get_items(series_id, Some(season_options)).await {
Ok(result) => result.items,
Err(e) => {
log::warn!(
"[PlayerController] Season lookup failed for series {}: {}",
series_id,
e
);
return None;
}
};
// Same client-side sort as the episode list: the offline repository
// ignores sort_by and orders by sort_name instead.
seasons.sort_by_key(|s| s.index_number.unwrap_or(i32::MAX));
let current_idx = seasons.iter().position(|s| s.id == season_id)?;
for season in &seasons[current_idx + 1..] {
// Never roll into Specials. Jellyfin numbers them 0, so they sort
// ahead of season 1 and are normally unreachable from here -- but a
// server that leaves the index unset sorts them last, right where
// this walk would otherwise land.
if season.index_number == Some(0) {
continue;
}
let episode_options = GetItemsOptions {
sort_by: Some("IndexNumber".to_string()),
sort_order: Some("Ascending".to_string()),
limit: Some(500),
include_item_types: Some(vec!["Episode".to_string()]),
..Default::default()
};
let mut episodes = match repo.get_items(&season.id, Some(episode_options)).await {
Ok(result) => result.items,
Err(e) => {
log::warn!(
"[PlayerController] Episode lookup failed for season {}: {}",
season.id,
e
);
return None;
}
};
episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
// An empty season is a gap in the series, not the end of it.
if let Some(first) = episodes.into_iter().next() {
log::info!(
"[PlayerController] Rolling over to {} of {}: {}",
first.name,
season.name,
first.id
);
return Some(first);
}
}
log::info!("[PlayerController] No further season to roll over into");
None
}
/// Start autoplay countdown thread
pub fn start_autoplay_countdown(
&self,
@@ -3685,23 +3782,67 @@ mod tests {
/// lookup tests. Only `get_item` and `get_items` are used by
/// `fetch_next_episode_for_item`; everything else is unreachable.
struct MockEpisodeRepo {
episodes: Vec<repo_types::MediaItem>,
/// Seasons in the order the series lists them, each with its episodes.
seasons: Vec<(repo_types::MediaItem, Vec<repo_types::MediaItem>)>,
}
impl MockEpisodeRepo {
/// A one-season series, whose episodes keep the historical `ep{n}` ids.
fn season(count: usize) -> Self {
let episodes = (1..=count)
.map(|i| {
let mut item = make_repo_episode(&format!("ep{}", i), i as i32);
item.name = format!("Episode {}", i);
item
Self::series(&[count])
}
/// A series whose seasons hold the given episode counts. Season 1 keeps
/// the `ep{n}` ids the single-season tests use; later seasons get
/// `s{season}e{n}` so a rollover assertion names the season it landed in.
fn series(counts: &[usize]) -> Self {
let seasons = counts
.iter()
.enumerate()
.map(|(s, count)| {
let season_number = s as i32 + 1;
let episodes = (1..=*count)
.map(|i| {
let id = if season_number == 1 {
format!("ep{}", i)
} else {
format!("s{}e{}", season_number, i)
};
make_repo_episode_in(season_number, &id, i as i32)
})
.collect();
(make_repo_season(season_number), episodes)
})
.collect();
Self { episodes }
Self { seasons }
}
fn all_episodes(&self) -> impl Iterator<Item = &repo_types::MediaItem> {
self.seasons.iter().flat_map(|(_, eps)| eps.iter())
}
}
fn make_repo_season(index: i32) -> repo_types::MediaItem {
repo_types::MediaItem {
id: format!("season{}", index),
name: format!("Season {}", index),
item_type: "Season".to_string(),
kind: crate::domain::MediaKind::Season,
is_folder: true,
parent_id: Some("series1".to_string()),
index_number: Some(index),
season_id: None,
season_name: None,
parent_index_number: None,
..make_repo_episode(&format!("season{}", index), index)
}
}
fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
make_repo_episode_in(1, id, index)
}
fn make_repo_episode_in(season_number: i32, id: &str, index: i32) -> repo_types::MediaItem {
repo_types::MediaItem {
id: id.to_string(),
name: format!("Episode {}", index),
@@ -3709,7 +3850,7 @@ mod tests {
kind: crate::domain::MediaKind::Episode,
is_folder: false,
server_id: "server".to_string(),
parent_id: Some("season1".to_string()),
parent_id: Some(format!("season{}", season_number)),
library_id: None,
overview: None,
genres: None,
@@ -3731,9 +3872,9 @@ mod tests {
index_number: Some(index),
series_id: Some("series1".to_string()),
series_name: Some("Test Series".to_string()),
season_id: Some("season1".to_string()),
season_name: Some("Season 1".to_string()),
parent_index_number: Some(1),
season_id: Some(format!("season{}", season_number)),
season_name: Some(format!("Season {}", season_number)),
parent_index_number: Some(season_number),
user_data: None,
media_streams: None,
media_sources: None,
@@ -3751,18 +3892,29 @@ mod tests {
parent_id: &str,
_options: Option<repo_types::GetItemsOptions>,
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
assert_eq!(parent_id, "season1", "episode lookup must query the season");
// The series lists its seasons; a season lists its episodes. Both
// are real lookups the autoplay path makes -- the second only once
// the first has told it which season comes next.
let items = if parent_id == "series1" {
self.seasons.iter().map(|(s, _)| s.clone()).collect()
} else {
self.seasons
.iter()
.find(|(season, _)| season.id == parent_id)
.map(|(_, eps)| eps.clone())
.unwrap_or_else(|| panic!("unexpected lookup of container {}", parent_id))
};
let total_record_count = items.len();
Ok(repo_types::SearchResult {
items: self.episodes.clone(),
total_record_count: self.episodes.len(),
items,
total_record_count,
})
}
async fn get_item(
&self,
item_id: &str,
) -> Result<repo_types::MediaItem, repo_types::RepoError> {
self.episodes
.iter()
self.all_episodes()
.find(|e| e.id == item_id)
.cloned()
.ok_or(repo_types::RepoError::NotFound {
@@ -4004,6 +4156,91 @@ mod tests {
}
}
/// A season boundary is not the end of the series. The lookup used to stop
/// dead at the last episode of a season, which on Android's background-audio
/// path is felt as playback simply pausing at the end of an episode with the
/// screen locked and nothing to un-pause it.
///
/// TRACES: UR-023, UR-040 | DR-263 | UT-238
#[tokio::test]
async fn test_next_episode_rolls_over_to_the_next_season() {
let controller = PlayerController::default();
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[2, 2]));
let decision = controller
.on_video_playback_ended("ep2", repo)
.await
.expect("decision should succeed");
match decision {
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
assert_eq!(
next_episode.id, "s2e1",
"the first episode of the next season follows the last of this one"
);
}
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
}
}
/// A season with nothing in it is not the end of the series either.
///
/// TRACES: UR-023 | DR-263 | UT-238
#[tokio::test]
async fn test_next_episode_skips_an_empty_season() {
let controller = PlayerController::default();
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[1, 0, 1]));
let decision = controller
.on_video_playback_ended("ep1", repo)
.await
.expect("decision should succeed");
match decision {
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
assert_eq!(next_episode.id, "s3e1");
}
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
}
}
/// The rollover must not out-rank the sleep timer: crossing a season
/// boundary is still a track boundary, and that is exactly where a timer set
/// to "end of episode" is supposed to stop.
///
/// TRACES: UR-023, UR-026 | DR-263 | UT-238
#[tokio::test]
async fn test_sleep_timer_still_stops_at_a_season_boundary() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::series(&[1, 1])));
controller.set_sleep_timer(SleepTimerMode::Episodes { remaining: 1 });
let episode = MediaItem {
transport: None,
media_type: MediaType::Audio,
item_type: Some("Episode".to_string()),
series_id: Some("series1".to_string()),
duration: Some(180.0),
source: MediaSource::Remote {
stream_url: "http://example.com/ep1.mp3".to_string(),
jellyfin_item_id: "ep1".to_string(),
},
..create_test_items(1).remove(0)
};
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();
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
assert!(
matches!(decision, AutoplayDecision::Stop),
"the last episode the timer allows must stop, not roll into season 2 (got {:?})",
decision
);
}
/// Last episode of the season: no popup, stop.
#[tokio::test]
async fn test_video_playback_ended_last_episode_stops() {