//! Queue manipulation commands (add / remove / move / skip). use std::path::PathBuf; use log::info; use serde::Deserialize; use tauri::State; use super::{ check_for_local_download, create_media_item, get_player_status, get_queue_status, DatabaseWrapper, PlayItemRequest, PlayerStateWrapper, PlayerStatus, QueueStatus, }; use crate::commands::repository::RepositoryManagerWrapper; use crate::player::{MediaItem, MediaSource, MediaType}; use crate::repository::types::{ImageOptions, ImageType}; use crate::repository::MediaRepository; /// Request to add items to queue #[derive(specta::Type, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AddToQueueRequest { pub items: Vec, pub position: String, // "next" or "end" } /// Request to add a track by ID - backend fetches metadata #[derive(specta::Type, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AddTrackByIdRequest { pub track_id: String, pub position: String, // "next" or "end" } /// Request to add multiple tracks by IDs - backend fetches metadata #[derive(specta::Type, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AddTracksByIdsRequest { pub track_ids: Vec, pub position: String, // "next" or "end" } #[tauri::command] #[specta::specta] pub async fn player_add_to_queue( player: State<'_, PlayerStateWrapper>, db: State<'_, DatabaseWrapper>, request: AddToQueueRequest, ) -> Result { use crate::player::queue::AddPosition; let position = match request.position.as_str() { "next" => AddPosition::Next, _ => AddPosition::End, }; // Create media items first (without holding any locks during await) let mut items: Vec = Vec::new(); for req in request.items { items.push(create_media_item(req, Some(&db)).await?); } // Now add to queue let controller = player.0.lock().await; let queue = controller.queue(); let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; queue_lock.add(items, position); let result = 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(), }; drop(queue_lock); controller.emit_queue_changed(); Ok(result) } #[tauri::command] #[specta::specta] pub async fn player_remove_from_queue( player: State<'_, PlayerStateWrapper>, index: usize, ) -> Result { let controller = player.0.lock().await; let queue = controller.queue(); let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; queue_lock.remove(index); let result = 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(), }; drop(queue_lock); controller.emit_queue_changed(); Ok(result) } #[tauri::command] #[specta::specta] pub async fn player_move_in_queue( player: State<'_, PlayerStateWrapper>, from_index: usize, to_index: usize, ) -> Result { let controller = player.0.lock().await; let queue = controller.queue(); let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; if !queue_lock.move_item(from_index, to_index) { return Err("Invalid indices for move operation".to_string()); } let result = 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(), }; drop(queue_lock); controller.emit_queue_changed(); Ok(result) } /// Add a track to queue by ID - backend fetches metadata and constructs URLs #[tauri::command] #[specta::specta] pub async fn player_add_track_by_id( player: State<'_, PlayerStateWrapper>, db: State<'_, DatabaseWrapper>, repository_manager: State<'_, RepositoryManagerWrapper>, repository_handle: String, request: AddTrackByIdRequest, ) -> Result { use crate::player::queue::AddPosition; info!("player_add_track_by_id called: track_id={}, position={}", request.track_id, request.position); // 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 track metadata via repository info!("Fetching metadata for track {} via repository", request.track_id); let track = repository.get_item(&request.track_id).await .map_err(|e| format!("Failed to fetch track metadata: {}", e))?; // Check for local download first let local_path = check_for_local_download(&db, &request.track_id).await?; let source = if let Some(path) = local_path { info!("Using local download for track {}", request.track_id); MediaSource::Local { file_path: PathBuf::from(path), jellyfin_item_id: Some(track.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: track.id.clone(), } }; // Build MediaItem with artwork URL from repository and 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, 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, }; // Add to queue at specified position let position = match request.position.as_str() { "next" => AddPosition::Next, _ => AddPosition::End, }; let controller = player.0.lock().await; let queue = controller.queue(); let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; queue_lock.add(vec![media_item], position); let result = get_queue_status(&controller); drop(queue_lock); controller.emit_queue_changed(); info!("Successfully added track {} to queue", request.track_id); Ok(result) } /// Add multiple tracks to queue by IDs - backend fetches metadata and constructs URLs #[tauri::command] #[specta::specta] pub async fn player_add_tracks_by_ids( player: State<'_, PlayerStateWrapper>, db: State<'_, DatabaseWrapper>, repository_manager: State<'_, RepositoryManagerWrapper>, repository_handle: String, request: AddTracksByIdsRequest, ) -> Result { use crate::player::queue::AddPosition; info!("player_add_tracks_by_ids called: {} tracks, position={}", request.track_ids.len(), request.position); // 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 metadata and build MediaItems for all tracks let mut media_items = Vec::new(); for track_id in &request.track_ids { info!("Fetching metadata for track {} via repository", track_id); let track = repository.get_item(track_id).await .map_err(|e| format!("Failed to fetch track metadata for {}: {}", track_id, e))?; // Check for local download first let local_path = check_for_local_download(&db, track_id).await?; let source = if let Some(path) = local_path { info!("Using local download for track {}", track_id); MediaSource::Local { file_path: PathBuf::from(path), jellyfin_item_id: Some(track.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: track.id.clone(), } }; // Build MediaItem with artwork URL from repository and 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, 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); } // Add to queue at specified position let position = match request.position.as_str() { "next" => AddPosition::Next, _ => AddPosition::End, }; let controller = player.0.lock().await; let queue = controller.queue(); let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; queue_lock.add(media_items, position); let result = get_queue_status(&controller); drop(queue_lock); controller.emit_queue_changed(); info!("Successfully added {} tracks to queue", request.track_ids.len()); Ok(result) } #[tauri::command] #[specta::specta] pub async fn player_skip_to( player: State<'_, PlayerStateWrapper>, index: usize, ) -> Result { let controller = player.0.lock().await; // Skip to the index and get the item to play let item = { let queue = controller.queue(); let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; queue_lock.skip_to(index).cloned().ok_or("Invalid index")? }; // Play the item without modifying the queue controller.load_and_play(&item).map_err(|e| e.to_string())?; // Emit queue changed event controller.emit_queue_changed(); Ok(get_player_status(&controller)) }