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}