//! TRACES: UR-003, UR-004, UR-005, UR-010, UR-020, UR-021 | JA-022, JA-023, JA-024, JA-025, JA-026 | DR-001 // Cohesive command clusters live in their own submodules and are re-exported so // the command names remain at `commands::player::*` (invoke_handler unchanged). mod queue; mod remote; mod session; mod settings; mod timers; pub use queue::*; pub use remote::*; pub use session::*; pub use settings::*; pub use timers::*; use crate::utils::lock::MutexSafe; use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tokio::sync::Mutex as TokioMutex; use tauri::State; use super::DatabaseWrapper; use crate::download::cache::{CacheConfig, SmartCache}; use crate::jellyfin::{JellyfinClient, JellyfinConfig}; use crate::player::{ determine_video_seek_strategy, MediaItem, MediaSource, MediaType, MediaSessionManager, PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy, }; use crate::repository::{MediaRepository, types::{GetItemsOptions, ImageOptions, ImageType}}; use crate::settings::VideoSettings; use crate::storage::db_service::{DatabaseService, Query, QueryParam}; /// SmartCache wrapper for Tauri state management pub struct SmartCacheWrapper(pub Mutex); /// Player state wrapper for Tauri. /// /// Uses Arc to allow sharing with the MediaSession handler on Android /// for lockscreen control integration. /// /// @req: UR-005 - Control media playback /// @req: DR-001 - Player state machine pub struct PlayerStateWrapper(pub Arc>); /// Media session manager wrapper for Tauri state management /// /// @req: DR-009 - Audio player UI (mini player, full screen) pub struct MediaSessionManagerWrapper(pub Mutex); /// Video settings state wrapper for Tauri /// /// @req: DR-048 - Video settings (auto-play toggle, countdown duration) pub struct VideoSettingsWrapper(pub Mutex); /// Response for player state queries #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PlayerStatus { pub state: PlayerState, pub position: f64, pub duration: Option, pub volume: f32, pub muted: bool, pub shuffle: bool, pub repeat: RepeatMode, /// Backend being used (native = ExoPlayer/libmpv, html5 = fallback) pub backend: VideoBackend, /// Whether frontend should render HTML5 video element pub use_html5_element: bool, // Merged fields (prefer remote session when available) /// Media item from either local queue or remote session pub merged_media: Option, /// Playing state from either local player or remote session pub merged_is_playing: bool, /// Volume from either local player or remote session (0-1 normalized) pub merged_volume: f32, } /// Lightweight media item for merged playback state /// Converts from both local MediaItem and remote NowPlayingItem #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct MergedMediaItem { pub id: String, pub title: String, pub artist: Option, pub album: Option, pub album_id: Option, pub duration: Option, pub primary_image_tag: Option, pub media_type: String, } // Convert from local MediaItem impl From<&crate::player::MediaItem> for MergedMediaItem { fn from(item: &crate::player::MediaItem) -> Self { Self { id: item.id.clone(), title: item.title.clone(), artist: item.artist.clone(), album: item.album.clone(), album_id: item.album_id.clone(), duration: item.duration, primary_image_tag: item.primary_image_tag.clone(), media_type: match item.media_type { crate::player::MediaType::Audio => "audio".to_string(), crate::player::MediaType::Video => "video".to_string(), }, } } } // Convert from remote NowPlayingItem impl From<&crate::jellyfin::client::NowPlayingItem> for MergedMediaItem { fn from(item: &crate::jellyfin::client::NowPlayingItem) -> Self { Self { id: item.id.clone().unwrap_or_default(), title: item.name.clone().unwrap_or_else(|| "Unknown".to_string()), artist: item.album_artist.clone() .or_else(|| item.artists.as_ref().and_then(|a| a.first().cloned())), album: item.album.clone(), album_id: item.album_id.clone(), duration: item.run_time_ticks.map(|ticks| ticks as f64 / 10_000_000.0), primary_image_tag: item.primary_image_tag.clone(), media_type: item.item_type.clone() .unwrap_or_else(|| "audio".to_string()) .to_lowercase(), } } } /// Response for queue queries #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct QueueStatus { pub items: Vec, pub current_index: Option, pub shuffle: bool, pub repeat: RepeatMode, pub has_next: bool, pub has_previous: bool, } /// Backend type for video playback #[derive(Debug, Serialize)] #[serde(rename_all = "lowercase")] pub enum VideoBackend { /// Native backend (ExoPlayer on Android, libmpv on Linux) Native, /// HTML5 video element fallback Html5, } /// Request to play a single video item /// /// Simplified to video playback only. Audio playback uses player_play_tracks /// to avoid Tauri Android serialization issues with complex objects. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PlayItemRequest { pub id: String, pub title: String, pub stream_url: String, /// Video codec (e.g., "h264", "hevc") for video media pub video_codec: String, /// Whether the video requires server-side transcoding pub needs_transcoding: bool, } /// Queue context for remote transfer - what type of queue is this? #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] pub enum PlayQueueContext { /// Playing from a specific album Album { #[serde(rename = "albumId")] album_id: String, #[serde(rename = "albumName")] album_name: String, }, /// Playing from a specific playlist Playlist { #[serde(rename = "playlistId")] playlist_id: String, #[serde(rename = "playlistName")] playlist_name: String, }, /// Custom queue (search results, manual queue, etc.) Custom, } /// Request to play a queue of items #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PlayQueueRequest { pub items: Vec, pub start_index: usize, pub shuffle: bool, /// Optional context for the queue (album, playlist, or custom) /// Used for remote playback transfer #[serde(default)] pub context: Option, } /// Request to play a track from an album (backend fetches all tracks) #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PlayAlbumTrackRequest { pub album_id: String, pub album_name: String, pub track_id: String, pub shuffle: bool, } /// Request to play tracks by ID (backend fetches metadata) #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PlayTracksRequest { pub track_ids: Vec, pub start_index: usize, pub shuffle: bool, pub context: PlayTracksContext, } /// Context information for track playback #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] pub enum PlayTracksContext { Playlist { #[serde(rename = "playlistId")] playlist_id: String, #[serde(rename = "playlistName")] playlist_name: String, }, Search { #[serde(rename = "searchQuery")] #[allow(dead_code)] // Used for deserialization, may be used later for analytics search_query: String, }, Custom { #[serde(rename = "label")] #[allow(dead_code)] // Used for deserialization, may be used later for UI display label: Option, }, } /// Response for video seek operations #[derive(Debug, Serialize)] #[serde(tag = "strategy", rename_all = "camelCase")] pub enum VideoSeekResponse { /// Use native seeking (HLS or direct stream) Native { /// Confirmed position after seek position: f64, }, /// Reload stream from new position (transcoded non-HLS) ReloadStream { /// New stream URL starting at seek position new_url: String, /// Position offset to track (for display purposes) seek_offset: f64, }, } /// Response for audio track switching operations #[derive(Debug, Serialize)] #[serde(tag = "strategy", rename_all = "camelCase")] pub enum AudioTrackSwitchResponse { /// Native backend handled it (Android ExoPlayer) Native { /// Confirmation message success: bool, }, /// HTML5 needs to reload stream with new audio track ReloadStream { /// New stream URL with selected audio track new_url: String, /// Current position to resume from position: f64, }, } /// Helper function to create MediaItem from video request /// /// PlayItemRequest is now video-only, so we create a video MediaItem. /// Audio playback uses player_play_tracks which fetches full metadata from backend. pub(super) async fn create_media_item( req: PlayItemRequest, db: Option<&DatabaseWrapper>, ) -> Result { // For video-only requests, we use the item ID as the jellyfin ID let jellyfin_id = req.id.clone(); // Check if item is downloaded locally let local_path = if let Some(db_wrapper) = db { check_for_local_download(db_wrapper, &jellyfin_id).await? } else { None }; let source = if let Some(path) = local_path { MediaSource::Local { file_path: PathBuf::from(path), jellyfin_item_id: Some(jellyfin_id.clone()), } } else { MediaSource::Remote { stream_url: req.stream_url, jellyfin_item_id: jellyfin_id.clone(), } }; Ok(MediaItem { id: req.id.clone(), title: req.title.clone(), name: Some(req.title.clone()), artist: None, // Not available from video-only request album: None, // Not available from video-only request album_name: None, // Not available from video-only request album_id: None, // Not available from video-only request artist_items: None, // Not available from video-only request artists: None, // Not available from video-only request primary_image_tag: None, // Not available from video-only request item_type: None, // Not available from video-only request playlist_id: None, // Not available from video-only request duration: None, // Not available from video-only request artwork_url: None, // Not available from video-only request media_type: crate::player::MediaType::Video, // Video-only request source, video_codec: Some(req.video_codec), needs_transcoding: req.needs_transcoding, video_width: None, // Not available from video-only request video_height: None, // Not available from video-only request subtitles: vec![], series_id: None, // Not available from video-only request server_id: None, // Not available from video-only request }) } /// Check if an item has a completed download pub(super) async fn check_for_local_download( db: &DatabaseWrapper, item_id: &str, ) -> Result, String> { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; let query = Query::with_params( "SELECT file_path FROM downloads WHERE item_id = ? AND status = 'completed' LIMIT 1", vec![QueryParam::String(item_id.to_string())], ); let path: Option = db_service .query_optional(query, |row| row.get(0)) .await .map_err(|e| e.to_string())?; // Verify the file actually exists on disk if let Some(ref file_path) = path { if std::path::Path::new(file_path).exists() { Ok(path) } else { warn!("[Player] Download entry exists in DB but file not found: {}", file_path); Ok(None) } } else { Ok(None) } } /// Play a single media item (audio or video) /// /// Accepts a PlayItemRequest with all optional fields properly defaulted. /// This avoids Tauri's Android serialization issues with complex objects. /// /// @req: UR-003 - Play videos /// @req: UR-004 - Play audio uninterrupted /// @req: UR-005 - Control media playback (play operation) /// @req: DR-009 - Audio player UI #[tauri::command] pub async fn player_play_item( player: State<'_, PlayerStateWrapper>, session: State<'_, MediaSessionManagerWrapper>, db: State<'_, DatabaseWrapper>, item: PlayItemRequest, ) -> Result { info!("player_play_item called: {} - {}", item.title, item.stream_url); // Create media item, checking for local download first let media_item = create_media_item(item, Some(&db)).await?; // Start appropriate session based on media type { let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?; match media_item.media_type { MediaType::Audio => { session_mgr.start_audio_session(media_item.clone()); } MediaType::Video => { // For single video items, treat as Movie (no series_id available here) session_mgr.start_movie_session(media_item.clone()); } } } let controller = player.0.lock().await; controller .play_item(media_item) .map_err(|e| e.to_string())?; // Emit queue changed event controller.emit_queue_changed(); // Emit session changed event if let Some(emitter) = controller.event_emitter() { let session_mgr = session.0.lock().map_err(|e| e.to_string())?; emitter.emit(PlayerStatusEvent::SessionChanged { session: session_mgr.current().clone(), }); } Ok(get_player_status(&controller)) } /// Play a queue of media items /// /// @req: UR-004 - Play audio uninterrupted /// @req: UR-005 - Control media playback (queue playback) /// @req: UR-015 - View and manage current audio queue /// @req: DR-005 - Queue manager with shuffle, repeat, history #[tauri::command] pub async fn player_play_queue( player: State<'_, PlayerStateWrapper>, session: State<'_, MediaSessionManagerWrapper>, db: State<'_, DatabaseWrapper>, request: PlayQueueRequest, ) -> Result { info!("player_play_queue called: {} items, start_index: {}, shuffle: {}", request.items.len(), request.start_index, request.shuffle); // Handle shuffle first if request.shuffle { let controller = player.0.lock().await; controller.toggle_shuffle(); } // Convert request context to internal QueueContext let queue_context = match request.context { Some(PlayQueueContext::Album { album_id, album_name }) => { QueueContext::Album { album_id, album_name } } Some(PlayQueueContext::Playlist { playlist_id, playlist_name }) => { QueueContext::Playlist { playlist_id, playlist_name } } Some(PlayQueueContext::Custom) | None => QueueContext::Custom, }; // Create media items, checking for local downloads (must not hold locks during await) let mut items: Vec = Vec::new(); for req in request.items { items.push(create_media_item(req, Some(&db)).await?); } // Start appropriate session based on first item's media type if let Some(first_item) = items.get(request.start_index) { let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?; match first_item.media_type { MediaType::Audio => { session_mgr.start_audio_session(first_item.clone()); } MediaType::Video => { // Queue of videos treated as movie session (TV episodes use different flow) session_mgr.start_movie_session(first_item.clone()); } } } // Now play the queue and get status let controller = player.0.lock().await; info!("player_play_queue: Calling controller.play_queue with {} items at index {}", items.len(), request.start_index); controller .play_queue(items, request.start_index) .map_err(|e| e.to_string())?; // Set the queue context for remote transfer { let queue_arc = controller.queue(); let mut queue = queue_arc.lock().map_err(|e| e.to_string())?; queue.set_context(queue_context); } // Emit queue changed event controller.emit_queue_changed(); // Emit session changed event if let Some(emitter) = controller.event_emitter() { let session_mgr = session.0.lock().map_err(|e| e.to_string())?; emitter.emit(PlayerStatusEvent::SessionChanged { session: session_mgr.current().clone(), }); } Ok(get_player_status(&controller)) } #[tauri::command] pub async fn player_play( player: State<'_, PlayerStateWrapper>, playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>, ) -> Result { // Check if we're in remote mode let mode = playback_mode.0.get_mode(); if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode { // Send play command to remote session - clone client before await let client = { let controller = player.0.lock().await; let client_arc = controller.jellyfin_client(); let client_opt = client_arc.lock().map_err(|e| e.to_string())?; client_opt.as_ref().ok_or("Jellyfin client not configured")?.clone() }; client.send_session_command(session_id, "Unpause").await?; } else { // Local playback let controller = player.0.lock().await; controller.play().map_err(|e| e.to_string())?; } let controller = player.0.lock().await; Ok(get_player_status(&controller)) } #[tauri::command] pub async fn player_pause( player: State<'_, PlayerStateWrapper>, playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>, ) -> Result { // Check if we're in remote mode let mode = playback_mode.0.get_mode(); if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode { // Send pause command to remote session - clone client before await let client = { let controller = player.0.lock().await; let client_arc = controller.jellyfin_client(); let client_opt = client_arc.lock().map_err(|e| e.to_string())?; client_opt.as_ref().ok_or("Jellyfin client not configured")?.clone() }; client.send_session_command(session_id, "Pause").await?; } else { // Local playback let controller = player.0.lock().await; controller.pause().map_err(|e| e.to_string())?; } let controller = player.0.lock().await; Ok(get_player_status(&controller)) } #[tauri::command] pub async fn player_toggle( player: State<'_, PlayerStateWrapper>, playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>, ) -> Result { // Check if we're in remote mode let mode = playback_mode.0.get_mode(); if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode { // Send toggle command to remote session - clone client before await let client = { let controller = player.0.lock().await; let client_arc = controller.jellyfin_client(); let client_opt = client_arc.lock().map_err(|e| e.to_string())?; client_opt.as_ref().ok_or("Jellyfin client not configured")?.clone() }; client.send_session_command(session_id, "PlayPause").await?; } else { // Local playback let controller = player.0.lock().await; controller.toggle_playback().map_err(|e| e.to_string())?; } let controller = player.0.lock().await; Ok(get_player_status(&controller)) } #[tauri::command] pub async fn player_stop( player: State<'_, PlayerStateWrapper>, session: State<'_, MediaSessionManagerWrapper>, playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>, ) -> Result { // Check if we're in remote mode let mode = playback_mode.0.get_mode(); if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode { // Send stop command to remote session - clone client before await let client = { let controller = player.0.lock().await; let client_arc = controller.jellyfin_client(); let client_opt = client_arc.lock().map_err(|e| e.to_string())?; client_opt.as_ref().ok_or("Jellyfin client not configured")?.clone() }; client.send_session_command(session_id, "Stop").await?; } else { // Local playback let controller = player.0.lock().await; controller.stop().map_err(|e| e.to_string())?; // Handle session state based on type (local playback only) { let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?; let current_session = session_mgr.current().clone(); match current_session { crate::player::MediaSessionType::Movie { .. } => { // Movies auto-dismiss when stopped session_mgr.movie_session_ended(); } crate::player::MediaSessionType::Audio { .. } => { // Audio persists as inactive (user can resume later) session_mgr.audio_session_inactive(); } crate::player::MediaSessionType::TvShow { .. } => { // TV shows mark episode ended (waiting for next or dismiss) session_mgr.tv_session_episode_ended(); } crate::player::MediaSessionType::Idle => { // Already idle, no-op } } // Emit session changed event if let Some(emitter) = controller.event_emitter() { emitter.emit(PlayerStatusEvent::SessionChanged { session: session_mgr.current().clone(), }); } } } let controller = player.0.lock().await; Ok(get_player_status(&controller)) } #[tauri::command] pub async fn player_next( player: State<'_, PlayerStateWrapper>, session: State<'_, MediaSessionManagerWrapper>, playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>, ) -> Result { debug!("[player_next] Command called from frontend"); // Check if we're in remote mode let mode = playback_mode.0.get_mode(); if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode { // Send next track command to remote session - clone client before await let client = { let controller = player.0.lock().await; let client_arc = controller.jellyfin_client(); let client_opt = client_arc.lock().map_err(|e| e.to_string())?; client_opt.as_ref().ok_or("Jellyfin client not configured")?.clone() }; client.send_session_command(session_id, "NextTrack").await?; } else { // Local playback let controller = player.0.lock().await; controller.next().map_err(|e| e.to_string())?; controller.emit_queue_changed(); // Update audio session track if in audio session let current_item = { let queue = controller.queue(); let queue_lock = queue.lock().map_err(|e| e.to_string())?; queue_lock.current().cloned() }; if let Some(item) = current_item { if item.media_type == MediaType::Audio { let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?; session_mgr.update_audio_track(item); // Emit session changed event if let Some(emitter) = controller.event_emitter() { emitter.emit(PlayerStatusEvent::SessionChanged { session: session_mgr.current().clone(), }); } } } } let controller = player.0.lock().await; Ok(get_player_status(&controller)) } #[tauri::command] pub async fn player_previous( player: State<'_, PlayerStateWrapper>, session: State<'_, MediaSessionManagerWrapper>, playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>, ) -> Result { // Check if we're in remote mode let mode = playback_mode.0.get_mode(); if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode { // Send previous track command to remote session - clone client before await let client = { let controller = player.0.lock().await; let client_arc = controller.jellyfin_client(); let client_opt = client_arc.lock().map_err(|e| e.to_string())?; client_opt.as_ref().ok_or("Jellyfin client not configured")?.clone() }; client.send_session_command(session_id, "PreviousTrack").await?; } else { // Local playback let controller = player.0.lock().await; controller.previous().map_err(|e| e.to_string())?; controller.emit_queue_changed(); // Update audio session track if in audio session let current_item = { let queue = controller.queue(); let queue_lock = queue.lock().map_err(|e| e.to_string())?; queue_lock.current().cloned() }; if let Some(item) = current_item { if item.media_type == MediaType::Audio { let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?; session_mgr.update_audio_track(item); // Emit session changed event if let Some(emitter) = controller.event_emitter() { emitter.emit(PlayerStatusEvent::SessionChanged { session: session_mgr.current().clone(), }); } } } } let controller = player.0.lock().await; Ok(get_player_status(&controller)) } #[tauri::command] pub async fn player_seek( player: State<'_, PlayerStateWrapper>, playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>, position: f64, ) -> Result { // Check if we're in remote mode let mode = playback_mode.0.get_mode(); if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode { // Send seek command to remote session - clone client before await let client = { let controller = player.0.lock().await; let client_arc = controller.jellyfin_client(); let client_opt = client_arc.lock().map_err(|e| e.to_string())?; client_opt.as_ref().ok_or("Jellyfin client not configured")?.clone() }; let position_ticks = (position * 10_000_000.0) as i64; client.session_seek(session_id, position_ticks).await?; } else { // Local playback let controller = player.0.lock().await; controller.seek(position).map_err(|e| e.to_string())?; } let controller = player.0.lock().await; Ok(get_player_status(&controller)) } /// Smart video seeking that decides between native and server-side seeking /// /// This command analyzes the current video stream and automatically chooses /// the best seeking strategy: /// - HLS streams (.m3u8): Use native seeking /// - Direct play streams: Use native seeking /// - Transcoded non-HLS: Request new stream URL from server starting at seek position /// /// For native (non-HTML5) backends, this command handles the entire stream reload /// internally. For HTML5 backends, it returns the new URL for the frontend to handle. #[tauri::command] pub async fn player_seek_video( player: State<'_, PlayerStateWrapper>, repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, repository_handle: String, position: f64, media_source_id: Option, audio_stream_index: Option, use_html5: bool, ) -> Result { info!("[player_seek_video] Seeking to {} seconds (use_html5: {})", position, use_html5); // Get repository let repository = repository_manager.0.get(&repository_handle) .ok_or("Repository not found - user may need to log in")?; // Get current playing item to analyze stream characteristics // Clone what we need to avoid holding locks across await points let (needs_transcoding, jellyfin_item_id, stream_url, is_local) = { let controller = player.0.lock().await; let queue_arc = controller.queue(); let queue = queue_arc.lock().map_err(|e| e.to_string())?; let current_item = queue.current() .ok_or("No item currently playing")?; if current_item.media_type != MediaType::Video { return Err("Current item is not a video".to_string()); } let jellyfin_id = current_item.jellyfin_id() .ok_or("Current video has no Jellyfin ID")? .to_string(); let (stream_url, is_local_file) = match ¤t_item.source { MediaSource::Remote { stream_url, .. } => (stream_url.clone(), false), MediaSource::Local { .. } => (String::new(), true), MediaSource::DirectUrl { url } => (url.clone(), false), }; let needs_trans = current_item.needs_transcoding; (needs_trans, jellyfin_id, stream_url, is_local_file) }; // Locks are dropped here // Determine seek strategy using the testable helper function let is_hls = stream_url.contains(".m3u8"); let strategy = determine_video_seek_strategy(is_local, is_hls, needs_transcoding, use_html5); info!("[player_seek_video] Stream analysis: is_local={}, is_hls={}, needs_transcoding={}, use_html5={}, strategy={:?}", is_local, is_hls, needs_transcoding, use_html5, strategy); match strategy { VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => { // Local files and native backend streams - call backend.seek() info!("[player_seek_video] Using backend native seek"); let controller = player.0.lock().await; controller.seek(position).map_err(|e| e.to_string())?; Ok(VideoSeekResponse::Native { position }) } VideoSeekStrategy::Html5NativeSeek => { // HTML5 backend with HLS or direct play - frontend handles seeking // We don't call backend.seek() because video is in HTML5 element, not in MPV info!("[player_seek_video] HTML5 native seek - returning position for frontend"); Ok(VideoSeekResponse::Native { position }) } VideoSeekStrategy::Html5ReloadStream => { // Transcoded non-HLS with HTML5 - frontend handles stream reload info!("[player_seek_video] HTML5 reload stream - requesting new stream URL"); let new_url = repository.get_video_stream_url( &jellyfin_item_id, media_source_id.as_deref(), Some(position), audio_stream_index, ).await .map_err(|e| format!("Failed to get video stream URL: {:?}", e))?; info!("[player_seek_video] Got new stream URL for position {}", position); Ok(VideoSeekResponse::ReloadStream { new_url, seek_offset: position, }) } VideoSeekStrategy::BackendReloadStream => { // Transcoded non-HLS with native backend - backend handles stream reload info!("[player_seek_video] Backend reload stream - requesting new stream URL"); let new_url = repository.get_video_stream_url( &jellyfin_item_id, media_source_id.as_deref(), Some(position), audio_stream_index, ).await .map_err(|e| format!("Failed to get video stream URL: {:?}", e))?; info!("[player_seek_video] Got new stream URL, handling reload internally"); // Stop current playback { let controller = player.0.lock().await; controller.stop().map_err(|e| e.to_string())?; } // Update the stream URL in the queue { let controller = player.0.lock().await; let queue_arc = controller.queue(); let mut queue = queue_arc.lock().map_err(|e| e.to_string())?; if !queue.update_current_stream_url(new_url.clone()) { return Err("Failed to update stream URL in queue".to_string()); } } // Reload the player with the updated item from queue { let controller = player.0.lock().await; let queue_arc = controller.queue(); let queue = queue_arc.lock().map_err(|e| e.to_string())?; if let Some(updated_item) = queue.current() { controller.load_and_play(updated_item).map_err(|e| e.to_string())?; } else { return Err("No current item after URL update".to_string()); } } info!("[player_seek_video] Stream reloaded successfully at position {}", position); Ok(VideoSeekResponse::Native { position }) } } } /// Switch audio track - handles both HTML5 (stream reload) and native (direct switch) /// Note: Frontend should handle saving series preferences after this command succeeds #[tauri::command] pub async fn player_switch_audio_track( player: State<'_, PlayerStateWrapper>, repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, repository_handle: String, stream_index: i32, array_index: i32, use_html5: bool, current_position: Option, media_source_id: Option, ) -> Result { info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}", stream_index, array_index, use_html5); if use_html5 { // HTML5 backend needs stream reload let repository = repository_manager.0.get(&repository_handle) .ok_or("Repository not found - user may need to log in")?; // Get current item to find Jellyfin ID let jellyfin_item_id = { let controller = player.0.lock().await; let queue_arc = controller.queue(); let queue = queue_arc.lock().map_err(|e| e.to_string())?; let current_item = queue.current() .ok_or("No item currently playing")?; current_item.jellyfin_id() .ok_or("Current item has no Jellyfin ID")? .to_string() }; // Get new stream URL with selected audio track let new_url = repository.get_video_stream_url( &jellyfin_item_id, media_source_id.as_deref(), current_position, Some(stream_index), ).await .map_err(|e| format!("Failed to get video stream URL: {:?}", e))?; Ok(AudioTrackSwitchResponse::ReloadStream { new_url, position: current_position.unwrap_or(0.0), }) } else { // Native backend (Android ExoPlayer) - use array index let controller = player.0.lock().await; controller.set_audio_track(array_index).map_err(|e| e.to_string())?; Ok(AudioTrackSwitchResponse::Native { success: true }) } } #[tauri::command] pub async fn player_set_audio_track( player: State<'_, PlayerStateWrapper>, stream_index: i32, ) -> Result { let controller = player.0.lock().await; controller.set_audio_track(stream_index).map_err(|e| e.to_string())?; Ok(get_player_status(&controller)) } #[tauri::command] pub async fn player_set_subtitle_track( player: State<'_, PlayerStateWrapper>, stream_index: Option, ) -> Result { let controller = player.0.lock().await; controller.set_subtitle_track(stream_index).map_err(|e| e.to_string())?; Ok(get_player_status(&controller)) } #[tauri::command] pub async fn player_set_volume( player: State<'_, PlayerStateWrapper>, playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>, volume: f32, ) -> Result { // Check if we're in remote mode let mode = playback_mode.0.get_mode(); if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode { // Send volume command to remote session - clone client before await let client = { let controller = player.0.lock().await; let client_arc = controller.jellyfin_client(); let client_opt = client_arc.lock().map_err(|e| e.to_string())?; client_opt.as_ref().ok_or("Jellyfin client not configured")?.clone() }; // Convert 0-1 range to 0-100 for Jellyfin API let volume_percent = (volume * 100.0) as i32; client.session_set_volume(session_id, volume_percent).await?; } else { // Local playback let controller = player.0.lock().await; controller.set_volume(volume).map_err(|e| e.to_string())?; } let controller = player.0.lock().await; Ok(get_player_status(&controller)) } #[tauri::command] pub async fn player_toggle_mute( player: State<'_, PlayerStateWrapper>, playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>, ) -> Result { // Check if we're in remote mode let mode = playback_mode.0.get_mode(); if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode { // Send toggle mute command to remote session - clone client before await let client = { let controller = player.0.lock().await; let client_arc = controller.jellyfin_client(); let client_opt = client_arc.lock().map_err(|e| e.to_string())?; client_opt.as_ref().ok_or("Jellyfin client not configured")?.clone() }; client.send_session_command(session_id, "ToggleMute").await?; } else { // Local playback // TODO: Implement toggle_mute in PlayerController // let controller = player.0.lock().await; // controller.toggle_mute().map_err(|e| e.to_string())?; } let controller = player.0.lock().await; Ok(get_player_status(&controller)) } #[tauri::command] pub async fn player_toggle_shuffle( player: State<'_, PlayerStateWrapper>, ) -> Result { let controller = player.0.lock().await; controller.toggle_shuffle(); controller.emit_queue_changed(); Ok(get_queue_status(&controller)) } #[tauri::command] pub async fn player_cycle_repeat(player: State<'_, PlayerStateWrapper>) -> Result { let controller = player.0.lock().await; controller.cycle_repeat(); controller.emit_queue_changed(); Ok(get_queue_status(&controller)) } #[tauri::command] pub async fn player_get_status( player: State<'_, PlayerStateWrapper>, playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>, ) -> Result { let mode = playback_mode.0.get_mode(); // Get base local status let controller = player.0.lock().await; let mut status = get_player_status(&controller); // Get local media from queue let local_media = { let queue_arc = controller.queue(); let queue = queue_arc.lock().map_err(|e| e.to_string())?; queue.current().map(|item| MergedMediaItem::from(item)) }; let local_is_playing = status.state.is_playing(); let local_volume = status.volume; // If in remote mode, fetch session and merge state if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode { let client = { let client_arc = controller.jellyfin_client(); let client_opt = client_arc.lock().map_err(|e| e.to_string())?; client_opt.as_ref().ok_or("Jellyfin client not configured")?.clone() }; drop(controller); // Release lock before async call match client.get_session(&session_id).await { Ok(Some(session)) => { log::info!("[PlayerCommands] Merging remote session state"); // Merge media item status.merged_media = session.now_playing_item .as_ref() .map(|item| MergedMediaItem::from(item)); // Merge isPlaying (NOT isPaused!) status.merged_is_playing = session.play_state .as_ref() .map(|ps| !ps.is_paused.unwrap_or(true)) .unwrap_or(false); // Merge position (convert ticks to seconds) status.position = session.play_state .as_ref() .and_then(|ps| ps.position_ticks) .map(|ticks| ticks as f64 / 10_000_000.0) .unwrap_or(0.0); // Merge duration (convert ticks to seconds) status.duration = session.now_playing_item .as_ref() .and_then(|item| item.run_time_ticks) .map(|ticks| ticks as f64 / 10_000_000.0); // Merge volume (convert 0-100 → 0-1) status.merged_volume = session.play_state .as_ref() .and_then(|ps| ps.volume_level) .map(|vol| (vol.clamp(0, 100) as f32) / 100.0) .unwrap_or(1.0); return Ok(status); }, Ok(None) => { log::warn!("[PlayerCommands] Remote session not found, using local state"); }, Err(e) => { log::warn!("[PlayerCommands] Failed to fetch remote session: {}", e); } } } // Local playback or fallback status.merged_media = local_media; status.merged_is_playing = local_is_playing; status.merged_volume = local_volume; Ok(status) } #[tauri::command] pub async fn player_get_queue(player: State<'_, PlayerStateWrapper>) -> Result { let controller = player.0.lock().await; Ok(get_queue_status(&controller)) } pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus { // Determine backend at compile time based on platform let (backend, use_html5_element) = if cfg!(target_os = "android") { // Android uses ExoPlayer native backend (VideoBackend::Native, false) } else { // Linux and other platforms use HTML5 video element in frontend (VideoBackend::Html5, true) }; PlayerStatus { state: controller.state(), position: controller.position(), duration: controller.duration(), volume: controller.volume(), muted: controller.muted(), shuffle: controller.is_shuffle(), repeat: controller.repeat_mode(), backend, use_html5_element, // Merged fields initialized to defaults (will be set by player_get_status) merged_media: None, merged_is_playing: false, merged_volume: controller.volume(), } } pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus { let queue = controller.queue(); let queue_lock = queue.lock_safe(); QueueStatus { items: queue_lock.items().to_vec(), current_index: queue_lock.current_index(), shuffle: queue_lock.is_shuffle(), repeat: queue_lock.repeat_mode(), has_next: queue_lock.has_next(), has_previous: queue_lock.has_previous(), } } /// Play a track from an album - backend fetches all album tracks and builds queue #[tauri::command] pub async fn player_play_album_track( player: State<'_, PlayerStateWrapper>, session: State<'_, MediaSessionManagerWrapper>, db: State<'_, DatabaseWrapper>, repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, repository_handle: String, request: PlayAlbumTrackRequest, ) -> Result { info!("player_play_album_track called: album_id={}, track_id={}, shuffle={}", request.album_id, request.track_id, request.shuffle); // Get repository (hybrid - supports offline/online) let repository = repository_manager.0.get(&repository_handle) .ok_or("Repository not found - user may need to log in")?; // Fetch all tracks from the album via hybrid repository info!("Fetching tracks for album {} via repository", request.album_id); let album_items = repository.get_items( &request.album_id, Some(GetItemsOptions { limit: Some(1000), fields: Some(vec!["PrimaryImageAspectRatio".to_string(), "Overview".to_string(), "MediaStreams".to_string()]), ..Default::default() }) ).await.map_err(|e| format!("Failed to fetch album tracks: {}", e))?; info!("Found {} items in album", album_items.items.len()); // Filter to only Audio items and sort by index let mut tracks: Vec<_> = album_items.items.into_iter() .filter(|item| item.item_type == "Audio") .collect(); tracks.sort_by_key(|t| t.index_number.unwrap_or(0)); info!("Found {} audio tracks in album", tracks.len()); if tracks.is_empty() { return Err("No audio tracks found in album".to_string()); } // Debug: Log all track IDs and their indices info!("Album has {} tracks after sorting:", tracks.len()); for (idx, track) in tracks.iter().enumerate() { info!(" [{}] {} (ID: {})", idx, track.name, track.id); } // Find the index of the requested track info!("Looking for track_id: {}", request.track_id); let start_index = tracks.iter() .position(|t| t.id == request.track_id) .ok_or_else(|| format!("Track {} not found in album", request.track_id))?; info!("Track {} is at index {} in album", request.track_id, start_index); // Convert tracks to MediaItems let mut media_items = Vec::new(); for track in tracks { // Check for local download first let jellyfin_id = &track.id; let local_path = check_for_local_download(&db, jellyfin_id).await?; let source = if let Some(path) = local_path { MediaSource::Local { file_path: PathBuf::from(path), jellyfin_item_id: Some(jellyfin_id.clone()), } } else { // Get stream URL from repository (works online/offline) let stream_url = repository.get_audio_stream_url(&track.id).await .map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?; MediaSource::Remote { stream_url, jellyfin_item_id: jellyfin_id.clone(), } }; let primary_image_tag_for_url = track.primary_image_tag.clone(); let media_item = MediaItem { id: track.id.clone(), title: track.name.clone(), name: Some(track.name.clone()), // Frontend compatibility artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())), album: Some(request.album_name.clone()), album_name: Some(request.album_name.clone()), // Frontend compatibility album_id: Some(request.album_id.clone()), artist_items: track.artist_items.clone(), // For clickable artist links artists: track.artists.clone(), // Fallback artist info primary_image_tag: track.primary_image_tag.clone(), // For frontend image display item_type: Some(track.item_type.clone()), // Frontend compatibility playlist_id: None, duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0), artwork_url: primary_image_tag_for_url.map(|tag| { repository.get_image_url(&request.album_id, ImageType::Primary, Some(ImageOptions { max_width: Some(300), tag: Some(tag), ..Default::default() })) }), media_type: MediaType::Audio, source, video_codec: None, needs_transcoding: false, video_width: None, video_height: None, subtitles: vec![], series_id: None, server_id: None, }; media_items.push(media_item); } info!("Built queue with {} media items, starting at index {}", media_items.len(), start_index); // Handle shuffle before setting queue if request.shuffle { let controller = player.0.lock().await; controller.toggle_shuffle(); } // Start audio session with the first item if let Some(first_item) = media_items.get(start_index) { let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?; session_mgr.start_audio_session(first_item.clone()); } // Play the queue let controller = player.0.lock().await; controller .play_queue(media_items, start_index) .map_err(|e| e.to_string())?; // Set the queue context for remote transfer { let queue_arc = controller.queue(); let mut queue = queue_arc.lock().map_err(|e| e.to_string())?; queue.set_context(QueueContext::Album { album_id: request.album_id.clone(), album_name: request.album_name.clone(), }); } // Emit queue changed event controller.emit_queue_changed(); // Log final queue state { let queue_arc = controller.queue(); let queue_lock = queue_arc.lock().map_err(|e| e.to_string())?; info!("player_play_album_track: Queue now has {} items, current_index: {:?}", queue_lock.items().len(), queue_lock.current_index()); } // Emit session changed event if let Some(emitter) = controller.event_emitter() { let session_mgr = session.0.lock().map_err(|e| e.to_string())?; emitter.emit(PlayerStatusEvent::SessionChanged { session: session_mgr.current().clone(), }); } Ok(get_player_status(&controller)) } /// Play tracks by ID - backend fetches all metadata #[tauri::command] pub async fn player_play_tracks( player: State<'_, PlayerStateWrapper>, session: State<'_, MediaSessionManagerWrapper>, db: State<'_, DatabaseWrapper>, repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, repository_handle: String, request: PlayTracksRequest, ) -> Result { info!("player_play_tracks called: {} tracks, start_index={}, shuffle={}", request.track_ids.len(), request.start_index, request.shuffle); // Validate input if request.track_ids.is_empty() { return Err("No tracks provided".to_string()); } // Get repository let repository = repository_manager.0.get(&repository_handle) .ok_or("Repository not found - user may need to log in")?; // Fetch metadata for all tracks let mut media_items = Vec::new(); for track_id in &request.track_ids { // Fetch track metadata from repository let track = repository.get_item(track_id).await .map_err(|e| format!("Failed to fetch track {}: {}", track_id, e))?; // Check for local download let local_path = check_for_local_download(&db, track_id).await?; // Build MediaSource let source = if let Some(path) = local_path { MediaSource::Local { file_path: PathBuf::from(path), jellyfin_item_id: Some(track.id.clone()), } } else { let stream_url = repository.get_audio_stream_url(track_id).await .map_err(|e| format!("Failed to get stream URL: {}", e))?; MediaSource::Remote { stream_url, jellyfin_item_id: track.id.clone(), } }; // Transform to MediaItem with frontend-compatible fields let primary_image_tag_for_url = track.primary_image_tag.clone(); let media_item = MediaItem { id: track.id.clone(), title: track.name.clone(), name: Some(track.name.clone()), // Frontend compatibility artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())), album: track.album_name.clone(), album_name: track.album_name.clone(), // Frontend compatibility album_id: track.album_id.clone(), artist_items: track.artist_items.clone(), // For clickable artist links artists: track.artists.clone(), // Fallback artist info primary_image_tag: track.primary_image_tag.clone(), // For frontend image display item_type: Some(track.item_type.clone()), // Frontend compatibility playlist_id: None, // Set based on context below duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0), artwork_url: primary_image_tag_for_url.and_then(|tag| { track.album_id.as_ref().map(|album_id| { repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions { max_width: Some(300), tag: Some(tag), ..Default::default() })) }) }), media_type: MediaType::Audio, source, video_codec: None, needs_transcoding: false, video_width: None, video_height: None, subtitles: vec![], series_id: None, server_id: None, }; media_items.push(media_item); } info!("Built queue with {} media items", media_items.len()); // Map context and set playlist_id let queue_context = match request.context { PlayTracksContext::Playlist { playlist_id, playlist_name } => { for item in &mut media_items { item.playlist_id = Some(playlist_id.clone()); } QueueContext::Playlist { playlist_id, playlist_name } } PlayTracksContext::Search { .. } | PlayTracksContext::Custom { .. } => { QueueContext::Custom } }; // Handle shuffle if request.shuffle { let controller = player.0.lock().await; controller.toggle_shuffle(); } // Start session if let Some(first_item) = media_items.get(request.start_index) { let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?; session_mgr.start_audio_session(first_item.clone()); } // Play queue let controller = player.0.lock().await; controller.play_queue(media_items, request.start_index) .map_err(|e| e.to_string())?; // Set queue context { let queue_arc = controller.queue(); let mut queue = queue_arc.lock().map_err(|e| e.to_string())?; queue.set_context(queue_context); } // Emit events controller.emit_queue_changed(); if let Some(emitter) = controller.event_emitter() { let session_mgr = session.0.lock().map_err(|e| e.to_string())?; emitter.emit(PlayerStatusEvent::SessionChanged { session: session_mgr.current().clone(), }); } info!("player_play_tracks completed successfully"); Ok(get_player_status(&controller)) } /// Response for preload operation #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PreloadResult { /// Number of tracks queued for preload pub queued_count: usize, /// Number of tracks already downloaded pub already_downloaded: usize, /// Number of tracks skipped (no jellyfin ID or other reasons) pub skipped: usize, } /// Preload upcoming tracks from the queue /// This queues background downloads for the next N tracks that aren't already downloaded #[tauri::command] pub async fn player_preload_upcoming( player: State<'_, PlayerStateWrapper>, db: State<'_, DatabaseWrapper>, smart_cache: State<'_, SmartCacheWrapper>, user_id: String, _download_base_path: String, ) -> Result { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; // Get cache settings let precache_count = { let cache = smart_cache.0.lock().map_err(|e| e.to_string())?; if !cache.should_precache_queue() { return Ok(PreloadResult { queued_count: 0, already_downloaded: 0, skipped: 0, }); } cache.queue_precache_count() }; // Get upcoming items from queue let upcoming_items: Vec = { let controller = player.0.lock().await; let queue = controller.queue(); let queue_lock = queue.lock().map_err(|e| e.to_string())?; queue_lock.get_upcoming(precache_count).into_iter().cloned().collect() }; if upcoming_items.is_empty() { return Ok(PreloadResult { queued_count: 0, already_downloaded: 0, skipped: 0, }); } let mut queued_count = 0; let mut already_downloaded = 0; let mut skipped = 0; // 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() } MediaSource::Local { .. } => { already_downloaded += 1; continue; } MediaSource::DirectUrl { .. } => { skipped += 1; continue; } }; // Check if already downloaded let query = Query::with_params( "SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ? AND status IN ('completed', 'downloading', 'pending') LIMIT 1", vec![ QueryParam::String(jellyfin_id.clone()), QueryParam::String(user_id.clone()), ], ); let is_downloaded: Option = db_service .query_optional(query, |row| row.get(0)) .await .map_err(|e| e.to_string())?; if is_downloaded.is_some() { already_downloaded += 1; 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)); // Insert download record with preload priority 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", vec![ QueryParam::String(jellyfin_id), QueryParam::String(user_id.clone()), QueryParam::String(file_path), 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), ], ); match db_service.execute(insert_query).await { Ok(rows) if rows > 0 => { info!("[Preload] Queued download for: {} - {}", item.artist.as_deref().unwrap_or("Unknown"), item.title); queued_count += 1; } Ok(_) => { // Row already exists (conflict), count as already queued already_downloaded += 1; } Err(e) => { error!("[Preload] Failed to queue {}: {}", item.title, e); skipped += 1; } } } info!("[Preload] Result: queued={}, already_downloaded={}, skipped={}", queued_count, already_downloaded, skipped); Ok(PreloadResult { queued_count, already_downloaded, skipped, }) } /// Sanitize filename by removing invalid characters fn sanitize_filename(name: &str) -> String { name.chars() .map(|c| match c { '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', _ => c, }) .collect() } /// Update SmartCache configuration #[tauri::command] pub async fn player_set_cache_config( smart_cache: State<'_, SmartCacheWrapper>, config: CacheConfig, ) -> Result<(), String> { let cache = smart_cache.0.lock().map_err(|e| e.to_string())?; cache.update_config(config); Ok(()) } /// Get current SmartCache configuration #[tauri::command] pub async fn player_get_cache_config( smart_cache: State<'_, SmartCacheWrapper>, ) -> Result { let cache = smart_cache.0.lock().map_err(|e| e.to_string())?; Ok(cache.get_config().unwrap_or_default()) } /// Configure Jellyfin API client for automatic playback reporting #[tauri::command] pub async fn player_configure_jellyfin( player: State<'_, PlayerStateWrapper>, server_url: String, access_token: String, _user_id: String, device_id: String, ) -> Result<(), String> { log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting"); let config = JellyfinConfig { server_url, access_token, device_id, }; let client = JellyfinClient::new(config)?; let controller = player.0.lock().await; controller.set_jellyfin_client(Some(client)); log::info!("[PlayerCommand] Jellyfin client configured successfully"); Ok(()) } /// Disable Jellyfin automatic playback reporting #[tauri::command] pub async fn player_disable_jellyfin( player: State<'_, PlayerStateWrapper>, ) -> Result<(), String> { log::info!("[PlayerCommand] Disabling Jellyfin client"); let controller = player.0.lock().await; controller.set_jellyfin_client(None); log::info!("[PlayerCommand] Jellyfin client disabled"); Ok(()) } #[cfg(test)] mod tests { /// Test track index finding in album /// This reproduces the bug where clicking songs 1-5 always played song 13 #[test] fn test_find_track_index_in_album() { // Create mock album tracks #[derive(Clone)] struct MockTrack { id: String, name: String, index_number: Option, } let mut tracks = vec![ MockTrack { id: "track1".to_string(), name: "Song 1".to_string(), index_number: Some(1) }, MockTrack { id: "track2".to_string(), name: "Song 2".to_string(), index_number: Some(2) }, MockTrack { id: "track3".to_string(), name: "Song 3".to_string(), index_number: Some(3) }, MockTrack { id: "track4".to_string(), name: "Song 4".to_string(), index_number: Some(4) }, MockTrack { id: "track5".to_string(), name: "Song 5".to_string(), index_number: Some(5) }, ]; // Sort by index (same as the real code does) tracks.sort_by_key(|t| t.index_number.unwrap_or(0)); // Test finding track 1 (should be index 0) let index1 = tracks.iter().position(|t| t.id == "track1"); assert_eq!(index1, Some(0), "Track 1 should be at index 0"); // Test finding track 3 (should be index 2) let index3 = tracks.iter().position(|t| t.id == "track3"); assert_eq!(index3, Some(2), "Track 3 should be at index 2"); // Test finding track 5 (should be index 4) let index5 = tracks.iter().position(|t| t.id == "track5"); assert_eq!(index5, Some(4), "Track 5 should be at index 4"); // Test finding non-existent track let index_none = tracks.iter().position(|t| t.id == "nonexistent"); assert_eq!(index_none, None, "Non-existent track should return None"); } /// Test that track order is preserved when iterating #[test] fn test_track_iteration_order() { // Simulate the loop that builds MediaItems let track_ids = vec!["id1", "id2", "id3", "id4", "id5"]; // Find where "id3" is in the original list let target_index = track_ids.iter().position(|&id| id == "id3"); assert_eq!(target_index, Some(2), "id3 should be at index 2 in original list"); // Simulate building the MediaItems vector let mut media_items = Vec::new(); for id in &track_ids { media_items.push(id.to_string()); } // Verify the order is preserved assert_eq!(media_items.len(), 5); assert_eq!(media_items[0], "id1"); assert_eq!(media_items[2], "id3"); assert_eq!(media_items[4], "id5"); // The start_index found earlier should still be valid assert_eq!(media_items[target_index.unwrap()], "id3"); } /// Test album track sorting behavior #[test] fn test_album_track_sorting() { #[derive(Clone, Debug)] struct MockTrack { id: String, name: String, index_number: Option, } // Create tracks in random order (not sorted) let mut tracks = vec![ MockTrack { id: "id5".to_string(), name: "Track 5".to_string(), index_number: Some(5) }, MockTrack { id: "id1".to_string(), name: "Track 1".to_string(), index_number: Some(1) }, MockTrack { id: "id3".to_string(), name: "Track 3".to_string(), index_number: Some(3) }, MockTrack { id: "id2".to_string(), name: "Track 2".to_string(), index_number: Some(2) }, MockTrack { id: "id4".to_string(), name: "Track 4".to_string(), index_number: Some(4) }, ]; // User clicks track "id1" before sorting - what index is it? let requested_track_id = "id1"; // Sort the tracks (same as real code) tracks.sort_by_key(|t| t.index_number.unwrap_or(0)); // Now find the index AFTER sorting let start_index = tracks.iter().position(|t| t.id == requested_track_id); // Track "id1" should be at index 0 after sorting assert_eq!(start_index, Some(0), "After sorting, track id1 should be at index 0"); // Verify all tracks are in correct order assert_eq!(tracks[0].id, "id1"); assert_eq!(tracks[1].id, "id2"); assert_eq!(tracks[2].id, "id3"); assert_eq!(tracks[3].id, "id4"); assert_eq!(tracks[4].id, "id5"); } }