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        id: track.id.clone(),
202        title: track.name.clone(),
203        name: Some(track.name.clone()), // Frontend compatibility
204        artist: track
205            .album_artist
206            .clone()
207            .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
208        album: track.album_name.clone(),
209        album_name: track.album_name.clone(), // Frontend compatibility
210        album_id: track.album_id.clone(),
211        artist_items: track.artist_items.clone(), // For clickable artist links
212        artists: track.artists.clone(),           // Fallback artist info
213        primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
214        image_id: track.primary_image_tag.clone(),
215        item_type: Some(track.item_type.clone()), // Frontend compatibility
216        playlist_id: None,
217        duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
218        artwork_url: primary_image_tag_for_url.and_then(|tag| {
219            track.album_id.as_ref().map(|album_id| {
220                repository.get_image_url(
221                    album_id,
222                    ImageType::Primary,
223                    Some(ImageOptions {
224                        max_width: Some(300),
225                        tag: Some(tag),
226                        ..Default::default()
227                    }),
228                )
229            })
230        }),
231        media_type: MediaType::Audio,
232        source,
233        video_codec: None,
234        needs_transcoding: false,
235        video_width: None,
236        video_height: None,
237        subtitles: vec![],
238        series_id: None,
239        server_id: None,
240    };
241
242    // Add to queue at specified position
243    let position = match request.position.as_str() {
244        "next" => AddPosition::Next,
245        _ => AddPosition::End,
246    };
247
248    let controller = player.0.lock().await;
249    let queue = controller.queue();
250    let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
251
252    queue_lock.add(vec![media_item], position);
253
254    let result = get_queue_status(&controller);
255    drop(queue_lock);
256    controller.emit_queue_changed();
257
258    info!("Successfully added track {} to queue", request.track_id);
259    Ok(result)
260}
261
262/// Add multiple tracks to queue by IDs - backend fetches metadata and constructs URLs
263#[tauri::command]
264#[specta::specta]
265pub async fn player_add_tracks_by_ids(
266    player: State<'_, PlayerStateWrapper>,
267    db: State<'_, DatabaseWrapper>,
268    repository_manager: State<'_, RepositoryManagerWrapper>,
269    repository_handle: String,
270    request: AddTracksByIdsRequest,
271) -> Result<QueueStatus, String> {
272    use crate::player::queue::AddPosition;
273
274    info!(
275        "player_add_tracks_by_ids called: {} tracks, position={}",
276        request.track_ids.len(),
277        request.position
278    );
279
280    // Get repository (hybrid - supports offline/online)
281    let repository = repository_manager
282        .0
283        .get(&repository_handle)
284        .ok_or("Repository not found - user may need to log in")?;
285
286    // Fetch metadata and build MediaItems for all tracks
287    let mut media_items = Vec::new();
288    for track_id in &request.track_ids {
289        info!("Fetching metadata for track {} via repository", track_id);
290        let track = repository
291            .get_item(track_id)
292            .await
293            .map_err(|e| format!("Failed to fetch track metadata for {}: {}", track_id, e))?;
294
295        // Check for local download first
296        let local_path = check_for_local_download(&db, track_id).await?;
297
298        let source = if let Some(path) = local_path {
299            info!("Using local download for track {}", track_id);
300            MediaSource::Local {
301                file_path: PathBuf::from(path),
302                jellyfin_item_id: Some(track.id.clone()),
303            }
304        } else {
305            // Get stream URL from repository (works online/offline)
306            let stream_url = repository
307                .get_audio_stream_url(&track.id)
308                .await
309                .map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
310
311            MediaSource::Remote {
312                stream_url,
313                jellyfin_item_id: track.id.clone(),
314            }
315        };
316
317        // Build MediaItem with artwork URL from repository and frontend-compatible fields
318        let primary_image_tag_for_url = track.primary_image_tag.clone();
319        let media_item = MediaItem {
320            id: track.id.clone(),
321            title: track.name.clone(),
322            name: Some(track.name.clone()), // Frontend compatibility
323            artist: track
324                .album_artist
325                .clone()
326                .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
327            album: track.album_name.clone(),
328            album_name: track.album_name.clone(), // Frontend compatibility
329            album_id: track.album_id.clone(),
330            artist_items: track.artist_items.clone(), // For clickable artist links
331            artists: track.artists.clone(),           // Fallback artist info
332            primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
333            image_id: track.primary_image_tag.clone(),
334            item_type: Some(track.item_type.clone()), // Frontend compatibility
335            playlist_id: None,
336            duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
337            artwork_url: primary_image_tag_for_url.and_then(|tag| {
338                track.album_id.as_ref().map(|album_id| {
339                    repository.get_image_url(
340                        album_id,
341                        ImageType::Primary,
342                        Some(ImageOptions {
343                            max_width: Some(300),
344                            tag: Some(tag),
345                            ..Default::default()
346                        }),
347                    )
348                })
349            }),
350            media_type: MediaType::Audio,
351            source,
352            video_codec: None,
353            needs_transcoding: false,
354            video_width: None,
355            video_height: None,
356            subtitles: vec![],
357            series_id: None,
358            server_id: None,
359        };
360
361        media_items.push(media_item);
362    }
363
364    // Add to queue at specified position
365    let position = match request.position.as_str() {
366        "next" => AddPosition::Next,
367        _ => AddPosition::End,
368    };
369
370    let controller = player.0.lock().await;
371    let queue = controller.queue();
372    let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
373
374    queue_lock.add(media_items, position);
375
376    let result = get_queue_status(&controller);
377    drop(queue_lock);
378    controller.emit_queue_changed();
379
380    info!(
381        "Successfully added {} tracks to queue",
382        request.track_ids.len()
383    );
384    Ok(result)
385}
386
387#[tauri::command]
388#[specta::specta]
389pub async fn player_skip_to(
390    player: State<'_, PlayerStateWrapper>,
391    db: State<'_, DatabaseWrapper>,
392    index: usize,
393) -> Result<PlayerStatus, String> {
394    let controller = player.0.lock().await;
395
396    // Prefer downloads that completed since the queue was built
397    if let Err(e) = super::refresh_queue_local_sources(&controller, &db).await {
398        log::warn!("[player_skip_to] Failed to refresh local sources: {}", e);
399    }
400
401    // Skip to the index and get the item to play
402    let item = {
403        let queue = controller.queue();
404        let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
405        queue_lock.skip_to(index).cloned().ok_or("Invalid index")?
406    };
407
408    // Play the item without modifying the queue
409    controller.load_and_play(&item).map_err(|e| e.to_string())?;
410
411    // Emit queue changed event
412    controller.emit_queue_changed();
413
414    Ok(get_player_status(&controller))
415}