Skip to main content

#
jellytau_lib/commands/player/
queue.rs

    1//! Queue manipulation commands (add / remove / move / skip).
    2//!
    3//! TRACES: UR-015 | DR-005, DR-020
    4
    5use std::path::PathBuf;
    6
    7use log::info;
    8use serde::Deserialize;
    9use tauri::State;
    10
    11use super::{
    12    check_for_local_download, create_media_item, get_player_status, get_queue_status,
    13    DatabaseWrapper, PlayItemRequest, PlayerStateWrapper, PlayerStatus, QueueStatus,
    14};
    15use crate::commands::repository::RepositoryManagerWrapper;
    16use crate::player::{MediaItem, MediaSource, MediaType};
    17use crate::repository::types::{ImageOptions, ImageType};
    18use crate::repository::MediaRepository;
    19
    20/// Request to add items to queue
    21#[derive(specta::Type, Debug, Deserialize)]
    22#[serde(rename_all = "camelCase")]
    23pub struct AddToQueueRequest {
    24    pub items: Vec<PlayItemRequest>,
    25    pub position: String, // "next" or "end"
    26}
    27
    28/// Request to add a track by ID - backend fetches metadata
    29#[derive(specta::Type, Debug, Deserialize)]
    30#[serde(rename_all = "camelCase")]
    31pub struct AddTrackByIdRequest {
    32    pub track_id: String,
    33    pub position: String, // "next" or "end"
    34}
    35
    36/// Request to add multiple tracks by IDs - backend fetches metadata
    37#[derive(specta::Type, Debug, Deserialize)]
    38#[serde(rename_all = "camelCase")]
    39pub struct AddTracksByIdsRequest {
    40    pub track_ids: Vec<String>,
    41    pub position: String, // "next" or "end"
    42}
    43
    44#[tauri::command]
    45#[specta::specta]
    46pub async fn player_add_to_queue(
    47    player: State<'_, PlayerStateWrapper>,
    48    db: State<'_, DatabaseWrapper>,
    49    request: AddToQueueRequest,
    50) -> Result<QueueStatus, String> {
    51    use crate::player::queue::AddPosition;
    52
    53    let position = match request.position.as_str() {
    54        "next" => AddPosition::Next,
    55        _ => AddPosition::End,
    56    };
    57
    58    // Create media items first (without holding any locks during await)
    59    let mut items: Vec<MediaItem> = Vec::new();
    60    for req in request.items {
    61        items.push(create_media_item(req, Some(&db)).await?);
    62    }
    63
    64    // Now add to queue
    65    let controller = player.0.lock().await;
    66    let queue = controller.queue();
    67    let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
    68
    69    queue_lock.add(items, position);
    70
    71    let result = QueueStatus {
    72        items: queue_lock.items().to_vec(),
    73        current_index: queue_lock.current_index(),
    74        shuffle: queue_lock.is_shuffle(),
    75        repeat: queue_lock.repeat_mode(),
    76        has_next: queue_lock.has_next(),
    77        has_previous: queue_lock.has_previous(),
    78    };
    79
    80    drop(queue_lock);
    81    controller.emit_queue_changed();
    82
    83    Ok(result)
    84}
    85
    86#[tauri::command]
    87#[specta::specta]
    88pub async fn player_remove_from_queue(
    89    player: State<'_, PlayerStateWrapper>,
    90    index: usize,
    91) -> Result<QueueStatus, String> {
    92    let controller = player.0.lock().await;
    93    let queue = controller.queue();
    94    let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
    95
    96    queue_lock.remove(index);
    97
    98    let result = QueueStatus {
    99        items: queue_lock.items().to_vec(),
    100        current_index: queue_lock.current_index(),
    101        shuffle: queue_lock.is_shuffle(),
    102        repeat: queue_lock.repeat_mode(),
    103        has_next: queue_lock.has_next(),
    104        has_previous: queue_lock.has_previous(),
    105    };
    106
    107    drop(queue_lock);
    108    controller.emit_queue_changed();
    109
    110    Ok(result)
    111}
    112
    113#[tauri::command]
    114#[specta::specta]
    115pub async fn player_move_in_queue(
    116    player: State<'_, PlayerStateWrapper>,
    117    from_index: usize,
    118    to_index: usize,
    119) -> Result<QueueStatus, String> {
    120    let controller = player.0.lock().await;
    121    let queue = controller.queue();
    122    let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
    123
    124    if !queue_lock.move_item(from_index, to_index) {
    125        return Err("Invalid indices for move operation".to_string());
    126    }
    127
    128    let result = QueueStatus {
    129        items: queue_lock.items().to_vec(),
    130        current_index: queue_lock.current_index(),
    131        shuffle: queue_lock.is_shuffle(),
    132        repeat: queue_lock.repeat_mode(),
    133        has_next: queue_lock.has_next(),
    134        has_previous: queue_lock.has_previous(),
    135    };
    136
    137    drop(queue_lock);
    138    controller.emit_queue_changed();
    139
    140    Ok(result)
    141}
    142
    143/// Add a track to queue by ID - backend fetches metadata and constructs URLs
    144#[tauri::command]
    145#[specta::specta]
    146pub async fn player_add_track_by_id(
    147    player: State<'_, PlayerStateWrapper>,
    148    db: State<'_, DatabaseWrapper>,
    149    repository_manager: State<'_, RepositoryManagerWrapper>,
    150    repository_handle: String,
    151    request: AddTrackByIdRequest,
    152) -> Result<QueueStatus, String> {
    153    use crate::player::queue::AddPosition;
    154
    155    info!(
    156        "player_add_track_by_id called: track_id={}, position={}",
    157        request.track_id, request.position
    158    );
    159
    160    // Get repository (hybrid - supports offline/online)
    161    let repository = repository_manager
    162        .0
    163        .get(&repository_handle)
    164        .ok_or("Repository not found - user may need to log in")?;
    165
    166    // Fetch track metadata via repository
    167    info!(
    168        "Fetching metadata for track {} via repository",
    169        request.track_id
    170    );
    171    let track = repository
    172        .get_item(&request.track_id)
    173        .await
    174        .map_err(|e| format!("Failed to fetch track metadata: {}", e))?;
    175
    176    // Check for local download first
    177    let local_path = check_for_local_download(&db, &request.track_id).await?;
    178
    179    let source = if let Some(path) = local_path {
    180        info!("Using local download for track {}", request.track_id);
    181        MediaSource::Local {
    182            file_path: PathBuf::from(path),
    183            jellyfin_item_id: Some(track.id.clone()),
    184        }
    185    } else {
    186        // Get stream URL from repository (works online/offline)
    187        let stream_url = repository
    188            .get_audio_stream_url(&track.id)
    189            .await
    190            .map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
    191
    192        MediaSource::Remote {
    193            stream_url,
    194            jellyfin_item_id: track.id.clone(),
    195        }
    196    };
    197
    198    // Build MediaItem with artwork URL from repository and frontend-compatible fields
    199    let primary_image_tag_for_url = track.primary_image_tag.clone();
    200    let media_item = MediaItem {
    201        // Audio and direct-URL items never negotiate a transport.
    202        transport: None,
    203        id: track.id.clone(),
    204        title: track.name.clone(),
    205        name: Some(track.name.clone()), // Frontend compatibility
    206        artist: track
    207            .album_artist
    208            .clone()
    209            .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
    210        album: track.album_name.clone(),
    211        album_name: track.album_name.clone(), // Frontend compatibility
    212        album_id: track.album_id.clone(),
    213        artist_items: track.artist_items.clone(), // For clickable artist links
    214        artists: track.artists.clone(),           // Fallback artist info
    215        primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
    216        image_id: track.primary_image_tag.clone(),
    217        item_type: Some(track.item_type.clone()), // Frontend compatibility
    218        playlist_id: None,
    219        duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
    220        artwork_url: primary_image_tag_for_url.and_then(|tag| {
    221            track.album_id.as_ref().map(|album_id| {
    222                repository.get_image_url(
    223                    album_id,
    224                    ImageType::Primary,
    225                    Some(ImageOptions {
    226                        max_width: Some(300),
    227                        tag: Some(tag),
    228                        ..Default::default()
    229                    }),
    230                )
    231            })
    232        }),
    233        media_type: MediaType::Audio,
    234        source,
    235        video_codec: None,
    236        needs_transcoding: false,
    237        video_width: None,
    238        video_height: None,
    239        subtitles: vec![],
    240        series_id: None,
    241        server_id: None,
    242    };
    243
    244    // Add to queue at specified position
    245    let position = match request.position.as_str() {
    246        "next" => AddPosition::Next,
    247        _ => AddPosition::End,
    248    };
    249
    250    let controller = player.0.lock().await;
    251    let queue = controller.queue();
    252    let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
    253
    254    queue_lock.add(vec![media_item], position);
    255
    256    let result = get_queue_status(&controller);
    257    drop(queue_lock);
    258    controller.emit_queue_changed();
    259
    260    info!("Successfully added track {} to queue", request.track_id);
    261    Ok(result)
    262}
    263
    264/// Add multiple tracks to queue by IDs - backend fetches metadata and constructs URLs
    265#[tauri::command]
    266#[specta::specta]
    267pub async fn player_add_tracks_by_ids(
    268    player: State<'_, PlayerStateWrapper>,
    269    db: State<'_, DatabaseWrapper>,
    270    repository_manager: State<'_, RepositoryManagerWrapper>,
    271    repository_handle: String,
    272    request: AddTracksByIdsRequest,
    273) -> Result<QueueStatus, String> {
    274    use crate::player::queue::AddPosition;
    275
    276    info!(
    277        "player_add_tracks_by_ids called: {} tracks, position={}",
    278        request.track_ids.len(),
    279        request.position
    280    );
    281
    282    // Get repository (hybrid - supports offline/online)
    283    let repository = repository_manager
    284        .0
    285        .get(&repository_handle)
    286        .ok_or("Repository not found - user may need to log in")?;
    287
    288    // Fetch metadata and build MediaItems for all tracks
    289    let mut media_items = Vec::new();
    290    for track_id in &request.track_ids {
    291        info!("Fetching metadata for track {} via repository", track_id);
    292        let track = repository
    293            .get_item(track_id)
    294            .await
    295            .map_err(|e| format!("Failed to fetch track metadata for {}: {}", track_id, e))?;
    296
    297        // Check for local download first
    298        let local_path = check_for_local_download(&db, track_id).await?;
    299
    300        let source = if let Some(path) = local_path {
    301            info!("Using local download for track {}", track_id);
    302            MediaSource::Local {
    303                file_path: PathBuf::from(path),
    304                jellyfin_item_id: Some(track.id.clone()),
    305            }
    306        } else {
    307            // Get stream URL from repository (works online/offline)
    308            let stream_url = repository
    309                .get_audio_stream_url(&track.id)
    310                .await
    311                .map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
    312
    313            MediaSource::Remote {
    314                stream_url,
    315                jellyfin_item_id: track.id.clone(),
    316            }
    317        };
    318
    319        // Build MediaItem with artwork URL from repository and frontend-compatible fields
    320        let primary_image_tag_for_url = track.primary_image_tag.clone();
    321        let media_item = MediaItem {
    322            // Audio and direct-URL items never negotiate a transport.
    323            transport: None,
    324            id: track.id.clone(),
    325            title: track.name.clone(),
    326            name: Some(track.name.clone()), // Frontend compatibility
    327            artist: track
    328                .album_artist
    329                .clone()
    330                .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
    331            album: track.album_name.clone(),
    332            album_name: track.album_name.clone(), // Frontend compatibility
    333            album_id: track.album_id.clone(),
    334            artist_items: track.artist_items.clone(), // For clickable artist links
    335            artists: track.artists.clone(),           // Fallback artist info
    336            primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
    337            image_id: track.primary_image_tag.clone(),
    338            item_type: Some(track.item_type.clone()), // Frontend compatibility
    339            playlist_id: None,
    340            duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
    341            artwork_url: primary_image_tag_for_url.and_then(|tag| {
    342                track.album_id.as_ref().map(|album_id| {
    343                    repository.get_image_url(
    344                        album_id,
    345                        ImageType::Primary,
    346                        Some(ImageOptions {
    347                            max_width: Some(300),
    348                            tag: Some(tag),
    349                            ..Default::default()
    350                        }),
    351                    )
    352                })
    353            }),
    354            media_type: MediaType::Audio,
    355            source,
    356            video_codec: None,
    357            needs_transcoding: false,
    358            video_width: None,
    359            video_height: None,
    360            subtitles: vec![],
    361            series_id: None,
    362            server_id: None,
    363        };
    364
    365        media_items.push(media_item);
    366    }
    367
    368    // Add to queue at specified position
    369    let position = match request.position.as_str() {
    370        "next" => AddPosition::Next,
    371        _ => AddPosition::End,
    372    };
    373
    374    let controller = player.0.lock().await;
    375    let queue = controller.queue();
    376    let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
    377
    378    queue_lock.add(media_items, position);
    379
    380    let result = get_queue_status(&controller);
    381    drop(queue_lock);
    382    controller.emit_queue_changed();
    383
    384    info!(
    385        "Successfully added {} tracks to queue",
    386        request.track_ids.len()
    387    );
    388    Ok(result)
    389}
    390
    391#[tauri::command]
    392#[specta::specta]
    393pub async fn player_skip_to(
    394    player: State<'_, PlayerStateWrapper>,
    395    db: State<'_, DatabaseWrapper>,
    396    index: usize,
    397) -> Result<PlayerStatus, String> {
    398    let controller = player.0.lock().await;
    399
    400    // Prefer downloads that completed since the queue was built
    401    if let Err(e) = super::refresh_queue_local_sources(&controller, &db).await {
    402        log::warn!("[player_skip_to] Failed to refresh local sources: {}", e);
    403    }
    404
    405    // Skip to the index and get the item to play
    406    let item = {
    407        let queue = controller.queue();
    408        let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
    409        queue_lock.skip_to(index).cloned().ok_or("Invalid index")?
    410    };
    411
    412    // Play the item without modifying the queue
    413    controller.load_and_play(&item).map_err(|e| e.to_string())?;
    414
    415    // Emit queue changed event
    416    controller.emit_queue_changed();
    417
    418    Ok(get_player_status(&controller))
    419}