From 75014ee00f67024946afeb06209c97dc82207fcc Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Wed, 1 Jul 2026 23:49:51 +0200 Subject: [PATCH] Fix sleep bug, fix menu return --- src-tauri/src/commands/download/mod.rs | 2 +- src-tauri/src/commands/player/mod.rs | 232 ++++++++++++- src-tauri/src/commands/player/queue.rs | 6 + src-tauri/src/commands/player/timers.rs | 10 +- src-tauri/src/commands/repository.rs | 9 + src-tauri/src/player/events.rs | 5 + src-tauri/src/player/mod.rs | 327 ++++++++++++++++++- src-tauri/src/player/queue.rs | 8 + src-tauri/src/repository/hybrid.rs | 1 + src-tauri/src/repository/offline.rs | 72 ++-- src-tauri/src/repository/online.rs | 9 +- src-tauri/src/repository/types.rs | 7 + src/lib/api/bindings.ts | 21 +- src/lib/components/BottomNav.svelte | 3 +- src/lib/components/player/VideoPlayer.svelte | 17 +- src/lib/services/playerEvents.ts | 8 +- src/lib/stores/movies.ts | 45 ++- src/lib/stores/music.ts | 49 ++- src/lib/stores/sleepTimer.ts | 8 + src/lib/stores/tv.ts | 53 ++- src/lib/utils/heroMix.test.ts | 78 +++++ src/lib/utils/heroMix.ts | 59 ++++ 22 files changed, 880 insertions(+), 149 deletions(-) create mode 100644 src/lib/utils/heroMix.test.ts create mode 100644 src/lib/utils/heroMix.ts diff --git a/src-tauri/src/commands/download/mod.rs b/src-tauri/src/commands/download/mod.rs index 5684d8b..5bf3cbb 100644 --- a/src-tauri/src/commands/download/mod.rs +++ b/src-tauri/src/commands/download/mod.rs @@ -1087,7 +1087,7 @@ pub async fn enqueue_video_downloads( /// (FIFO within a priority), registers each, flips it to `downloading`, and /// spawns a worker. Each spawned worker calls this again on completion/failure, /// so the queue drains itself without any frontend involvement. -async fn pump_download_queue( +pub(crate) async fn pump_download_queue( app: tauri::AppHandle, db_service: Arc, active_downloads: Arc>>, diff --git a/src-tauri/src/commands/player/mod.rs b/src-tauri/src/commands/player/mod.rs index 5296ae7..9ea3c7a 100644 --- a/src-tauri/src/commands/player/mod.rs +++ b/src-tauri/src/commands/player/mod.rs @@ -378,6 +378,65 @@ pub(super) async fn check_for_local_download( } } +/// Re-point queued streaming items at completed local downloads. +/// +/// Sources are resolved once when the queue is built, so downloads that finish +/// while it plays (preloaded upcoming tracks) — or that existed before the +/// connection dropped — would otherwise keep streaming. Called before advancing +/// so the next track always prefers the on-disk copy. +/// +/// Returns the number of items switched to a local source. +pub(super) async fn refresh_queue_local_sources( + controller: &PlayerController, + db: &DatabaseWrapper, +) -> Result { + // Collect remote item IDs first; the queue lock must not be held across awaits. + let remote_ids: Vec = { + let queue = controller.queue(); + let queue_lock = queue.lock().map_err(|e| e.to_string())?; + queue_lock + .items() + .iter() + .filter_map(|item| match &item.source { + MediaSource::Remote { jellyfin_item_id, .. } => Some(jellyfin_item_id.clone()), + _ => None, + }) + .collect() + }; + + if remote_ids.is_empty() { + return Ok(0); + } + + let mut local_paths: Vec<(String, String)> = Vec::new(); + for id in remote_ids { + if let Some(path) = check_for_local_download(db, &id).await? { + local_paths.push((id, path)); + } + } + + if local_paths.is_empty() { + return Ok(0); + } + + let queue = controller.queue(); + let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; + let mut switched = 0; + for item in queue_lock.items_mut() { + if let MediaSource::Remote { jellyfin_item_id, .. } = &item.source { + if let Some((id, path)) = local_paths.iter().find(|(id, _)| id == jellyfin_item_id) { + info!("[Player] Switching queued track {} to local download: {}", id, path); + item.source = MediaSource::Local { + file_path: PathBuf::from(path), + jellyfin_item_id: Some(id.clone()), + }; + switched += 1; + } + } + } + Ok(switched) +} + /// Play a single media item (audio or video) /// /// Accepts a PlayItemRequest with all optional fields properly defaulted. @@ -679,6 +738,7 @@ pub async fn player_next( player: State<'_, PlayerStateWrapper>, session: State<'_, MediaSessionManagerWrapper>, playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>, + db: State<'_, DatabaseWrapper>, ) -> Result { debug!("[player_next] Command called from frontend"); @@ -697,6 +757,10 @@ pub async fn player_next( } else { // Local playback let controller = player.0.lock().await; + // Prefer downloads that completed since the queue was built + if let Err(e) = refresh_queue_local_sources(&controller, &db).await { + warn!("[player_next] Failed to refresh local sources: {}", e); + } controller.next().map_err(|e| e.to_string())?; controller.emit_queue_changed(); @@ -732,6 +796,7 @@ pub async fn player_previous( player: State<'_, PlayerStateWrapper>, session: State<'_, MediaSessionManagerWrapper>, playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>, + db: State<'_, DatabaseWrapper>, ) -> Result { // Check if we're in remote mode let mode = playback_mode.0.get_mode(); @@ -748,6 +813,10 @@ pub async fn player_previous( } else { // Local playback let controller = player.0.lock().await; + // Prefer downloads that completed since the queue was built + if let Err(e) = refresh_queue_local_sources(&controller, &db).await { + warn!("[player_previous] Failed to refresh local sources: {}", e); + } controller.previous().map_err(|e| e.to_string())?; controller.emit_queue_changed(); @@ -1669,12 +1738,23 @@ pub async fn player_preload_upcoming( player: State<'_, PlayerStateWrapper>, db: State<'_, DatabaseWrapper>, smart_cache: State<'_, SmartCacheWrapper>, + download_manager: State<'_, crate::commands::download::DownloadManagerWrapper>, + app: tauri::AppHandle, user_id: String, _download_base_path: String, ) -> Result { - let db_service = { + // The pump only starts rows that carry both a stream URL and a target dir, + // so resolve the same storage root the user-initiated download paths use + // (storage_get_path = the database's parent directory). + let (db_service, target_dir) = { let database = db.0.lock().map_err(|e| e.to_string())?; - Arc::new(database.service()) + let target_dir = database + .path() + .parent() + .ok_or_else(|| "Database path has no parent directory".to_string())? + .to_string_lossy() + .to_string(); + (Arc::new(database.service()), target_dir) }; // Get cache settings @@ -1712,10 +1792,12 @@ pub async fn player_preload_upcoming( // Process each upcoming item for item in upcoming_items { - // Only process items with Remote source (not already local) - let jellyfin_id = match &item.source { - MediaSource::Remote { jellyfin_item_id, .. } => { - jellyfin_item_id.clone() + // Only process items with Remote source (not already local). The + // source already carries the resolved stream URL — reuse it so the + // pump can start the download without any extra resolution step. + let (jellyfin_id, stream_url) = match &item.source { + MediaSource::Remote { jellyfin_item_id, stream_url } => { + (jellyfin_item_id.clone(), stream_url.clone()) } MediaSource::Local { .. } => { already_downloaded += 1; @@ -1727,9 +1809,13 @@ pub async fn player_preload_upcoming( } }; - // Check if already downloaded + // Check if already downloaded or actively in flight. Stale pending rows + // without a stream URL are NOT skipped here — the upsert below heals + // them so the pump can finally start them. let query = Query::with_params( - "SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ? AND status IN ('completed', 'downloading', 'pending') LIMIT 1", + "SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ? + AND (status IN ('completed', 'downloading') + OR (status = 'pending' AND stream_url IS NOT NULL)) LIMIT 1", vec![ QueryParam::String(jellyfin_id.clone()), QueryParam::String(user_id.clone()), @@ -1746,14 +1832,29 @@ pub async fn player_preload_upcoming( continue; } - // Queue for download with low priority (preload priority = -100) - let file_path = format!("{}/{}.mp3", sanitize_filename(&item.album.clone().unwrap_or_default()), sanitize_filename(&item.title)); + // Queue for download with low priority (preload priority = -100) so + // user-initiated downloads always win a pump slot first. + let album_dir = item + .album + .as_deref() + .filter(|a| !a.is_empty()) + .unwrap_or("Unknown Album"); + let file_path = format!( + "downloads/{}/{}.mp3", + sanitize_filename(album_dir), + sanitize_filename(&item.title) + ); - // Insert download record with preload priority + // Insert with the stream URL + target dir the pump needs to start it. + // On conflict, heal pre-existing rows that were queued without a URL + // (they could never start) instead of leaving them stuck. let insert_query = Query::with_params( - "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name) - VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?) - ON CONFLICT(item_id, user_id) DO NOTHING", + "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, download_source, media_type, stream_url, target_dir) + VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?, 'auto', 'audio', ?, ?) + ON CONFLICT(item_id, user_id) DO UPDATE SET + stream_url = excluded.stream_url, + target_dir = excluded.target_dir + WHERE downloads.status = 'pending' AND downloads.stream_url IS NULL", vec![ QueryParam::String(jellyfin_id), QueryParam::String(user_id.clone()), @@ -1761,6 +1862,8 @@ pub async fn player_preload_upcoming( QueryParam::String(item.title.clone()), item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null), item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null), + QueryParam::String(stream_url), + QueryParam::String(target_dir.clone()), ], ); @@ -1780,6 +1883,16 @@ pub async fn player_preload_upcoming( } } + // Kick the pump so the queued preloads actually start; without this they'd + // only begin once some other download activity pumps the queue. + if queued_count > 0 { + let active_downloads = { + let manager = download_manager.0.lock().map_err(|e| e.to_string())?; + manager.get_active_downloads() + }; + crate::commands::download::pump_download_queue(app, db_service, active_downloads).await; + } + info!("[Preload] Result: queued={}, already_downloaded={}, skipped={}", queued_count, already_downloaded, skipped); Ok(PreloadResult { @@ -1886,6 +1999,97 @@ pub async fn player_disable_jellyfin( #[cfg(test)] mod tests { + /// Queue items enqueued as Remote must flip to Local once a completed + /// download exists on disk — this is what makes preloaded tracks (and + /// offline playback after a connection drop) actually use the cache. + #[tokio::test] + async fn test_refresh_queue_local_sources_switches_completed_downloads() { + use super::{refresh_queue_local_sources, DatabaseWrapper}; + use crate::player::{MediaItem, MediaSource, MediaType, PlayerController}; + use crate::storage::Database; + use std::sync::Mutex; + + // A real file on disk for the completed download; a missing file for + // the second entry to prove nonexistent files are not switched. + let dir = std::env::temp_dir().join("jellytau-test-refresh-sources"); + std::fs::create_dir_all(&dir).unwrap(); + let existing = dir.join("track-a.mp3"); + std::fs::write(&existing, b"audio").unwrap(); + let missing = dir.join("track-b-missing.mp3"); + let _ = std::fs::remove_file(&missing); + + let database = Database::open_in_memory().unwrap(); + { + let conn = database.connection(); + let conn = conn.lock().unwrap(); + conn.execute_batch(&format!( + r#" + INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test'); + INSERT INTO users (id, server_id, username) VALUES ('user1', 'srv', 'tester'); + INSERT INTO downloads (item_id, user_id, file_path, status) + VALUES ('track-a', 'user1', '{}', 'completed'); + INSERT INTO downloads (item_id, user_id, file_path, status) + VALUES ('track-b', 'user1', '{}', 'completed'); + "#, + existing.display(), + missing.display() + )) + .unwrap(); + } + let db = DatabaseWrapper(Mutex::new(database)); + + let make_item = |id: &str| MediaItem { + id: id.to_string(), + title: id.to_string(), + name: None, + artist: None, + album: None, + album_name: None, + album_id: None, + artist_items: None, + artists: None, + primary_image_tag: None, + item_type: None, + playlist_id: None, + duration: None, + artwork_url: None, + media_type: MediaType::Audio, + source: MediaSource::Remote { + stream_url: format!("http://test/Audio/{}/stream", id), + jellyfin_item_id: id.to_string(), + }, + video_codec: None, + needs_transcoding: false, + video_width: None, + video_height: None, + subtitles: vec![], + series_id: None, + server_id: None, + }; + + let controller = PlayerController::default(); + controller + .set_queue(vec![make_item("track-a"), make_item("track-b")], 0) + .unwrap(); + + let switched = refresh_queue_local_sources(&controller, &db).await.unwrap(); + assert_eq!(switched, 1, "only the download whose file exists switches"); + + let queue = controller.queue(); + let queue_lock = queue.lock().unwrap(); + match &queue_lock.items()[0].source { + MediaSource::Local { file_path, jellyfin_item_id } => { + assert_eq!(file_path, &existing); + assert_eq!(jellyfin_item_id.as_deref(), Some("track-a")); + } + other => panic!("track-a should be local, got {:?}", other), + } + assert!( + matches!(queue_lock.items()[1].source, MediaSource::Remote { .. }), + "track-b's file is missing, it must stay remote" + ); + } + /// Test track index finding in album /// This reproduces the bug where clicking songs 1-5 always played song 13 #[test] diff --git a/src-tauri/src/commands/player/queue.rs b/src-tauri/src/commands/player/queue.rs index 8665a09..c5e36a8 100644 --- a/src-tauri/src/commands/player/queue.rs +++ b/src-tauri/src/commands/player/queue.rs @@ -347,10 +347,16 @@ pub async fn player_add_tracks_by_ids( #[specta::specta] pub async fn player_skip_to( player: State<'_, PlayerStateWrapper>, + db: State<'_, DatabaseWrapper>, index: usize, ) -> Result { let controller = player.0.lock().await; + // Prefer downloads that completed since the queue was built + if let Err(e) = super::refresh_queue_local_sources(&controller, &db).await { + log::warn!("[player_skip_to] Failed to refresh local sources: {}", e); + } + // Skip to the index and get the item to play let item = { let queue = controller.queue(); diff --git a/src-tauri/src/commands/player/timers.rs b/src-tauri/src/commands/player/timers.rs index f859a11..b713e6b 100644 --- a/src-tauri/src/commands/player/timers.rs +++ b/src-tauri/src/commands/player/timers.rs @@ -142,6 +142,7 @@ pub async fn player_play_next_episode( pub async fn player_on_playback_ended( player: State<'_, PlayerStateWrapper>, repository_manager: State<'_, crate::commands::repository::RepositoryManagerWrapper>, + db: State<'_, DatabaseWrapper>, item_id: Option, repository_handle: Option, ) -> Result<(), String> { @@ -174,7 +175,7 @@ pub async fn player_on_playback_ended( // Handle the decision match decision { AutoplayDecision::Stop => { - log::debug!("[Autoplay] Decision: Stop playback"); + log::info!("[Autoplay] Decision: Stop playback"); let controller = controller_arc.lock().await; // Clear the queue so the frontend's currentQueueItem becomes null and // the mini player hides. Without this, the queue still holds the last @@ -193,9 +194,14 @@ pub async fn player_on_playback_ended( } } AutoplayDecision::AdvanceToNext => { - log::debug!("[Autoplay] Decision: Advance to next track"); + log::info!("[Autoplay] Decision: Advance to next track"); // Advance to next track in queue let controller = controller_arc.lock().await; + // Prefer downloads that completed since the queue was built (e.g. + // preloaded upcoming tracks) over continuing to stream. + if let Err(e) = super::refresh_queue_local_sources(&controller, &db).await { + log::warn!("[Autoplay] Failed to refresh local sources: {}", e); + } if let Err(e) = controller.next() { log::error!("[Autoplay] Failed to advance to next track: {}", e); // Emit PlaybackEnded event on error diff --git a/src-tauri/src/commands/repository.rs b/src-tauri/src/commands/repository.rs index fd1bbf2..394b1b1 100644 --- a/src-tauri/src/commands/repository.rs +++ b/src-tauri/src/commands/repository.rs @@ -52,6 +52,7 @@ pub struct RepositoryManagerWrapper(pub RepositoryManager); #[specta::specta] pub async fn repository_create( manager: State<'_, RepositoryManagerWrapper>, + player: State<'_, crate::commands::player::PlayerStateWrapper>, db: State<'_, crate::commands::storage::DatabaseWrapper>, connectivity: State<'_, crate::commands::connectivity::ConnectivityMonitorWrapper>, server_url: String, @@ -115,6 +116,14 @@ pub async fn repository_create( manager.0.create(handle.clone(), hybrid); info!("[REPO] Repository stored successfully"); + // Give the player controller a repository for next-episode lookups. The + // Android playback-ended callback has no repository handle, so without + // this the episode autoplay countdown never triggers there. + if let Some(repo) = manager.0.get(&handle) { + let controller = player.0.lock().await; + controller.set_repository(repo); + } + Ok(handle) } diff --git a/src-tauri/src/player/events.rs b/src-tauri/src/player/events.rs index e6205db..4ad95bc 100644 --- a/src-tauri/src/player/events.rs +++ b/src-tauri/src/player/events.rs @@ -78,6 +78,11 @@ pub enum PlayerStatusEvent { /// Remaining seconds (for time-based timer) remaining_seconds: u32, }, + /// Time-based sleep timer expired: playback must stop. The backend stops + /// its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the + /// webview outside the backend's control — the frontend pauses it on this + /// event. + SleepTimerExpired, /// Show next episode popup with countdown ShowNextEpisodePopup { /// Current episode that just finished diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index 67ed0ff..5ee7068 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -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) { + *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) { @@ -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(¤t.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 ¤t_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::>()); } 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, + } + + 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, repo_types::RepoError> { + unimplemented!() + } + async fn get_items( + &self, + parent_id: &str, + _options: Option, + ) -> Result { + 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 { + 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) -> Result, repo_types::RepoError> { + unimplemented!() + } + async fn get_resume_items(&self, _: Option<&str>, _: Option) -> Result, repo_types::RepoError> { + unimplemented!() + } + async fn get_next_up_episodes(&self, _: Option<&str>, _: Option) -> Result, repo_types::RepoError> { + unimplemented!() + } + async fn get_recently_played_audio(&self, _: Option) -> Result, repo_types::RepoError> { + unimplemented!() + } + async fn get_rediscover_albums(&self, _: Option<&str>, _: Option) -> Result, repo_types::RepoError> { + unimplemented!() + } + async fn get_resume_movies(&self, _: Option) -> Result, repo_types::RepoError> { + unimplemented!() + } + async fn get_genres(&self, _: Option<&str>) -> Result, repo_types::RepoError> { + unimplemented!() + } + async fn search(&self, _: &str, _: Option) -> Result { + unimplemented!() + } + async fn get_playback_info(&self, _: &str) -> Result { + unimplemented!() + } + async fn get_audio_stream_url(&self, _: &str) -> Result { + unimplemented!() + } + async fn get_live_tv_channels(&self) -> Result, repo_types::RepoError> { + unimplemented!() + } + async fn get_channels(&self) -> Result { + unimplemented!() + } + async fn open_live_stream(&self, _: &str) -> Result { + 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) -> 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 { + unimplemented!() + } + async fn get_items_by_person(&self, _: &str, _: Option) -> Result { + unimplemented!() + } + async fn get_similar_items(&self, _: &str, _: Option) -> Result { + unimplemented!() + } + async fn create_playlist(&self, _: &str, _: &[String]) -> Result { + 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, 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 = 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 = 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)); + } } diff --git a/src-tauri/src/player/queue.rs b/src-tauri/src/player/queue.rs index 3bc52b4..7283af8 100644 --- a/src-tauri/src/player/queue.rs +++ b/src-tauri/src/player/queue.rs @@ -62,6 +62,14 @@ impl QueueManager { &self.items } + /// Mutable access to queue items. + /// + /// Used to re-point streaming entries at a completed local download + /// without disturbing queue order, shuffle state, or the current index. + pub fn items_mut(&mut self) -> &mut [MediaItem] { + &mut self.items + } + /// Get the current item index pub fn current_index(&self) -> Option { self.current_index diff --git a/src-tauri/src/repository/hybrid.rs b/src-tauri/src/repository/hybrid.rs index 3037f3b..ad1e3b8 100644 --- a/src-tauri/src/repository/hybrid.rs +++ b/src-tauri/src/repository/hybrid.rs @@ -1029,6 +1029,7 @@ mod tests { genres: Some(vec!["Action".to_string(), "Adventure".to_string()]), runtime_ticks: Some(7200000000), production_year: Some(2024), + premiere_date: None, community_rating: Some(8.5), official_rating: Some("PG-13".to_string()), primary_image_tag: Some("image-tag-123".to_string()), diff --git a/src-tauri/src/repository/offline.rs b/src-tauri/src/repository/offline.rs index 0b97119..f0eb33a 100644 --- a/src-tauri/src/repository/offline.rs +++ b/src-tauri/src/repository/offline.rs @@ -39,6 +39,7 @@ impl OfflineRepository { .and_then(|s| serde_json::from_str::>(s).ok()), runtime_ticks: item.runtime_ticks, production_year: item.production_year, + premiere_date: item.premiere_date, community_rating: item.community_rating, official_rating: item.official_rating, primary_image_tag: item.primary_image_tag, @@ -101,6 +102,7 @@ struct CachedItem { genres: Option, runtime_ticks: Option, production_year: Option, + premiere_date: Option, community_rating: Option, official_rating: Option, primary_image_tag: Option, @@ -145,8 +147,9 @@ fn row_to_cached_item(row: &rusqlite::Row) -> rusqlite::Result { season_id: row.get(20)?, season_name: row.get(21)?, parent_index_number: row.get(22)?, - // Appended as the final column in every SELECT that maps through this fn. + // Appended as the final columns in every SELECT that maps through this fn. is_folder: row.get::<_, Option>(23)?.unwrap_or(0) != 0, + premiere_date: row.get(24)?, }) } @@ -238,7 +241,7 @@ impl OfflineRepository { genres, series_id, series_name, season_id, season_name, index_number, parent_index_number, album_id, album_name, album_artist, artists, - production_year, runtime_ticks, + production_year, premiere_date, runtime_ticks, primary_image_tag, backdrop_image_tags, community_rating, official_rating, synced_at @@ -248,10 +251,10 @@ impl OfflineRepository { ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, - ?20, ?21, - ?22, ?23, - ?24, ?25, - ?26 + ?20, ?21, ?22, + ?23, ?24, + ?25, ?26, + ?27 )", vec![ QueryParam::String(item.id.clone()), @@ -318,6 +321,10 @@ impl OfflineRepository { Some(y) => QueryParam::Int(y), None => QueryParam::Null, }, + match &item.premiere_date { + Some(d) => QueryParam::String(d.clone()), + None => QueryParam::Null, + }, match item.runtime_ticks { Some(r) => QueryParam::Int64(r), None => QueryParam::Null, @@ -446,6 +453,13 @@ impl MediaRepository for OfflineRepository { let limit = opts.limit.unwrap_or(10000); // Match frontend limit for full library loading let start_index = opts.start_index.unwrap_or(0); + // SortBy=Random is the only sort the landing pages rely on offline (the + // hero "surprise" pool); everything else keeps the stable name order. + let order_by = match opts.sort_by.as_deref() { + Some("Random") => "RANDOM()", + _ => "i.sort_name ASC, i.name ASC", + }; + // Build type filter for optional filtering let type_filter = if let Some(include_item_types) = &opts.include_item_types { if !include_item_types.is_empty() { @@ -496,13 +510,13 @@ impl MediaRepository for OfflineRepository { i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, - i.parent_index_number, i.is_folder - FROM items i + i.parent_index_number, i.is_folder, i.premiere_date + FROM items i INNER JOIN available_items ai ON i.id = ai.id WHERE i.server_id = ? AND i.parent_id = ?{} - ORDER BY i.sort_name ASC, i.name ASC + ORDER BY {} LIMIT {} OFFSET {}", - type_filter, limit, start_index + type_filter, order_by, limit, start_index ); let query = Query::with_params( @@ -561,8 +575,8 @@ impl MediaRepository for OfflineRepository { i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, - i.parent_index_number, i.is_folder - FROM items i + i.parent_index_number, i.is_folder, i.premiere_date + FROM items i INNER JOIN downloaded_items di ON i.id = di.id WHERE i.id = ?", vec![QueryParam::String(item_id.to_string())], @@ -606,8 +620,8 @@ impl MediaRepository for OfflineRepository { i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, - i.parent_index_number, i.is_folder - FROM items i + i.parent_index_number, i.is_folder, i.premiere_date + FROM items i INNER JOIN downloaded_items di ON i.id = di.id WHERE i.server_id = ? AND i.library_id = ? ORDER BY i.synced_at DESC @@ -644,8 +658,8 @@ impl MediaRepository for OfflineRepository { i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, - i.season_name, i.parent_index_number, i.is_folder - FROM items i + i.season_name, i.parent_index_number, i.is_folder, i.premiere_date + FROM items i JOIN user_data ud ON i.id = ud.item_id INNER JOIN downloads d ON i.id = d.item_id WHERE i.server_id = ? AND ud.user_id = ? AND i.library_id = ? @@ -669,8 +683,8 @@ impl MediaRepository for OfflineRepository { i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, - i.season_name, i.parent_index_number, i.is_folder - FROM items i + i.season_name, i.parent_index_number, i.is_folder, i.premiere_date + FROM items i JOIN user_data ud ON i.id = ud.item_id INNER JOIN downloads d ON i.id = d.item_id WHERE i.server_id = ? AND ud.user_id = ? @@ -757,8 +771,8 @@ impl MediaRepository for OfflineRepository { i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, - i.season_name, i.parent_index_number, i.is_folder - FROM ranked_plays rp + i.season_name, i.parent_index_number, i.is_folder, i.premiere_date + FROM ranked_plays rp JOIN items i ON rp.display_id = i.id INNER JOIN downloaded_items di ON i.id = di.id ORDER BY rp.most_recent_play DESC", @@ -805,8 +819,8 @@ impl MediaRepository for OfflineRepository { i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, - i.season_name, i.parent_index_number, i.is_folder - FROM items i + i.season_name, i.parent_index_number, i.is_folder, i.premiere_date + FROM items i JOIN user_data ud ON i.id = ud.item_id INNER JOIN downloads d ON i.id = d.item_id WHERE i.server_id = ? AND ud.user_id = ? AND i.item_type = 'Movie' @@ -939,8 +953,8 @@ impl MediaRepository for OfflineRepository { i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, - i.season_name, i.parent_index_number, i.is_folder - FROM items i + i.season_name, i.parent_index_number, i.is_folder, i.premiere_date + FROM items i JOIN items_fts fts ON fts.rowid = i.rowid INNER JOIN downloaded_items di ON i.id = di.id WHERE i.server_id = ? AND items_fts MATCH ?{} @@ -1099,6 +1113,7 @@ impl MediaRepository for OfflineRepository { genres: None, runtime_ticks: None, production_year: None, + premiere_date: None, community_rating: None, official_rating: None, primary_image_tag: person_data.3, @@ -1152,8 +1167,8 @@ impl MediaRepository for OfflineRepository { i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, - i.season_name, i.parent_index_number, i.is_folder - FROM items i + i.season_name, i.parent_index_number, i.is_folder, i.premiere_date + FROM items i JOIN item_people ip ON i.id = ip.item_id INNER JOIN downloaded_items di ON i.id = di.id WHERE i.server_id = ? AND ip.person_id = ? @@ -1263,7 +1278,7 @@ impl MediaRepository for OfflineRepository { i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \ i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \ i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \ - i.parent_index_number, i.is_folder \ + i.parent_index_number, i.is_folder, i.premiere_date \ FROM playlist_items pi \ JOIN items i ON pi.item_id = i.id \ WHERE pi.playlist_id = ? \ @@ -1302,6 +1317,7 @@ impl MediaRepository for OfflineRepository { season_name: row.get(22)?, parent_index_number: row.get(23)?, is_folder: row.get::<_, Option>(24)?.unwrap_or(0) != 0, + premiere_date: row.get(25)?, }; Ok((entry_id.to_string(), cached)) }) @@ -1473,6 +1489,7 @@ mod tests { genres TEXT, runtime_ticks INTEGER, production_year INTEGER, + premiere_date TEXT, community_rating REAL, official_rating TEXT, primary_image_tag TEXT, @@ -1549,6 +1566,7 @@ mod tests { genres: None, runtime_ticks: None, production_year: None, + premiere_date: None, community_rating: None, official_rating: None, primary_image_tag: None, diff --git a/src-tauri/src/repository/online.rs b/src-tauri/src/repository/online.rs index 4a71886..7f62a18 100644 --- a/src-tauri/src/repository/online.rs +++ b/src-tauri/src/repository/online.rs @@ -407,6 +407,7 @@ struct JellyfinItem { overview: Option, genres: Option>, production_year: Option, + premiere_date: Option, community_rating: Option, official_rating: Option, run_time_ticks: Option, @@ -497,6 +498,7 @@ impl JellyfinItem { overview: self.overview, genres: self.genres, production_year: self.production_year, + premiere_date: self.premiere_date, community_rating: self.community_rating, official_rating: self.official_rating, runtime_ticks: self.run_time_ticks, @@ -624,7 +626,7 @@ impl MediaRepository for OnlineRepository { // Request image fields for list views (People only needed in get_item // detail view). Genres is needed so cached items carry their genres, // which lets the offline store derive genre lists + per-genre counts. - endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres"); + endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate"); let response: ItemsResponse = self.get_json(&endpoint).await?; @@ -639,7 +641,7 @@ impl MediaRepository for OnlineRepository { } async fn get_item(&self, item_id: &str) -> Result { - let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources", self.user_id, item_id); + let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate", self.user_id, item_id); let item: JellyfinItem = self.get_json(&endpoint).await?; let media_item = item.to_media_item(self.user_id.clone()); @@ -776,6 +778,7 @@ impl MediaRepository for OnlineRepository { overview: None, genres: None, production_year: None, + premiere_date: None, community_rating: None, official_rating: None, runtime_ticks: None, @@ -943,7 +946,7 @@ impl MediaRepository for OnlineRepository { // Request image fields for list views (plus Genres so cached items // carry genres for offline genre lists/counts). - endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres"); + endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate"); let response: ItemsResponse = self.get_json(&endpoint).await?; Ok(SearchResult { diff --git a/src-tauri/src/repository/types.rs b/src-tauri/src/repository/types.rs index 0f3e572..69a2fbd 100644 --- a/src-tauri/src/repository/types.rs +++ b/src-tauri/src/repository/types.rs @@ -119,6 +119,10 @@ pub struct MediaItem { pub genres: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub production_year: Option, + /// ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort + /// podcast episodes by release date. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub premiere_date: Option, #[serde(skip_serializing_if = "Option::is_none")] pub community_rating: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -506,6 +510,7 @@ mod tests { overview: None, genres: None, production_year: None, + premiere_date: None, community_rating: None, official_rating: None, runtime_ticks: None, @@ -646,6 +651,7 @@ mod tests { overview: None, genres: None, production_year: None, + premiere_date: None, community_rating: None, official_rating: None, runtime_ticks: None, @@ -706,6 +712,7 @@ mod tests { overview: None, genres: None, production_year: None, + premiere_date: None, community_rating: None, official_rating: None, runtime_ticks: None, diff --git a/src/lib/api/bindings.ts b/src/lib/api/bindings.ts index 0b6fe47..e68249f 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -1596,7 +1596,12 @@ export type MediaItem = { id: string; name: string; type: string; * Whether this item is a folder/container (vs a playable leaf). Used to * decide whether a channel item drills into a list or plays directly. */ -isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null } +isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; +/** + * ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort + * podcast episodes by release date. + */ +premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null } /** * Media session type tracking the high-level playback context */ @@ -1938,6 +1943,13 @@ export type PlayerStatusEvent = * Sleep timer state changed */ { type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number } | +/** + * Time-based sleep timer expired: playback must stop. The backend stops + * its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the + * webview outside the backend's control — the frontend pauses it on this + * event. + */ +{ type: "sleep_timer_expired" } | /** * Show next episode popup with countdown */ @@ -1987,7 +1999,12 @@ export type PlaylistEntry = * Whether this item is a folder/container (vs a playable leaf). Used to * decide whether a channel item drills into a list or plays directly. */ -isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & { +isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; +/** + * ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort + * podcast episodes by release date. + */ +premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & { /** * The playlist-scoped entry ID (Jellyfin's PlaylistItemId) */ diff --git a/src/lib/components/BottomNav.svelte b/src/lib/components/BottomNav.svelte index 5f05fdc..42b96cc 100644 --- a/src/lib/components/BottomNav.svelte +++ b/src/lib/components/BottomNav.svelte @@ -2,6 +2,7 @@