Fix sleep bug, fix menu return
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m1s
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Successful in 4m7s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 19m5s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 19m12s
Build & Release / Create Release (push) Successful in 8s

This commit is contained in:
2026-07-01 23:49:51 +02:00
parent 342f95cac1
commit 75014ee00f
22 changed files with 880 additions and 149 deletions
+317 -10
View File
@@ -165,6 +165,16 @@ impl PlayerController {
self.jellyfin_client.clone()
}
/// Configure the media repository used for next-episode lookups.
///
/// The Android ExoPlayer ended-callback calls `on_playback_ended` with no
/// repository handle (unlike the Linux HTML5 path, which passes one per
/// call), so the controller needs a repository of its own or episode
/// autoplay silently decides Stop.
pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
*self.repository.lock_safe() = Some(repo);
}
/// Configure the playback reporter for dual sync (local DB + server).
/// Called from `player_configure_jellyfin` on login/restore/reauth.
pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
@@ -707,6 +717,10 @@ impl PlayerController {
mode: SleepTimerMode::Off,
remaining_seconds: 0,
});
// Tell the frontend playback must stop: HTML5 video
// (Linux) plays outside the backend, so stopping the
// backend below doesn't reach it.
emitter.emit(PlayerStatusEvent::SleepTimerExpired);
}
drop(timer);
@@ -879,9 +893,17 @@ impl PlayerController {
let repo = self.repository.lock_safe().clone();
let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id);
let next_ep_result = if let Some(repo) = &repo {
self.fetch_next_episode_for_item(jellyfin_id, repo).await?
// Degrade lookup failures to Stop: playback already ended, and
// surfacing an error here just kills autoplay silently upstream.
match self.fetch_next_episode_for_item(jellyfin_id, repo).await {
Ok(next) => next,
Err(e) => {
warn!("[PlayerController] Next-episode lookup failed for {}: {}", jellyfin_id, e);
None
}
}
} else {
debug!("[PlayerController] No repository available for audio-path episode lookup");
warn!("[PlayerController] No repository available for episode lookup - cannot autoplay next episode");
None
};
if let Some(next_ep) = next_ep_result {
@@ -936,7 +958,7 @@ impl PlayerController {
debug!("[PlayerController] Cleared stale end_reason for video: {:?}", stale_reason);
}
debug!("[PlayerController] on_video_playback_ended: item_id={}", item_id);
log::info!("[PlayerController] on_video_playback_ended: item_id={}", item_id);
// Check sleep timer state
let timer_mode = {
@@ -969,8 +991,17 @@ impl PlayerController {
_ => {}
}
// Fetch next episode for the video that just ended
if let Some(next_ep) = self.fetch_next_episode_for_item(item_id, &repo).await? {
// Fetch next episode for the video that just ended. Degrade lookup
// failures to Stop: playback already ended, and propagating an error
// here just kills autoplay silently upstream.
let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
Ok(next) => next,
Err(e) => {
warn!("[PlayerController] Next-episode lookup failed for {}: {}", item_id, e);
None
}
};
if let Some(next_ep) = next_ep_result {
let settings = self.autoplay_settings.lock_safe().clone();
let limit_reached = self.increment_autoplay_count();
@@ -1020,7 +1051,7 @@ impl PlayerController {
let season_id = match &current_repo_item.season_id {
Some(sid) => sid.clone(),
None => {
debug!("[PlayerController] Current item has no season_id, cannot find next episode");
log::info!("[PlayerController] Current item has no season_id, cannot find next episode");
return Ok(None);
}
};
@@ -1042,19 +1073,19 @@ impl PlayerController {
// (offline repo ignores sort_by and sorts by sort_name instead)
let mut episodes = result.items;
episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
debug!("[PlayerController] Season has {} episodes, looking for next after {}", episodes.len(), current_repo_item.id);
log::info!("[PlayerController] Season has {} episodes, looking for next after {}", episodes.len(), current_repo_item.id);
// Find the current episode by ID and return the next one
if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
if current_idx + 1 < episodes.len() {
let next = &episodes[current_idx + 1];
debug!("[PlayerController] Found next episode: {} (index {})", next.name, current_idx + 1);
log::info!("[PlayerController] Found next episode: {} (index {})", next.name, current_idx + 1);
return Ok(Some((current_repo_item, next.clone())));
} else {
debug!("[PlayerController] Current episode is the last in the season");
log::info!("[PlayerController] Current episode is the last in the season");
}
} else {
debug!("[PlayerController] Current episode not found in season episodes");
log::info!("[PlayerController] Current episode not found in season episodes (ids: {:?})", episodes.iter().map(|e| e.id.as_str()).take(20).collect::<Vec<_>>());
}
Ok(None)
@@ -1766,4 +1797,280 @@ mod tests {
let reason = controller.take_end_reason();
assert!(reason.is_none(), "take_end_reason should clear the state");
}
// ===== Next-episode autoplay decision tests =====
use crate::repository::types as repo_types;
/// Mock repository serving a single season of episodes for next-episode
/// 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>,
}
impl MockEpisodeRepo {
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
})
.collect();
Self { episodes }
}
}
fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
repo_types::MediaItem {
id: id.to_string(),
name: format!("Episode {}", index),
item_type: "Episode".to_string(),
is_folder: false,
server_id: "server".to_string(),
parent_id: Some("season1".to_string()),
library_id: None,
overview: None,
genres: None,
runtime_ticks: None,
production_year: None,
premiere_date: None,
community_rating: None,
official_rating: None,
primary_image_tag: None,
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: None,
album_name: None,
album_artist: None,
artists: None,
artist_items: None,
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),
user_data: None,
media_streams: None,
media_sources: None,
people: None,
}
}
#[async_trait::async_trait]
impl crate::repository::MediaRepository for MockEpisodeRepo {
async fn get_libraries(&self) -> Result<Vec<repo_types::Library>, repo_types::RepoError> {
unimplemented!()
}
async fn get_items(
&self,
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");
Ok(repo_types::SearchResult {
items: self.episodes.clone(),
total_record_count: self.episodes.len(),
})
}
async fn get_item(&self, item_id: &str) -> Result<repo_types::MediaItem, repo_types::RepoError> {
self.episodes
.iter()
.find(|e| e.id == item_id)
.cloned()
.ok_or(repo_types::RepoError::NotFound {
message: format!("{} not found", item_id),
})
}
async fn get_latest_items(&self, _: &str, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_resume_items(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_next_up_episodes(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_recently_played_audio(&self, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_rediscover_albums(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_resume_movies(&self, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_genres(&self, _: Option<&str>) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
unimplemented!()
}
async fn search(&self, _: &str, _: Option<repo_types::SearchOptions>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn get_playback_info(&self, _: &str) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
unimplemented!()
}
async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
unimplemented!()
}
async fn get_live_tv_channels(&self) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn open_live_stream(&self, _: &str) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
unimplemented!()
}
async fn report_playback_start(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn report_playback_progress(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn report_playback_stopped(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
fn get_image_url(&self, _: &str, _: repo_types::ImageType, _: Option<repo_types::ImageOptions>) -> String {
unimplemented!()
}
fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
unimplemented!()
}
fn get_video_download_url(&self, _: &str, _: &str, _: Option<&str>) -> String {
unimplemented!()
}
async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn get_person(&self, _: &str) -> Result<repo_types::MediaItem, repo_types::RepoError> {
unimplemented!()
}
async fn get_items_by_person(&self, _: &str, _: Option<repo_types::GetItemsOptions>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn get_similar_items(&self, _: &str, _: Option<usize>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn create_playlist(&self, _: &str, _: &[String]) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
unimplemented!()
}
async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn get_playlist_items(&self, _: &str) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
unimplemented!()
}
async fn add_to_playlist(&self, _: &str, _: &[String]) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn remove_from_playlist(&self, _: &str, _: &[String]) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn move_playlist_item(&self, _: &str, _: &str, _: u32) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
}
/// Video (HTML5/Linux) path: ending mid-season must produce the
/// next-episode popup with auto-advance.
#[tokio::test]
async fn test_video_playback_ended_offers_next_episode() {
let controller = PlayerController::default();
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
let decision = controller
.on_video_playback_ended("ep2", repo)
.await
.expect("decision should succeed");
match decision {
AutoplayDecision::ShowNextEpisodePopup {
current_episode,
next_episode,
auto_advance,
..
} => {
assert_eq!(current_episode.id, "ep2");
assert_eq!(next_episode.id, "ep3");
assert!(auto_advance, "default settings should auto-advance");
}
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
}
}
/// Last episode of the season: no popup, stop.
#[tokio::test]
async fn test_video_playback_ended_last_episode_stops() {
let controller = PlayerController::default();
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
let decision = controller
.on_video_playback_ended("ep3", repo)
.await
.expect("decision should succeed");
assert!(matches!(decision, AutoplayDecision::Stop));
}
/// Android/ExoPlayer path: `on_playback_ended` has no per-call repository,
/// so the controller-level repository (wired up in `repository_create`)
/// must be used for the next-episode lookup. Regression test for episode
/// autoplay never triggering on Android because no repository was set.
#[tokio::test]
async fn test_playback_ended_uses_controller_repository_for_episodes() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
// Queue holds the episode that just finished playing
let episode = MediaItem {
media_type: MediaType::Video,
source: MediaSource::Remote {
stream_url: "http://example.com/ep1.mkv".to_string(),
jellyfin_item_id: "ep1".to_string(),
},
..create_test_items(1).remove(0)
};
controller.play_queue(vec![episode], 0).unwrap();
// Clear the NewTrackLoaded reason to simulate natural track end
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
match decision {
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
assert_eq!(next_episode.id, "ep2");
}
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
}
}
/// Without a controller repository the Android episode path must still
/// stop gracefully (previous behavior) rather than error.
#[tokio::test]
async fn test_playback_ended_without_repository_stops() {
let controller = PlayerController::default();
let episode = MediaItem {
media_type: MediaType::Video,
source: MediaSource::Remote {
stream_url: "http://example.com/ep1.mkv".to_string(),
jellyfin_item_id: "ep1".to_string(),
},
..create_test_items(1).remove(0)
};
controller.play_queue(vec![episode], 0).unwrap();
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
assert!(matches!(decision, AutoplayDecision::Stop));
}
}