Skip to main content

jellytau_lib/commands/player/
mod.rs

1//! 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
2
3// Cohesive command clusters live in their own submodules and are re-exported so
4// the command names remain at `commands::player::*` (invoke_handler unchanged).
5mod queue;
6mod remote;
7mod session;
8mod settings;
9mod timers;
10pub use queue::*;
11pub use remote::*;
12pub use session::*;
13pub use settings::*;
14pub use timers::*;
15
16use crate::utils::lock::MutexSafe;
17use log::{debug, error, info, warn};
18use serde::{Deserialize, Serialize};
19use std::path::PathBuf;
20use std::sync::{Arc, Mutex};
21use tauri::State;
22use tokio::sync::Mutex as TokioMutex;
23
24use super::DatabaseWrapper;
25use crate::download::cache::{CacheConfig, SmartCache};
26use crate::jellyfin::{JellyfinClient, JellyfinConfig};
27use crate::player::{
28    determine_video_seek_strategy, MediaItem, MediaSessionManager, MediaSource, MediaType,
29    PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy,
30};
31use crate::repository::{
32    types::{GetItemsOptions, ImageOptions, ImageType},
33    MediaRepository,
34};
35use crate::settings::VideoSettings;
36use crate::storage::db_service::{DatabaseService, Query, QueryParam};
37
38/// SmartCache wrapper for Tauri state management
39pub struct SmartCacheWrapper(pub Mutex<SmartCache>);
40
41/// Player state wrapper for Tauri.
42///
43/// Uses Arc to allow sharing with the MediaSession handler on Android
44/// for lockscreen control integration.
45///
46/// @req: UR-005 - Control media playback
47/// @req: DR-001 - Player state machine
48pub struct PlayerStateWrapper(pub Arc<TokioMutex<PlayerController>>);
49
50/// Media session manager wrapper for Tauri state management
51///
52/// @req: DR-009 - Audio player UI (mini player, full screen)
53pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
54
55/// Video settings state wrapper for Tauri
56///
57/// @req: DR-048 - Video settings (auto-play toggle, countdown duration)
58pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
59
60/// Response for player state queries
61#[derive(specta::Type, Debug, Serialize)]
62#[serde(rename_all = "camelCase")]
63pub struct PlayerStatus {
64    pub state: PlayerState,
65    pub position: f64,
66    pub duration: Option<f64>,
67    pub volume: f32,
68    pub muted: bool,
69    pub shuffle: bool,
70    pub repeat: RepeatMode,
71    /// Backend being used (native = ExoPlayer/libmpv, html5 = fallback)
72    pub backend: VideoBackend,
73    /// Whether frontend should render HTML5 video element
74    pub use_html5_element: bool,
75
76    // Merged fields (prefer remote session when available)
77    /// Media item from either local queue or remote session
78    pub merged_media: Option<MergedMediaItem>,
79    /// Playing state from either local player or remote session
80    pub merged_is_playing: bool,
81    /// Volume from either local player or remote session (0-1 normalized)
82    pub merged_volume: f32,
83}
84
85/// Lightweight media item for merged playback state
86/// Converts from both local MediaItem and remote NowPlayingItem
87#[derive(specta::Type, Debug, Serialize, Clone)]
88#[serde(rename_all = "camelCase")]
89pub struct MergedMediaItem {
90    pub id: String,
91    pub title: String,
92    pub artist: Option<String>,
93    pub album: Option<String>,
94    pub album_id: Option<String>,
95    pub duration: Option<f64>,
96    pub primary_image_tag: Option<String>,
97    /// Neutral image identifier — replaces `primary_image_tag` (same value).
98    pub image_id: Option<String>,
99    pub media_type: String,
100}
101
102// Convert from local MediaItem
103impl From<&crate::player::MediaItem> for MergedMediaItem {
104    fn from(item: &crate::player::MediaItem) -> Self {
105        Self {
106            id: item.id.clone(),
107            title: item.title.clone(),
108            artist: item.artist.clone(),
109            album: item.album.clone(),
110            album_id: item.album_id.clone(),
111            duration: item.duration,
112            primary_image_tag: item.primary_image_tag.clone(),
113            image_id: item.primary_image_tag.clone(),
114            media_type: match item.media_type {
115                crate::player::MediaType::Audio => "audio".to_string(),
116                crate::player::MediaType::Video => "video".to_string(),
117            },
118        }
119    }
120}
121
122// Convert from remote NowPlayingItem
123impl From<&crate::jellyfin::client::NowPlayingItem> for MergedMediaItem {
124    fn from(item: &crate::jellyfin::client::NowPlayingItem) -> Self {
125        Self {
126            id: item.id.clone().unwrap_or_default(),
127            title: item.name.clone().unwrap_or_else(|| "Unknown".to_string()),
128            artist: item
129                .album_artist
130                .clone()
131                .or_else(|| item.artists.as_ref().and_then(|a| a.first().cloned())),
132            album: item.album.clone(),
133            album_id: item.album_id.clone(),
134            duration: item.run_time_ticks.map(|ticks| ticks as f64 / 10_000_000.0),
135            primary_image_tag: item.primary_image_tag.clone(),
136            image_id: item.primary_image_tag.clone(),
137            media_type: item
138                .item_type
139                .clone()
140                .unwrap_or_else(|| "audio".to_string())
141                .to_lowercase(),
142        }
143    }
144}
145
146/// Response for queue queries
147#[derive(specta::Type, Debug, Serialize)]
148#[serde(rename_all = "camelCase")]
149pub struct QueueStatus {
150    pub items: Vec<MediaItem>,
151    pub current_index: Option<usize>,
152    pub shuffle: bool,
153    pub repeat: RepeatMode,
154    pub has_next: bool,
155    pub has_previous: bool,
156}
157
158/// Backend type for video playback
159#[derive(specta::Type, Debug, Serialize)]
160#[serde(rename_all = "lowercase")]
161pub enum VideoBackend {
162    /// Native backend (ExoPlayer on Android, libmpv on Linux)
163    Native,
164    /// HTML5 video element fallback
165    Html5,
166}
167
168/// Request to play a single video item
169///
170/// Simplified to video playback only. Audio playback uses player_play_tracks
171/// to avoid Tauri Android serialization issues with complex objects.
172#[derive(specta::Type, Debug, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct PlayItemRequest {
175    pub id: String,
176    pub title: String,
177    pub stream_url: String,
178    /// Video codec (e.g., "h264", "hevc") for video media
179    pub video_codec: String,
180    /// Whether the video requires server-side transcoding
181    pub needs_transcoding: bool,
182    /// Optional now-playing metadata. Used by the background-audio handoff so the
183    /// lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
184    /// existing video-only callers need not send them.
185    #[serde(default)]
186    pub artist: Option<String>,
187    #[serde(default)]
188    pub primary_image_tag: Option<String>,
189    #[serde(default)]
190    pub server_id: Option<String>,
191    /// Total media duration (seconds). Threaded through the background-audio
192    /// handoff so the lockscreen MediaSession advertises a real duration — a
193    /// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
194    #[serde(default)]
195    pub duration_seconds: Option<f64>,
196    /// Item type (e.g. "Episode", "Movie", "Audio"). Carried through the
197    /// background-audio handoff so an episode played as audio-only is still
198    /// recognised as an episode by autoplay (UR-040) and advances to the next one.
199    #[serde(default)]
200    pub item_type: Option<String>,
201    /// Series ID for TV episodes. Needed alongside `item_type` so the backend can
202    /// look up the next episode when a background-audio track ends.
203    #[serde(default)]
204    pub series_id: Option<String>,
205    /// Subtitle tracks to sideload, with URLs the frontend has already resolved.
206    ///
207    /// Only the native backends use these: on Android they become the
208    /// `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
209    /// builds its own `<track>` children instead and ignores this list.
210    ///
211    /// **Order is the contract.** `player_set_subtitle_track(n)` reaches
212    /// `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
213    /// *text track groups* — i.e. the position of the sideloaded configuration,
214    /// not the Jellyfin stream index (which is kept on each entry for the UI's
215    /// benefit). So `n` must be a position in this very array, and the array
216    /// must not be reordered or filtered between building it and sending it.
217    /// `nativeSubtitleArrayIndex()` on the frontend computes `n` from the same
218    /// list that is sent here, for exactly this reason.
219    ///
220    /// Defaulted so the background-audio handoff and the autoplay/next-episode
221    /// callers, which have no subtitles to offer, need not send the field.
222    ///
223    /// TRACES: UR-020 | IR-016, JA-008 | UT-145
224    #[serde(default)]
225    pub subtitles: Vec<crate::player::SubtitleTrack>,
226}
227
228/// Queue context for remote transfer - what type of queue is this?
229#[derive(specta::Type, Debug, Deserialize)]
230#[serde(tag = "type", rename_all = "lowercase")]
231pub enum PlayQueueContext {
232    /// Playing from a specific album
233    Album {
234        #[serde(rename = "albumId")]
235        album_id: String,
236        #[serde(rename = "albumName")]
237        album_name: String,
238    },
239    /// Playing from a specific playlist
240    Playlist {
241        #[serde(rename = "playlistId")]
242        playlist_id: String,
243        #[serde(rename = "playlistName")]
244        playlist_name: String,
245    },
246    /// Custom queue (search results, manual queue, etc.)
247    Custom,
248}
249
250/// Request to play a queue of items
251#[derive(specta::Type, Debug, Deserialize)]
252#[serde(rename_all = "camelCase")]
253pub struct PlayQueueRequest {
254    pub items: Vec<PlayItemRequest>,
255    pub start_index: usize,
256    pub shuffle: bool,
257    /// Optional context for the queue (album, playlist, or custom)
258    /// Used for remote playback transfer
259    #[serde(default)]
260    pub context: Option<PlayQueueContext>,
261}
262
263/// Request to play a track from an album (backend fetches all tracks)
264#[derive(specta::Type, Debug, Deserialize)]
265#[serde(rename_all = "camelCase")]
266pub struct PlayAlbumTrackRequest {
267    pub album_id: String,
268    pub album_name: String,
269    pub track_id: String,
270    pub shuffle: bool,
271}
272
273/// Request to play tracks by ID (backend fetches metadata)
274#[derive(specta::Type, Debug, Deserialize)]
275#[serde(rename_all = "camelCase")]
276pub struct PlayTracksRequest {
277    pub track_ids: Vec<String>,
278    pub start_index: usize,
279    pub shuffle: bool,
280    pub context: PlayTracksContext,
281    /// Position (seconds) to resume the starting track from. Used when taking
282    /// over playback from a remote session so we don't restart from 0.
283    #[serde(default)]
284    pub start_position: Option<f64>,
285}
286
287/// Context information for track playback
288#[derive(specta::Type, Debug, Deserialize)]
289#[serde(tag = "type", rename_all = "lowercase")]
290pub enum PlayTracksContext {
291    Playlist {
292        #[serde(rename = "playlistId")]
293        playlist_id: String,
294        #[serde(rename = "playlistName")]
295        playlist_name: String,
296    },
297    Search {
298        #[serde(rename = "searchQuery")]
299        #[allow(dead_code)] // Used for deserialization, may be used later for analytics
300        search_query: String,
301    },
302    Custom {
303        #[serde(rename = "label")]
304        #[allow(dead_code)] // Used for deserialization, may be used later for UI display
305        label: Option<String>,
306    },
307}
308
309/// Response for video seek operations
310#[derive(specta::Type, Debug, Serialize)]
311#[serde(tag = "strategy", rename_all = "camelCase")]
312pub enum VideoSeekResponse {
313    /// Use native seeking (HLS or direct stream)
314    Native {
315        /// Confirmed position after seek
316        position: f64,
317    },
318    /// Reload stream from new position (transcoded non-HLS)
319    ReloadStream {
320        /// New stream URL starting at seek position
321        new_url: String,
322        /// Position offset to track (for display purposes)
323        seek_offset: f64,
324    },
325}
326
327/// Response for audio track switching operations
328#[derive(specta::Type, Debug, Serialize)]
329#[serde(tag = "strategy", rename_all = "camelCase")]
330pub enum AudioTrackSwitchResponse {
331    /// Native backend handled it (Android ExoPlayer)
332    Native {
333        /// Confirmation message
334        success: bool,
335    },
336    /// HTML5 needs to reload stream with new audio track
337    ReloadStream {
338        /// New stream URL with selected audio track
339        new_url: String,
340        /// Current position to resume from
341        position: f64,
342    },
343}
344
345/// Response for a mid-playback streaming-quality change.
346///
347/// Mirrors [`AudioTrackSwitchResponse`]: the backend decides whether the caller
348/// has to reload anything, so no strategy branch lives in the UI.
349///
350/// TRACES: UR-074 | DR-162
351#[derive(specta::Type, Debug, Serialize)]
352#[serde(tag = "strategy", rename_all = "camelCase")]
353pub enum StreamQualityResponse {
354    /// The native backend was reloaded here; nothing left for the frontend.
355    Native {
356        /// Position playback resumed at.
357        position: f64,
358    },
359    /// HTML5 must reload its element with this URL.
360    ReloadStream {
361        /// New stream URL, already transcoded to the requested ceiling.
362        new_url: String,
363        /// Position to resume from.
364        position: f64,
365    },
366}
367
368/// Helper function to create MediaItem from video request
369///
370/// PlayItemRequest is now video-only, so we create a video MediaItem.
371/// Audio playback uses player_play_tracks which fetches full metadata from backend.
372pub(super) async fn create_media_item(
373    req: PlayItemRequest,
374    db: Option<&DatabaseWrapper>,
375) -> Result<MediaItem, String> {
376    // For video-only requests, we use the item ID as the jellyfin ID
377    let jellyfin_id = req.id.clone();
378
379    // Check if item is downloaded locally
380    let local_path = if let Some(db_wrapper) = db {
381        check_for_local_download(db_wrapper, &jellyfin_id).await?
382    } else {
383        None
384    };
385
386    let source = if let Some(path) = local_path {
387        MediaSource::Local {
388            file_path: PathBuf::from(path),
389            jellyfin_item_id: Some(jellyfin_id.clone()),
390        }
391    } else {
392        MediaSource::Remote {
393            stream_url: req.stream_url,
394            jellyfin_item_id: jellyfin_id.clone(),
395        }
396    };
397
398    Ok(MediaItem {
399        id: req.id.clone(),
400        title: req.title.clone(),
401        name: Some(req.title.clone()),
402        artist: None,            // Not available from video-only request
403        album: None,             // Not available from video-only request
404        album_name: None,        // Not available from video-only request
405        album_id: None,          // Not available from video-only request
406        artist_items: None,      // Not available from video-only request
407        artists: None,           // Not available from video-only request
408        primary_image_tag: None, // Not available from video-only request
409        image_id: None,
410        item_type: None,   // Not available from video-only request
411        playlist_id: None, // Not available from video-only request
412        duration: None,    // Not available from video-only request
413        artwork_url: None, // Not available from video-only request
414        media_type: crate::player::MediaType::Video, // Video-only request
415        source,
416        video_codec: Some(req.video_codec),
417        needs_transcoding: req.needs_transcoding,
418        video_width: None,  // Not available from video-only request
419        video_height: None, // Not available from video-only request
420        // Sideloaded subtitles, in the order the frontend sent them — that order
421        // is what `player_set_subtitle_track(n)` indexes into on Android.
422        // TRACES: UR-020 | IR-016 | UT-145
423        subtitles: req.subtitles,
424        series_id: None, // Not available from video-only request
425        server_id: None, // Not available from video-only request
426    })
427}
428
429/// Pick the source for an audio-only handoff.
430///
431/// A downloaded file wins over the audio-only stream URL. No transcode or audio
432/// extraction is involved or wanted: the native backends already play a video
433/// container without decoding its video — the Linux MPV backend is configured
434/// with `video: no`, and ExoPlayer simply has no surface to render to when the
435/// item is `MediaType::Audio`. Producing a separate audio-only file would cost
436/// CPU and battery, need an encoder the project does not ship, and leave a
437/// second artifact to keep in step with the first.
438///
439/// TRACES: UR-071 | DR-128 | UT-119
440pub(super) fn background_audio_source(
441    local_path: Option<String>,
442    stream_url: String,
443    item_id: &str,
444) -> MediaSource {
445    match local_path {
446        Some(path) => MediaSource::Local {
447            file_path: PathBuf::from(path),
448            jellyfin_item_id: Some(item_id.to_string()),
449        },
450        None => MediaSource::Remote {
451            stream_url,
452            jellyfin_item_id: item_id.to_string(),
453        },
454    }
455}
456
457/// How a background-audio handoff must start playback, given where its audio
458/// actually begins.
459///
460/// TRACES: UR-040, UR-071 | DR-180 | UT-181
461pub(super) struct BackgroundAudioPlan {
462    /// The position the stream's own zero corresponds to, recorded as the
463    /// handoff base so later readings can be shifted back to the episode's
464    /// timeline.
465    pub base_seconds: f64,
466    /// Where to seek after loading, if the source does not already start there.
467    pub seek_to: Option<f64>,
468}
469
470/// Decide the base and the seek for a handoff at `position_seconds`.
471///
472/// The two sources start in different places. An audio-only **stream** is built
473/// with `StartTimeTicks`, so the server makes the handoff point that stream's
474/// zero: the base is the handoff position, and seeking would skip *past* the
475/// content by that much again. A downloaded **file** has no such parameter and
476/// begins at the episode's own zero, so it needs the opposite — no base, and a
477/// real seek. Treating a file like a stream is why backgrounding a downloaded
478/// episode restarted it from 0:00 while the lockscreen showed the right time.
479///
480/// TRACES: UR-040, UR-071 | DR-180 | UT-181
481pub(super) fn background_audio_plan(
482    is_local_file: bool,
483    position_seconds: f64,
484) -> BackgroundAudioPlan {
485    let position = position_seconds.max(0.0);
486
487    if is_local_file {
488        BackgroundAudioPlan {
489            base_seconds: 0.0,
490            seek_to: (position > 0.0).then_some(position),
491        }
492    } else {
493        BackgroundAudioPlan {
494            base_seconds: position,
495            seek_to: None,
496        }
497    }
498}
499
500/// Resolve the on-disk file backing a completed download, if there is one.
501///
502/// A `downloads` row is not proof of a file: it can outlive the bytes (manual
503/// deletion, a cleared cache directory, a restored database). Every caller wants
504/// "can I play this from disk right now", so existence is checked here rather
505/// than trusted from the row.
506///
507/// Split out from [`check_for_local_download`] so the resolution is testable
508/// without a `DatabaseWrapper`, and reusable by the video path.
509///
510/// TRACES: UR-071 | DR-123 | UT-116
511pub(super) async fn resolve_local_media_path<S: DatabaseService>(
512    db_service: &Arc<S>,
513    item_id: &str,
514) -> Result<Option<String>, String> {
515    let query = Query::with_params(
516        "SELECT file_path FROM downloads WHERE item_id = ? AND status = 'completed' LIMIT 1",
517        vec![QueryParam::String(item_id.to_string())],
518    );
519
520    let path: Option<String> = db_service
521        .query_optional(query, |row| row.get(0))
522        .await
523        .map_err(|e| e.to_string())?;
524
525    match path {
526        Some(ref file_path) if std::path::Path::new(file_path).exists() => Ok(path),
527        Some(file_path) => {
528            warn!(
529                "[Player] Download entry exists in DB but file not found: {}",
530                file_path
531            );
532            Ok(None)
533        }
534        None => Ok(None),
535    }
536}
537
538/// Check if an item has a completed download
539pub(super) async fn check_for_local_download(
540    db: &DatabaseWrapper,
541    item_id: &str,
542) -> Result<Option<String>, String> {
543    let db_service = {
544        let database = db.0.lock().map_err(|e| e.to_string())?;
545        Arc::new(database.service())
546    };
547
548    resolve_local_media_path(&db_service, item_id).await
549}
550
551/// The on-disk path for a downloaded item, for playback surfaces that resolve
552/// their own source rather than going through the queue.
553///
554/// The video player is the reason this exists: audio has preferred local files
555/// since queue construction, but video asks the repository for a stream URL and
556/// never consults `downloads`, so a downloaded film was still streamed — costing
557/// bandwidth that had already been spent and failing outright when offline.
558///
559/// Returns `None` when nothing is downloaded *or* the file is missing, so the
560/// caller falls back to streaming.
561///
562/// TRACES: UR-071 | DR-123 | UT-116
563#[tauri::command]
564#[specta::specta]
565pub async fn player_local_media_path(
566    db: State<'_, DatabaseWrapper>,
567    item_id: String,
568) -> Result<Option<String>, String> {
569    let db_service = {
570        let database = db.0.lock().map_err(|e| e.to_string())?;
571        Arc::new(database.service())
572    };
573
574    resolve_local_media_path(&db_service, &item_id).await
575}
576
577/// Re-point queued streaming items at completed local downloads.
578///
579/// Sources are resolved once when the queue is built, so downloads that finish
580/// while it plays (preloaded upcoming tracks) — or that existed before the
581/// connection dropped — would otherwise keep streaming. Called before advancing
582/// so the next track always prefers the on-disk copy.
583///
584/// Returns the number of items switched to a local source.
585pub(super) async fn refresh_queue_local_sources(
586    controller: &PlayerController,
587    db: &DatabaseWrapper,
588) -> Result<usize, String> {
589    // Collect remote item IDs first; the queue lock must not be held across awaits.
590    let remote_ids: Vec<String> = {
591        let queue = controller.queue();
592        let queue_lock = queue.lock().map_err(|e| e.to_string())?;
593        queue_lock
594            .items()
595            .iter()
596            .filter_map(|item| match &item.source {
597                MediaSource::Remote {
598                    jellyfin_item_id, ..
599                } => Some(jellyfin_item_id.clone()),
600                _ => None,
601            })
602            .collect()
603    };
604
605    if remote_ids.is_empty() {
606        return Ok(0);
607    }
608
609    let mut local_paths: Vec<(String, String)> = Vec::new();
610    for id in remote_ids {
611        if let Some(path) = check_for_local_download(db, &id).await? {
612            local_paths.push((id, path));
613        }
614    }
615
616    if local_paths.is_empty() {
617        return Ok(0);
618    }
619
620    let queue = controller.queue();
621    let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
622    let mut switched = 0;
623    for item in queue_lock.items_mut() {
624        if let MediaSource::Remote {
625            jellyfin_item_id, ..
626        } = &item.source
627        {
628            if let Some((id, path)) = local_paths.iter().find(|(id, _)| id == jellyfin_item_id) {
629                info!(
630                    "[Player] Switching queued track {} to local download: {}",
631                    id, path
632                );
633                item.source = MediaSource::Local {
634                    file_path: PathBuf::from(path),
635                    jellyfin_item_id: Some(id.clone()),
636                };
637                switched += 1;
638            }
639        }
640    }
641    Ok(switched)
642}
643
644/// Play a single media item (audio or video)
645///
646/// Accepts a PlayItemRequest with all optional fields properly defaulted.
647/// This avoids Tauri's Android serialization issues with complex objects.
648///
649/// @req: UR-003 - Play videos
650/// @req: UR-004 - Play audio uninterrupted
651/// @req: UR-005 - Control media playback (play operation)
652/// @req: DR-009 - Audio player UI
653#[tauri::command]
654#[specta::specta]
655pub async fn player_play_item(
656    player: State<'_, PlayerStateWrapper>,
657    session: State<'_, MediaSessionManagerWrapper>,
658    db: State<'_, DatabaseWrapper>,
659    item: PlayItemRequest,
660) -> Result<PlayerStatus, String> {
661    info!(
662        "player_play_item called: {} - {}",
663        item.title, item.stream_url
664    );
665
666    // Create media item, checking for local download first
667    let media_item = create_media_item(item, Some(&db)).await?;
668
669    // Start appropriate session based on media type
670    {
671        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
672        match media_item.media_type {
673            MediaType::Audio => {
674                session_mgr.start_audio_session(media_item.clone());
675            }
676            MediaType::Video => {
677                // For single video items, treat as Movie (no series_id available here)
678                session_mgr.start_movie_session(media_item.clone());
679            }
680        }
681    }
682
683    let controller = player.0.lock().await;
684    // On Linux, video plays in the WebKitGTK HTML5 <video> element (see
685    // get_player_status -> use_html5_element). The MPV backend has no embedded
686    // window, so loading the stream into it would only start a redundant decode
687    // (and the frontend would immediately stop it). Only load into the native
688    // backend on platforms that actually render video through it (e.g. Android).
689    #[cfg(not(target_os = "linux"))]
690    controller
691        .play_item(media_item)
692        .map_err(|e| e.to_string())?;
693    #[cfg(target_os = "linux")]
694    {
695        // Keep the queue in sync for UI/remote-transfer without starting MPV.
696        controller
697            .set_current_item(media_item)
698            .map_err(|e| e.to_string())?;
699    }
700
701    // Emit queue changed event
702    controller.emit_queue_changed();
703
704    // Emit session changed event
705    if let Some(emitter) = controller.event_emitter() {
706        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
707        emitter.emit(PlayerStatusEvent::SessionChanged {
708            session: session_mgr.current().clone(),
709        });
710    }
711
712    Ok(get_player_status(&controller))
713}
714
715/// Enter background-audio mode: hand playback of the currently-watched video off
716/// to the native ExoPlayer *audio* path so the audio keeps playing while the app
717/// is backgrounded/locked, with no client-side video decode (UR-040).
718///
719/// `stream_url` MUST be an audio-only URL (see
720/// `get_audio_only_stream_url_for_video`). The item is created as
721/// `MediaType::Audio` so it starts an audio session and loads into the native
722/// backend with `mediaType="audio"` — the WebView `<video>` is torn down on the
723/// frontend side, so exactly one audio source is ever active.
724///
725/// This deliberately goes through the queue-based `play_item` path (NOT a
726/// side-channel) so end-of-track lands in `on_playback_ended`, which already
727/// honors the sleep timer (Time/Episodes/EndOfTrack) and drives autoplay-next.
728/// The sleep-timer state is intentionally left untouched by the handoff.
729///
730/// TRACES: UR-040 | DR-052 | UT-061, IT-013
731#[tauri::command]
732#[specta::specta]
733pub async fn player_enter_background_audio(
734    player: State<'_, PlayerStateWrapper>,
735    session: State<'_, MediaSessionManagerWrapper>,
736    db: State<'_, DatabaseWrapper>,
737    item: PlayItemRequest,
738    position_seconds: f64,
739) -> Result<PlayerStatus, String> {
740    info!(
741        "player_enter_background_audio: {} @ {:.1}s",
742        item.title, position_seconds
743    );
744
745    // Prefer the downloaded file over the audio-only stream URL the frontend
746    // resolved. Handing the native backend a local video container yields
747    // audio-only playback for free — no transcode, no second artifact.
748    // TRACES: UR-071 | DR-128
749    let local_path = check_for_local_download(&db, &item.id).await?;
750    if local_path.is_some() {
751        info!(
752            "player_enter_background_audio: using downloaded file for {}",
753            item.id
754        );
755    }
756    // A downloaded file starts at the episode's zero; a stream starts at the
757    // handoff point. Only one of them has a base, and only the other needs a seek.
758    let plan = background_audio_plan(local_path.is_some(), position_seconds);
759    let source = background_audio_source(local_path, item.stream_url, &item.id);
760
761    // Build an AUDIO media item pointing at the audio-only stream. We do not use
762    // create_media_item() because that hardcodes MediaType::Video; background
763    // audio must be Audio so no video decode is started.
764    let media_item = MediaItem {
765        id: item.id.clone(),
766        title: item.title.clone(),
767        name: Some(item.title.clone()),
768        artist: item.artist.clone(),
769        album: None,
770        album_name: None,
771        album_id: None,
772        artist_items: None,
773        artists: None,
774        primary_image_tag: item.primary_image_tag.clone(),
775        image_id: item.primary_image_tag.clone(),
776        // Carry episode identity so autoplay can advance to the next episode when
777        // this audio-only handoff ends while backgrounded (UR-040).
778        item_type: item.item_type.clone(),
779        playlist_id: None,
780        // Carry the real duration so the lockscreen MediaSession can draw a scrubber.
781        duration: item.duration_seconds,
782        artwork_url: None,
783        media_type: MediaType::Audio,
784        source,
785        video_codec: None,
786        needs_transcoding: false,
787        video_width: None,
788        video_height: None,
789        subtitles: vec![],
790        series_id: item.series_id.clone(),
791        server_id: item.server_id.clone(),
792    };
793
794    {
795        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
796        session_mgr.start_audio_session(media_item.clone());
797    }
798
799    // Same base offset drives the lockscreen scrubber: ExoPlayer reports position
800    // relative to the stream's StartTimeTicks zero, but the metadata duration is
801    // absolute, so shift the reported position back to absolute for the scrubber.
802    let _ = crate::player::set_lockscreen_position_offset(plan.base_seconds);
803
804    let controller = player.0.lock().await;
805    // Remember where the video was: for a stream the audio's zero == this
806    // position (the URL was built with StartTimeTicks=position_seconds), so on
807    // exit we add this base to the native player's relative position to get the
808    // absolute one. The controller owns it so a backend-driven advance to the
809    // next episode clears it along with the stream it described.
810    controller.enter_background_audio(plan.base_seconds);
811    controller
812        .play_item(media_item)
813        .map_err(|e| e.to_string())?;
814    // Seek ONLY a local file. The audio-only URL already starts at the handoff
815    // position via StartTimeTicks — its timeline begins at 0 == that point — so
816    // seeking a stream would jump PAST the content by the handoff position again.
817    if let Some(seek_to) = plan.seek_to {
818        info!(
819            "player_enter_background_audio: seeking the downloaded file to {:.1}s",
820            seek_to
821        );
822        controller.seek(seek_to).map_err(|e| e.to_string())?;
823    }
824
825    controller.emit_queue_changed();
826    if let Some(emitter) = controller.event_emitter() {
827        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
828        emitter.emit(PlayerStatusEvent::SessionChanged {
829            session: session_mgr.current().clone(),
830        });
831    }
832
833    Ok(get_player_status(&controller))
834}
835
836/// Exit background-audio mode: stop the native audio player and return its final
837/// position so the frontend can reload the WebView `<video>` there (UR-040).
838///
839/// Returns the position in seconds. The sleep timer is intentionally left
840/// untouched — if it fired while backgrounded, playback is already stopped and
841/// this simply reports the last position.
842///
843/// What playback should do now that the app is no longer visible.
844///
845/// The caller supplies only what it alone knows -- whether the per-player
846/// toggle is armed, and whether Android put the window into picture-in-picture.
847/// Everything else (what is playing, and therefore whether there is a picture to
848/// lose) is read here, because it is domain state.
849///
850/// The rule itself is in `player::background_policy`; this command is the wire.
851/// Returning `KeepPlaying` for an empty queue is deliberate: with nothing
852/// playing there is nothing to pause, and an error would make the frontend
853/// handle a case that is not a failure.
854///
855/// TRACES: UR-040, UR-041 | DR-224 | UT-211
856#[tauri::command]
857#[specta::specta]
858pub async fn player_background_action(
859    player: State<'_, PlayerStateWrapper>,
860    background_audio_armed: bool,
861    in_picture_in_picture: bool,
862) -> Result<crate::player::background_policy::BackgroundAction, String> {
863    use crate::player::background_policy::{background_action, is_video_media, BackgroundAction};
864
865    let is_video = {
866        let controller = player.0.lock().await;
867        let queue_arc = controller.queue();
868        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
869        match queue.current() {
870            Some(item) => is_video_media(item.media_type),
871            None => return Ok(BackgroundAction::KeepPlaying),
872        }
873    };
874
875    let action = background_action(is_video, background_audio_armed, in_picture_in_picture);
876    info!(
877        "[player_background_action] video={} armed={} pip={} -> {:?}",
878        is_video, background_audio_armed, in_picture_in_picture, action
879    );
880    Ok(action)
881}
882
883/// TRACES: UR-040 | DR-052 | UT-061, IT-013
884#[tauri::command]
885#[specta::specta]
886pub async fn player_exit_background_audio(
887    player: State<'_, PlayerStateWrapper>,
888) -> Result<f64, String> {
889    let controller = player.0.lock().await;
890
891    // Read the position BEFORE clearing either base. The position tick applies the
892    // base natively, so a tick landing between "base cleared" and "position read"
893    // would hand back a relative position — the whole bug, reintroduced at the one
894    // moment it matters most. Capturing into a `let` before stop() is also the
895    // lock discipline from CLAUDE.md: never hold work across a re-entrant call.
896    // (DR-159)
897    //
898    // `absolute_position` rather than `position`, because a tick that has not
899    // landed *yet* is the same hazard from the other side: returning to the
900    // foreground while the audio-only transcode is still opening read 0.0, and
901    // the video reloaded at StartTimeTicks=0 — the episode restarting from the
902    // beginning. Flooring at the handoff base cannot overshoot: the stream is
903    // physically incapable of being behind its own starting point. (DR-178)
904    let absolute = controller.absolute_position();
905
906    // Now safe to tear the handoff down, native side first.
907    let _ = crate::player::set_lockscreen_position_offset(0.0);
908    controller.exit_background_audio();
909    controller.stop().map_err(|e| e.to_string())?;
910    info!(
911        "player_exit_background_audio: resuming the video at {:.1}s",
912        absolute
913    );
914    Ok(absolute)
915}
916
917/// Play a queue of media items
918///
919/// @req: UR-004 - Play audio uninterrupted
920/// @req: UR-005 - Control media playback (queue playback)
921/// @req: UR-015 - View and manage current audio queue
922/// @req: DR-005 - Queue manager with shuffle, repeat, history
923#[tauri::command]
924#[specta::specta]
925pub async fn player_play_queue(
926    player: State<'_, PlayerStateWrapper>,
927    session: State<'_, MediaSessionManagerWrapper>,
928    db: State<'_, DatabaseWrapper>,
929    request: PlayQueueRequest,
930) -> Result<PlayerStatus, String> {
931    info!(
932        "player_play_queue called: {} items, start_index: {}, shuffle: {}",
933        request.items.len(),
934        request.start_index,
935        request.shuffle
936    );
937
938    // Handle shuffle first
939    if request.shuffle {
940        let controller = player.0.lock().await;
941        controller.toggle_shuffle();
942    }
943
944    // Convert request context to internal QueueContext
945    let queue_context = match request.context {
946        Some(PlayQueueContext::Album {
947            album_id,
948            album_name,
949        }) => QueueContext::Album {
950            album_id,
951            album_name,
952        },
953        Some(PlayQueueContext::Playlist {
954            playlist_id,
955            playlist_name,
956        }) => QueueContext::Playlist {
957            playlist_id,
958            playlist_name,
959        },
960        Some(PlayQueueContext::Custom) | None => QueueContext::Custom,
961    };
962
963    // Create media items, checking for local downloads (must not hold locks during await)
964    let mut items: Vec<MediaItem> = Vec::new();
965    for req in request.items {
966        items.push(create_media_item(req, Some(&db)).await?);
967    }
968
969    // Start appropriate session based on first item's media type
970    if let Some(first_item) = items.get(request.start_index) {
971        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
972        match first_item.media_type {
973            MediaType::Audio => {
974                session_mgr.start_audio_session(first_item.clone());
975            }
976            MediaType::Video => {
977                // Queue of videos treated as movie session (TV episodes use different flow)
978                session_mgr.start_movie_session(first_item.clone());
979            }
980        }
981    }
982
983    // Now play the queue and get status
984    let controller = player.0.lock().await;
985    info!(
986        "player_play_queue: Calling controller.play_queue with {} items at index {}",
987        items.len(),
988        request.start_index
989    );
990    controller
991        .play_queue(items, request.start_index)
992        .map_err(|e| e.to_string())?;
993
994    // Set the queue context for remote transfer
995    {
996        let queue_arc = controller.queue();
997        let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
998        queue.set_context(queue_context);
999    }
1000
1001    // Emit queue changed event
1002    controller.emit_queue_changed();
1003
1004    // Emit session changed event
1005    if let Some(emitter) = controller.event_emitter() {
1006        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1007        emitter.emit(PlayerStatusEvent::SessionChanged {
1008            session: session_mgr.current().clone(),
1009        });
1010    }
1011
1012    Ok(get_player_status(&controller))
1013}
1014
1015#[tauri::command]
1016#[specta::specta]
1017pub async fn player_play(
1018    player: State<'_, PlayerStateWrapper>,
1019    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1020) -> Result<PlayerStatus, String> {
1021    // Check if we're in remote mode
1022    let mode = playback_mode.0.get_mode();
1023
1024    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1025        // Send play command to remote session - clone client before await
1026        let client = {
1027            let controller = player.0.lock().await;
1028            let client_arc = controller.jellyfin_client();
1029            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1030            client_opt
1031                .as_ref()
1032                .ok_or("Jellyfin client not configured")?
1033                .clone()
1034        };
1035        client.send_session_command(session_id, "Unpause").await?;
1036    } else {
1037        // Local playback
1038        let controller = player.0.lock().await;
1039        controller.play().map_err(|e| e.to_string())?;
1040    }
1041
1042    let controller = player.0.lock().await;
1043    Ok(get_player_status(&controller))
1044}
1045
1046#[tauri::command]
1047#[specta::specta]
1048pub async fn player_pause(
1049    player: State<'_, PlayerStateWrapper>,
1050    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1051) -> Result<PlayerStatus, String> {
1052    // Check if we're in remote mode
1053    let mode = playback_mode.0.get_mode();
1054
1055    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1056        // Send pause command to remote session - clone client before await
1057        let client = {
1058            let controller = player.0.lock().await;
1059            let client_arc = controller.jellyfin_client();
1060            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1061            client_opt
1062                .as_ref()
1063                .ok_or("Jellyfin client not configured")?
1064                .clone()
1065        };
1066        client.send_session_command(session_id, "Pause").await?;
1067    } else {
1068        // Local playback
1069        let controller = player.0.lock().await;
1070        controller.pause().map_err(|e| e.to_string())?;
1071    }
1072
1073    let controller = player.0.lock().await;
1074    Ok(get_player_status(&controller))
1075}
1076
1077#[tauri::command]
1078#[specta::specta]
1079pub async fn player_toggle(
1080    player: State<'_, PlayerStateWrapper>,
1081    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1082) -> Result<PlayerStatus, String> {
1083    // Check if we're in remote mode
1084    let mode = playback_mode.0.get_mode();
1085
1086    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1087        // Send toggle command to remote session - clone client before await
1088        let client = {
1089            let controller = player.0.lock().await;
1090            let client_arc = controller.jellyfin_client();
1091            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1092            client_opt
1093                .as_ref()
1094                .ok_or("Jellyfin client not configured")?
1095                .clone()
1096        };
1097        client.send_session_command(session_id, "PlayPause").await?;
1098    } else {
1099        // Local playback
1100        let controller = player.0.lock().await;
1101        controller.toggle_playback().map_err(|e| e.to_string())?;
1102    }
1103
1104    let controller = player.0.lock().await;
1105    Ok(get_player_status(&controller))
1106}
1107
1108#[tauri::command]
1109#[specta::specta]
1110pub async fn player_stop(
1111    player: State<'_, PlayerStateWrapper>,
1112    session: State<'_, MediaSessionManagerWrapper>,
1113    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1114) -> Result<PlayerStatus, String> {
1115    // Check if we're in remote mode
1116    let mode = playback_mode.0.get_mode();
1117
1118    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1119        // Send stop command to remote session - clone client before await
1120        let client = {
1121            let controller = player.0.lock().await;
1122            let client_arc = controller.jellyfin_client();
1123            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1124            client_opt
1125                .as_ref()
1126                .ok_or("Jellyfin client not configured")?
1127                .clone()
1128        };
1129        client.send_session_command(session_id, "Stop").await?;
1130
1131        // Stopping the remote session ends the cast, so the manager returns to
1132        // Idle — same as a local stop. This is also what hands OS volume control
1133        // back to this device: set_mode releases the Android remote volume
1134        // provider on any exit from remote mode. Without it the mode stayed
1135        // Remote and the system volume slider remained stuck on the remote
1136        // session with no way back to the local speaker.
1137        playback_mode
1138            .0
1139            .set_mode(crate::playback_mode::PlaybackMode::Idle);
1140    } else {
1141        // Local playback
1142        let controller = player.0.lock().await;
1143        controller.stop().map_err(|e| e.to_string())?;
1144
1145        // A genuine local stop returns the manager to Idle so it no longer
1146        // reports Local (or a stale Remote) — otherwise a later play/pause would
1147        // route to the wrong device.
1148        playback_mode
1149            .0
1150            .set_mode(crate::playback_mode::PlaybackMode::Idle);
1151
1152        // Handle session state based on type (local playback only)
1153        {
1154            let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1155            let current_session = session_mgr.current().clone();
1156
1157            match current_session {
1158                crate::player::MediaSessionType::Movie { .. } => {
1159                    // Movies auto-dismiss when stopped
1160                    session_mgr.movie_session_ended();
1161                }
1162                crate::player::MediaSessionType::Audio { .. } => {
1163                    // Audio persists as inactive (user can resume later)
1164                    session_mgr.audio_session_inactive();
1165                }
1166                crate::player::MediaSessionType::TvShow { .. } => {
1167                    // TV shows mark episode ended (waiting for next or dismiss)
1168                    session_mgr.tv_session_episode_ended();
1169                }
1170                crate::player::MediaSessionType::Idle => {
1171                    // Already idle, no-op
1172                }
1173            }
1174
1175            // Emit session changed event
1176            if let Some(emitter) = controller.event_emitter() {
1177                emitter.emit(PlayerStatusEvent::SessionChanged {
1178                    session: session_mgr.current().clone(),
1179                });
1180            }
1181        }
1182    }
1183
1184    let controller = player.0.lock().await;
1185    Ok(get_player_status(&controller))
1186}
1187
1188#[tauri::command]
1189#[specta::specta]
1190pub async fn player_next(
1191    player: State<'_, PlayerStateWrapper>,
1192    session: State<'_, MediaSessionManagerWrapper>,
1193    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1194    db: State<'_, DatabaseWrapper>,
1195) -> Result<PlayerStatus, String> {
1196    debug!("[player_next] Command called from frontend");
1197
1198    // Check if we're in remote mode
1199    let mode = playback_mode.0.get_mode();
1200
1201    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1202        // Send next track command to remote session - clone client before await
1203        let client = {
1204            let controller = player.0.lock().await;
1205            let client_arc = controller.jellyfin_client();
1206            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1207            client_opt
1208                .as_ref()
1209                .ok_or("Jellyfin client not configured")?
1210                .clone()
1211        };
1212        client.send_session_command(session_id, "NextTrack").await?;
1213    } else {
1214        // Local playback
1215        let controller = player.0.lock().await;
1216        // Prefer downloads that completed since the queue was built
1217        if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
1218            warn!("[player_next] Failed to refresh local sources: {}", e);
1219        }
1220        controller.next().map_err(|e| e.to_string())?;
1221        controller.emit_queue_changed();
1222
1223        // Update audio session track if in audio session
1224        let current_item = {
1225            let queue = controller.queue();
1226            let queue_lock = queue.lock().map_err(|e| e.to_string())?;
1227            queue_lock.current().cloned()
1228        };
1229
1230        if let Some(item) = current_item {
1231            if item.media_type == MediaType::Audio {
1232                let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1233                session_mgr.update_audio_track(item);
1234
1235                // Emit session changed event
1236                if let Some(emitter) = controller.event_emitter() {
1237                    emitter.emit(PlayerStatusEvent::SessionChanged {
1238                        session: session_mgr.current().clone(),
1239                    });
1240                }
1241            }
1242        }
1243    }
1244
1245    let controller = player.0.lock().await;
1246    Ok(get_player_status(&controller))
1247}
1248
1249#[tauri::command]
1250#[specta::specta]
1251pub async fn player_previous(
1252    player: State<'_, PlayerStateWrapper>,
1253    session: State<'_, MediaSessionManagerWrapper>,
1254    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1255    db: State<'_, DatabaseWrapper>,
1256) -> Result<PlayerStatus, String> {
1257    // Check if we're in remote mode
1258    let mode = playback_mode.0.get_mode();
1259
1260    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1261        // Send previous track command to remote session - clone client before await
1262        let client = {
1263            let controller = player.0.lock().await;
1264            let client_arc = controller.jellyfin_client();
1265            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1266            client_opt
1267                .as_ref()
1268                .ok_or("Jellyfin client not configured")?
1269                .clone()
1270        };
1271        client
1272            .send_session_command(session_id, "PreviousTrack")
1273            .await?;
1274    } else {
1275        // Local playback
1276        let controller = player.0.lock().await;
1277        // Prefer downloads that completed since the queue was built
1278        if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
1279            warn!("[player_previous] Failed to refresh local sources: {}", e);
1280        }
1281        controller.previous().map_err(|e| e.to_string())?;
1282        controller.emit_queue_changed();
1283
1284        // Update audio session track if in audio session
1285        let current_item = {
1286            let queue = controller.queue();
1287            let queue_lock = queue.lock().map_err(|e| e.to_string())?;
1288            queue_lock.current().cloned()
1289        };
1290
1291        if let Some(item) = current_item {
1292            if item.media_type == MediaType::Audio {
1293                let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1294                session_mgr.update_audio_track(item);
1295
1296                // Emit session changed event
1297                if let Some(emitter) = controller.event_emitter() {
1298                    emitter.emit(PlayerStatusEvent::SessionChanged {
1299                        session: session_mgr.current().clone(),
1300                    });
1301                }
1302            }
1303        }
1304    }
1305
1306    let controller = player.0.lock().await;
1307    Ok(get_player_status(&controller))
1308}
1309
1310#[tauri::command]
1311#[specta::specta]
1312pub async fn player_seek(
1313    player: State<'_, PlayerStateWrapper>,
1314    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1315    position: f64,
1316) -> Result<PlayerStatus, String> {
1317    // Check if we're in remote mode
1318    let mode = playback_mode.0.get_mode();
1319
1320    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1321        // Send seek command to remote session - clone client before await
1322        let client = {
1323            let controller = player.0.lock().await;
1324            let client_arc = controller.jellyfin_client();
1325            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1326            client_opt
1327                .as_ref()
1328                .ok_or("Jellyfin client not configured")?
1329                .clone()
1330        };
1331        let position_ticks = (position * 10_000_000.0) as i64;
1332        client.session_seek(session_id, position_ticks).await?;
1333    } else {
1334        // Local playback. seek_absolute, not seek: the position came from the UI,
1335        // which shows the whole item, so during a background-audio handoff it has
1336        // to be resolved against the episode's timeline rather than the handoff
1337        // stream's. (DR-159)
1338        let controller = player.0.lock().await;
1339        controller.seek_absolute(position).await?;
1340    }
1341
1342    let controller = player.0.lock().await;
1343    Ok(get_player_status(&controller))
1344}
1345
1346/// Smart video seeking that decides between native and server-side seeking
1347///
1348/// This command analyzes the current video stream and automatically chooses
1349/// the best seeking strategy:
1350/// - HLS streams (.m3u8): Use native seeking
1351/// - Direct play streams: Use native seeking
1352/// - Transcoded non-HLS: Request new stream URL from server starting at seek position
1353///
1354/// For native (non-HTML5) backends, this command handles the entire stream reload
1355/// internally. For HTML5 backends, it returns the new URL for the frontend to handle.
1356#[tauri::command]
1357#[specta::specta]
1358pub async fn player_seek_video(
1359    player: State<'_, PlayerStateWrapper>,
1360    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1361    repository_handle: String,
1362    position: f64,
1363    media_source_id: Option<String>,
1364    audio_stream_index: Option<i32>,
1365    use_html5: bool,
1366) -> Result<VideoSeekResponse, String> {
1367    info!(
1368        "[player_seek_video] Seeking to {} seconds (use_html5: {})",
1369        position, use_html5
1370    );
1371
1372    // Get repository
1373    let repository = repository_manager
1374        .0
1375        .get(&repository_handle)
1376        .ok_or("Repository not found - user may need to log in")?;
1377
1378    // Get current playing item to analyze stream characteristics
1379    // Clone what we need to avoid holding locks across await points
1380    let (needs_transcoding, jellyfin_item_id, stream_url, is_local) = {
1381        let controller = player.0.lock().await;
1382        let queue_arc = controller.queue();
1383        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1384
1385        let current_item = queue.current().ok_or("No item currently playing")?;
1386
1387        if current_item.media_type != MediaType::Video {
1388            return Err("Current item is not a video".to_string());
1389        }
1390
1391        let jellyfin_id = current_item
1392            .jellyfin_id()
1393            .ok_or("Current video has no Jellyfin ID")?
1394            .to_string();
1395
1396        let (stream_url, is_local_file) = match &current_item.source {
1397            MediaSource::Remote { stream_url, .. } => (stream_url.clone(), false),
1398            MediaSource::Local { .. } => (String::new(), true),
1399            MediaSource::DirectUrl { url } => (url.clone(), false),
1400        };
1401
1402        let needs_trans = current_item.needs_transcoding;
1403        (needs_trans, jellyfin_id, stream_url, is_local_file)
1404    }; // Locks are dropped here
1405
1406    // Determine seek strategy using the testable helper function
1407    let is_hls = stream_url.contains(".m3u8");
1408    let strategy = determine_video_seek_strategy(is_local, is_hls, needs_transcoding, use_html5);
1409
1410    info!("[player_seek_video] Stream analysis: is_local={}, is_hls={}, needs_transcoding={}, use_html5={}, strategy={:?}",
1411        is_local, is_hls, needs_transcoding, use_html5, strategy);
1412
1413    match strategy {
1414        VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {
1415            // Local files and native backend streams - call backend.seek()
1416            info!("[player_seek_video] Using backend native seek");
1417            let controller = player.0.lock().await;
1418            controller.seek(position).map_err(|e| e.to_string())?;
1419            Ok(VideoSeekResponse::Native { position })
1420        }
1421        VideoSeekStrategy::Html5NativeSeek => {
1422            // HTML5 backend with HLS or direct play - frontend handles seeking
1423            // We don't call backend.seek() because video is in HTML5 element, not in MPV
1424            info!("[player_seek_video] HTML5 native seek - returning position for frontend");
1425            Ok(VideoSeekResponse::Native { position })
1426        }
1427        VideoSeekStrategy::Html5ReloadStream => {
1428            // Transcoded non-HLS with HTML5 - frontend handles stream reload
1429            info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");
1430
1431            let new_url = repository
1432                .get_video_stream_url(
1433                    &jellyfin_item_id,
1434                    media_source_id.as_deref(),
1435                    audio_stream_index,
1436                )
1437                .await
1438                .map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
1439
1440            info!(
1441                "[player_seek_video] Got new stream URL for position {}",
1442                position
1443            );
1444
1445            // `seek_offset` carries the position to RESUME AT, not a base to add
1446            // to the element's clock. The reloaded stream starts at the item's
1447            // zero — a position on an HLS playlist makes the server 400 every
1448            // segment behind it (DR-181) — so the adapter reaches the position by
1449            // seeking the element and leaves the transcode offset at zero. The
1450            // field keeps its name only because renaming it means regenerating
1451            // the specta bindings; `reloadSource` documents the contract.
1452            Ok(VideoSeekResponse::ReloadStream {
1453                new_url,
1454                seek_offset: position,
1455            })
1456        }
1457        VideoSeekStrategy::BackendReloadStream => {
1458            // Transcoded non-HLS with native backend - backend handles stream reload
1459            info!("[player_seek_video] Backend reload stream - requesting new stream URL");
1460
1461            let new_url = repository
1462                .get_video_stream_url(
1463                    &jellyfin_item_id,
1464                    media_source_id.as_deref(),
1465                    audio_stream_index,
1466                )
1467                .await
1468                .map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
1469
1470            info!("[player_seek_video] Got new stream URL, handling reload internally");
1471
1472            // Stop current playback
1473            {
1474                let controller = player.0.lock().await;
1475                controller.stop().map_err(|e| e.to_string())?;
1476            }
1477
1478            // Update the stream URL in the queue
1479            {
1480                let controller = player.0.lock().await;
1481                let queue_arc = controller.queue();
1482                let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1483
1484                if !queue.update_current_stream_url(new_url.clone()) {
1485                    return Err("Failed to update stream URL in queue".to_string());
1486                }
1487            }
1488
1489            // Reload the player with the updated item from queue
1490            {
1491                let controller = player.0.lock().await;
1492                let queue_arc = controller.queue();
1493                let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1494
1495                if let Some(updated_item) = queue.current() {
1496                    controller
1497                        .load_and_play(updated_item)
1498                        .map_err(|e| e.to_string())?;
1499                } else {
1500                    return Err("No current item after URL update".to_string());
1501                }
1502
1503                // The re-opened stream begins at zero — the position cannot ride
1504                // along in the URL without 400ing every segment (DR-181) — so the
1505                // seek that the reload was asked for happens here.
1506                controller.seek(position).map_err(|e| e.to_string())?;
1507            }
1508
1509            info!(
1510                "[player_seek_video] Stream reloaded successfully at position {}",
1511                position
1512            );
1513
1514            Ok(VideoSeekResponse::Native { position })
1515        }
1516    }
1517}
1518
1519/// Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
1520/// Note: Frontend should handle saving series preferences after this command succeeds
1521///
1522/// The split is the requirement: an HTML5 `<video>` element cannot be told to
1523/// change audio track, so the stream is re-opened at the chosen
1524/// `AudioStreamIndex` and the frontend seeks the reloaded element back to
1525/// `position`; a native backend (ExoPlayer) switches in place by track-group
1526/// index. libmpv implements neither — it is the audio-only backend here and
1527/// leaves `PlayerBackend::set_audio_track` at its `not_implemented()` default,
1528/// which is why IR-019 is met by these two paths rather than by MPV.
1529///
1530/// TRACES: UR-021 | IR-019, DR-024
1531#[tauri::command]
1532#[specta::specta]
1533// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
1534// input. Folding the rest into a struct would change the IPC contract and the
1535// generated TypeScript for no readability gain.
1536#[allow(clippy::too_many_arguments)]
1537pub async fn player_switch_audio_track(
1538    player: State<'_, PlayerStateWrapper>,
1539    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1540    repository_handle: String,
1541    stream_index: i32,
1542    array_index: i32,
1543    use_html5: bool,
1544    current_position: Option<f64>,
1545    media_source_id: Option<String>,
1546) -> Result<AudioTrackSwitchResponse, String> {
1547    info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
1548        stream_index, array_index, use_html5);
1549
1550    if use_html5 {
1551        // HTML5 backend needs stream reload
1552        let repository = repository_manager
1553            .0
1554            .get(&repository_handle)
1555            .ok_or("Repository not found - user may need to log in")?;
1556
1557        // Get current item to find Jellyfin ID
1558        let jellyfin_item_id = {
1559            let controller = player.0.lock().await;
1560            let queue_arc = controller.queue();
1561            let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1562
1563            let current_item = queue.current().ok_or("No item currently playing")?;
1564
1565            current_item
1566                .jellyfin_id()
1567                .ok_or("Current item has no Jellyfin ID")?
1568                .to_string()
1569        };
1570
1571        // Get new stream URL with selected audio track. It starts at zero — an
1572        // HLS playlist cannot carry a position (DR-181) — and `position` below
1573        // tells the frontend where to seek the reloaded element back to.
1574        let new_url = repository
1575            .get_video_stream_url(
1576                &jellyfin_item_id,
1577                media_source_id.as_deref(),
1578                Some(stream_index),
1579            )
1580            .await
1581            .map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
1582
1583        Ok(AudioTrackSwitchResponse::ReloadStream {
1584            new_url,
1585            position: current_position.unwrap_or(0.0),
1586        })
1587    } else {
1588        // Native backend (Android ExoPlayer) - use array index
1589        let controller = player.0.lock().await;
1590        controller
1591            .set_audio_track(array_index)
1592            .map_err(|e| e.to_string())?;
1593
1594        Ok(AudioTrackSwitchResponse::Native { success: true })
1595    }
1596}
1597
1598/// Change the bandwidth ceiling of the video that is playing *right now*.
1599///
1600/// A cap is a property of the stream the server is producing, so unlike a volume
1601/// change it cannot be applied to a stream already in flight — the stream has to
1602/// be re-opened at the new quality and resumed at the current position. That is
1603/// the same reload the transcoded-seek and audio-track paths use, and the same
1604/// two-sided split: HTML5 gets the URL back and reloads its own element, while a
1605/// native backend is reloaded here.
1606///
1607/// The change applies to this playback *and* to everything started afterwards
1608/// (it sets the process-wide ceiling), but it is deliberately **not** persisted:
1609/// the in-player picker is a "this film, this connection" control, and the
1610/// durable default belongs to Settings. `player_set_video_settings` is the one
1611/// that writes to the database.
1612///
1613/// TRACES: UR-074 | DR-162
1614#[tauri::command]
1615#[specta::specta]
1616// Three of the nine arguments are Tauri `State<'_, _>` injections, not caller
1617// input. Folding the rest into a struct would change the IPC contract and the
1618// generated TypeScript for no readability gain.
1619#[allow(clippy::too_many_arguments)]
1620pub async fn player_set_stream_quality(
1621    player: State<'_, PlayerStateWrapper>,
1622    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1623    video_settings: State<'_, VideoSettingsWrapper>,
1624    repository_handle: String,
1625    quality: crate::settings::StreamingQuality,
1626    use_html5: bool,
1627    current_position: Option<f64>,
1628    media_source_id: Option<String>,
1629    audio_stream_index: Option<i32>,
1630) -> Result<StreamQualityResponse, String> {
1631    info!(
1632        "[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})",
1633        quality.label(),
1634        use_html5,
1635        current_position
1636    );
1637
1638    let repository = repository_manager
1639        .0
1640        .get(&repository_handle)
1641        .ok_or("Repository not found - user may need to log in")?;
1642
1643    let jellyfin_item_id = {
1644        let controller = player.0.lock().await;
1645        let queue_arc = controller.queue();
1646        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1647
1648        let current_item = queue.current().ok_or("No item currently playing")?;
1649
1650        if current_item.media_type != MediaType::Video {
1651            return Err("Current item is not a video".to_string());
1652        }
1653
1654        current_item
1655            .jellyfin_id()
1656            .ok_or("Current item has no Jellyfin ID")?
1657            .to_string()
1658    };
1659
1660    // Set the ceiling *before* building the URL — the builder reads it.
1661    crate::repository::online::set_streaming_quality(quality);
1662    {
1663        let mut settings = video_settings.0.lock().map_err(|e| e.to_string())?;
1664        settings.streaming_quality = quality;
1665    }
1666
1667    let position = current_position.unwrap_or(0.0);
1668    let new_url = repository
1669        .get_video_stream_url(
1670            &jellyfin_item_id,
1671            media_source_id.as_deref(),
1672            audio_stream_index,
1673        )
1674        .await
1675        .map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
1676
1677    if use_html5 {
1678        return Ok(StreamQualityResponse::ReloadStream { new_url, position });
1679    }
1680
1681    // Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
1682    // new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
1683    // The re-opened stream begins at zero (an HLS playlist cannot carry a start
1684    // position without 400ing every segment — DR-181), so it is seeked back to
1685    // where the picture was.
1686    {
1687        let controller = player.0.lock().await;
1688        controller.stop().map_err(|e| e.to_string())?;
1689
1690        let queue_arc = controller.queue();
1691        {
1692            let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1693            if !queue.update_current_stream_url(new_url.clone()) {
1694                return Err("Failed to update stream URL in queue".to_string());
1695            }
1696        }
1697
1698        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1699        let updated_item = queue.current().ok_or("No current item after URL update")?;
1700        controller
1701            .load_and_play(updated_item)
1702            .map_err(|e| e.to_string())?;
1703        if position > 0.0 {
1704            controller.seek(position).map_err(|e| e.to_string())?;
1705        }
1706    }
1707
1708    Ok(StreamQualityResponse::Native { position })
1709}
1710
1711/// Set the active audio track on a native backend directly.
1712///
1713/// TRACES: UR-021 | IR-019, DR-024
1714#[tauri::command]
1715#[specta::specta]
1716pub async fn player_set_audio_track(
1717    player: State<'_, PlayerStateWrapper>,
1718    stream_index: i32,
1719) -> Result<PlayerStatus, String> {
1720    let controller = player.0.lock().await;
1721    controller
1722        .set_audio_track(stream_index)
1723        .map_err(|e| e.to_string())?;
1724    Ok(get_player_status(&controller))
1725}
1726
1727/// Set (or clear, with `None`) the active subtitle track on a native backend.
1728///
1729/// On Android this indexes ExoPlayer's *text track groups* — i.e. the position
1730/// of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
1731/// index. The HTML5 path never reaches here; it toggles its own `<track>`
1732/// children. libmpv implements neither, leaving the trait default in place.
1733///
1734/// TRACES: UR-020 | IR-018, DR-023
1735#[tauri::command]
1736#[specta::specta]
1737pub async fn player_set_subtitle_track(
1738    player: State<'_, PlayerStateWrapper>,
1739    stream_index: Option<i32>,
1740) -> Result<PlayerStatus, String> {
1741    let controller = player.0.lock().await;
1742    controller
1743        .set_subtitle_track(stream_index)
1744        .map_err(|e| e.to_string())?;
1745    Ok(get_player_status(&controller))
1746}
1747
1748/// Normalise a volume arriving over IPC to the 0.0..=1.0 range every backend
1749/// works in.
1750///
1751/// NaN is handled before the clamp rather than by it: `f32::clamp` returns NaN
1752/// for a NaN input (it only panics on NaN *bounds*), and NaN then survives every
1753/// comparison downstream, so a backend clamp cannot catch it either. It is
1754/// treated as "no volume asked for" and floored to 0.0.
1755///
1756/// TRACES: DR-212 | UT-206
1757fn normalize_volume(volume: f32) -> f32 {
1758    if volume.is_nan() {
1759        0.0
1760    } else {
1761        volume.clamp(0.0, 1.0)
1762    }
1763}
1764
1765#[tauri::command]
1766#[specta::specta]
1767pub async fn player_set_volume(
1768    player: State<'_, PlayerStateWrapper>,
1769    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1770    volume: f32,
1771) -> Result<PlayerStatus, String> {
1772    // Clamp at the boundary as well as in each backend: the remote branch below
1773    // never reaches a backend clamp, and `(f32::INFINITY * 100.0) as i32` would
1774    // hand the server i32::MAX as a volume percentage.
1775    // TRACES: DR-212 | UT-206
1776    let volume = normalize_volume(volume);
1777
1778    // Check if we're in remote mode
1779    let mode = playback_mode.0.get_mode();
1780
1781    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1782        // Send volume command to remote session - clone client before await
1783        let client = {
1784            let controller = player.0.lock().await;
1785            let client_arc = controller.jellyfin_client();
1786            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1787            client_opt
1788                .as_ref()
1789                .ok_or("Jellyfin client not configured")?
1790                .clone()
1791        };
1792        // Convert 0-1 range to 0-100 for Jellyfin API
1793        let volume_percent = (volume * 100.0) as i32;
1794        client
1795            .session_set_volume(session_id, volume_percent)
1796            .await?;
1797    } else {
1798        // Local playback
1799        let controller = player.0.lock().await;
1800        controller.set_volume(volume).map_err(|e| e.to_string())?;
1801    }
1802
1803    let controller = player.0.lock().await;
1804    Ok(get_player_status(&controller))
1805}
1806
1807#[tauri::command]
1808#[specta::specta]
1809pub async fn player_toggle_mute(
1810    player: State<'_, PlayerStateWrapper>,
1811    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1812) -> Result<PlayerStatus, String> {
1813    // Check if we're in remote mode
1814    let mode = playback_mode.0.get_mode();
1815
1816    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1817        // Send toggle mute command to remote session - clone client before await
1818        let client = {
1819            let controller = player.0.lock().await;
1820            let client_arc = controller.jellyfin_client();
1821            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1822            client_opt
1823                .as_ref()
1824                .ok_or("Jellyfin client not configured")?
1825                .clone()
1826        };
1827        client
1828            .send_session_command(session_id, "ToggleMute")
1829            .await?;
1830    } else {
1831        // Local playback
1832        // TODO: Implement toggle_mute in PlayerController
1833        // let controller = player.0.lock().await;
1834        // controller.toggle_mute().map_err(|e| e.to_string())?;
1835    }
1836
1837    let controller = player.0.lock().await;
1838    Ok(get_player_status(&controller))
1839}
1840
1841#[tauri::command]
1842#[specta::specta]
1843pub async fn player_toggle_shuffle(
1844    player: State<'_, PlayerStateWrapper>,
1845) -> Result<QueueStatus, String> {
1846    let controller = player.0.lock().await;
1847    controller.toggle_shuffle();
1848    controller.emit_queue_changed();
1849    Ok(get_queue_status(&controller))
1850}
1851
1852#[tauri::command]
1853#[specta::specta]
1854pub async fn player_cycle_repeat(
1855    player: State<'_, PlayerStateWrapper>,
1856) -> Result<QueueStatus, String> {
1857    let controller = player.0.lock().await;
1858    controller.cycle_repeat();
1859    controller.emit_queue_changed();
1860    Ok(get_queue_status(&controller))
1861}
1862
1863#[tauri::command]
1864#[specta::specta]
1865pub async fn player_get_status(
1866    player: State<'_, PlayerStateWrapper>,
1867    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1868) -> Result<PlayerStatus, String> {
1869    let mode = playback_mode.0.get_mode();
1870
1871    // Get base local status
1872    let controller = player.0.lock().await;
1873    let mut status = get_player_status(&controller);
1874
1875    // Get local media from queue
1876    let local_media = {
1877        let queue_arc = controller.queue();
1878        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1879        queue.current().map(MergedMediaItem::from)
1880    };
1881
1882    let local_is_playing = status.state.is_playing();
1883    let local_volume = status.volume;
1884
1885    // If in remote mode, fetch session and merge state
1886    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1887        let client = {
1888            let client_arc = controller.jellyfin_client();
1889            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1890            client_opt
1891                .as_ref()
1892                .ok_or("Jellyfin client not configured")?
1893                .clone()
1894        };
1895
1896        drop(controller); // Release lock before async call
1897
1898        match client.get_session(&session_id).await {
1899            Ok(Some(session)) => {
1900                log::info!("[PlayerCommands] Merging remote session state");
1901
1902                // Merge media item
1903                status.merged_media = session.now_playing_item.as_ref().map(MergedMediaItem::from);
1904
1905                // Merge isPlaying (NOT isPaused!)
1906                status.merged_is_playing = session
1907                    .play_state
1908                    .as_ref()
1909                    .map(|ps| !ps.is_paused.unwrap_or(true))
1910                    .unwrap_or(false);
1911
1912                // Merge position (convert ticks to seconds)
1913                status.position = session
1914                    .play_state
1915                    .as_ref()
1916                    .and_then(|ps| ps.position_ticks)
1917                    .map(|ticks| ticks as f64 / 10_000_000.0)
1918                    .unwrap_or(0.0);
1919
1920                // Merge duration (convert ticks to seconds)
1921                status.duration = session
1922                    .now_playing_item
1923                    .as_ref()
1924                    .and_then(|item| item.run_time_ticks)
1925                    .map(|ticks| ticks as f64 / 10_000_000.0);
1926
1927                // Merge volume (convert 0-100 → 0-1)
1928                status.merged_volume = session
1929                    .play_state
1930                    .as_ref()
1931                    .and_then(|ps| ps.volume_level)
1932                    .map(|vol| (vol.clamp(0, 100) as f32) / 100.0)
1933                    .unwrap_or(1.0);
1934
1935                return Ok(status);
1936            }
1937            Ok(None) => {
1938                log::warn!("[PlayerCommands] Remote session not found, using local state");
1939            }
1940            Err(e) => {
1941                log::warn!("[PlayerCommands] Failed to fetch remote session: {}", e);
1942            }
1943        }
1944    }
1945
1946    // Local playback or fallback
1947    status.merged_media = local_media;
1948    status.merged_is_playing = local_is_playing;
1949    status.merged_volume = local_volume;
1950
1951    Ok(status)
1952}
1953
1954#[tauri::command]
1955#[specta::specta]
1956pub async fn player_get_queue(
1957    player: State<'_, PlayerStateWrapper>,
1958) -> Result<QueueStatus, String> {
1959    let controller = player.0.lock().await;
1960    Ok(get_queue_status(&controller))
1961}
1962
1963/// What playback facilities this platform's backend actually provides.
1964///
1965/// The frontend is presentation-only and must not re-derive backend facts from
1966/// `navigator.userAgent` — that sniffing was a second copy of the same platform
1967/// decision Rust already makes with `cfg!`, and it drifted. These flags are the
1968/// single source of truth; the frontend consumes them.
1969///
1970/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
1971#[derive(specta::Type, Debug, Serialize)]
1972#[serde(rename_all = "camelCase")]
1973pub struct PlaybackCapabilities {
1974    /// True when audio is rendered by a webview `<audio>` element rather than a
1975    /// native backend. Native audio exists on Linux (mpv) and Android
1976    /// (ExoPlayer); everything else (Windows, future desktops) uses the webview.
1977    pub uses_webview_audio: bool,
1978    /// True when video can be rendered by a native surface composited *behind*
1979    /// a transparent webview. Android only: ExoPlayer draws into a SurfaceView
1980    /// beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
1981    /// compositing), so it stays on the HTML5 element.
1982    pub supports_native_video: bool,
1983}
1984
1985/// Report this platform's playback capabilities to the frontend.
1986///
1987/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
1988#[tauri::command]
1989#[specta::specta]
1990pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
1991    // Mirrors the cfg gates the backends themselves are built under.
1992    let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
1993
1994    Ok(PlaybackCapabilities {
1995        uses_webview_audio: !native_audio,
1996        supports_native_video: cfg!(target_os = "android"),
1997    })
1998}
1999
2000pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
2001    // Determine backend at compile time based on platform
2002    let (backend, use_html5_element) = if cfg!(target_os = "android") {
2003        // Android uses ExoPlayer native backend
2004        (VideoBackend::Native, false)
2005    } else {
2006        // Linux and other platforms use HTML5 video element in frontend
2007        (VideoBackend::Html5, true)
2008    };
2009
2010    PlayerStatus {
2011        state: controller.state(),
2012        // The position on the item's timeline, whichever of the three paths is
2013        // rendering it — the native backend answers for only one of them, and
2014        // reads 0 for webview video and for a handoff that has not ticked yet.
2015        // TRACES: UR-005 | DR-178
2016        position: controller.absolute_position(),
2017        duration: controller.duration(),
2018        volume: controller.volume(),
2019        muted: controller.muted(),
2020        shuffle: controller.is_shuffle(),
2021        repeat: controller.repeat_mode(),
2022        backend,
2023        use_html5_element,
2024
2025        // Merged fields initialized to defaults (will be set by player_get_status)
2026        merged_media: None,
2027        merged_is_playing: false,
2028        merged_volume: controller.volume(),
2029    }
2030}
2031
2032pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus {
2033    let queue = controller.queue();
2034    let queue_lock = queue.lock_safe();
2035
2036    QueueStatus {
2037        items: queue_lock.items().to_vec(),
2038        current_index: queue_lock.current_index(),
2039        shuffle: queue_lock.is_shuffle(),
2040        repeat: queue_lock.repeat_mode(),
2041        has_next: queue_lock.has_next(),
2042        has_previous: queue_lock.has_previous(),
2043    }
2044}
2045
2046/// Start a freshly-built queue on the active remote session.
2047///
2048/// Used by the "play tracks"/"play album track" commands when we're in remote
2049/// mode: instead of starting local MPV playback, we cast the selected tracks to
2050/// the remote device. Mirrors PlaybackModeManager::transfer_to_remote's
2051/// play_on_session call, but for a brand-new selection (so there's no resume
2052/// position - playback starts from the chosen track's beginning).
2053///
2054/// Local-only items (no Jellyfin ID) can't be cast, so they're filtered out and
2055/// the start index is adjusted to the remaining Jellyfin items. Returns an error
2056/// if the selected track itself has no Jellyfin ID.
2057async fn play_selection_on_remote(
2058    controller: &PlayerController,
2059    session_id: &str,
2060    media_items: &[MediaItem],
2061    start_index: usize,
2062) -> Result<(), String> {
2063    // Collect Jellyfin IDs, tracking where the selected track lands after any
2064    // local-only items are dropped.
2065    let mut jellyfin_ids: Vec<String> = Vec::new();
2066    let mut adjusted_index: Option<usize> = None;
2067    for (i, item) in media_items.iter().enumerate() {
2068        if let Some(id) = item.jellyfin_id() {
2069            if i == start_index {
2070                adjusted_index = Some(jellyfin_ids.len());
2071            }
2072            jellyfin_ids.push(id.to_string());
2073        }
2074    }
2075
2076    let start_index =
2077        adjusted_index.ok_or("Cannot play on remote: selected track is not from Jellyfin")?;
2078
2079    if jellyfin_ids.is_empty() {
2080        return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string());
2081    }
2082
2083    let client = {
2084        let client_arc = controller.jellyfin_client();
2085        let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
2086        client_opt
2087            .as_ref()
2088            .ok_or("Jellyfin client not configured")?
2089            .clone()
2090    };
2091
2092    // Fresh selection: start from the beginning of the chosen track.
2093    client
2094        .play_on_session(session_id.to_string(), jellyfin_ids, start_index, None)
2095        .await
2096        .map_err(|e| format!("Failed to start playback on remote session: {}", e))
2097}
2098
2099/// Play a track from an album - backend fetches all album tracks and builds queue
2100#[tauri::command]
2101#[specta::specta]
2102pub async fn player_play_album_track(
2103    player: State<'_, PlayerStateWrapper>,
2104    session: State<'_, MediaSessionManagerWrapper>,
2105    db: State<'_, DatabaseWrapper>,
2106    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
2107    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2108    repository_handle: String,
2109    request: PlayAlbumTrackRequest,
2110) -> Result<PlayerStatus, String> {
2111    info!(
2112        "player_play_album_track called: album_id={}, track_id={}, shuffle={}",
2113        request.album_id, request.track_id, request.shuffle
2114    );
2115
2116    // Get repository (hybrid - supports offline/online)
2117    let repository = repository_manager
2118        .0
2119        .get(&repository_handle)
2120        .ok_or("Repository not found - user may need to log in")?;
2121
2122    // Fetch all tracks from the album via hybrid repository
2123    info!(
2124        "Fetching tracks for album {} via repository",
2125        request.album_id
2126    );
2127    let album_items = repository
2128        .get_items(
2129            &request.album_id,
2130            Some(GetItemsOptions {
2131                limit: Some(1000),
2132                fields: Some(vec![
2133                    "PrimaryImageAspectRatio".to_string(),
2134                    "Overview".to_string(),
2135                    "MediaStreams".to_string(),
2136                ]),
2137                ..Default::default()
2138            }),
2139        )
2140        .await
2141        .map_err(|e| format!("Failed to fetch album tracks: {}", e))?;
2142
2143    info!("Found {} items in album", album_items.items.len());
2144
2145    // Filter to only Audio items and sort by index
2146    let mut tracks: Vec<_> = album_items
2147        .items
2148        .into_iter()
2149        .filter(|item| item.item_type == "Audio")
2150        .collect();
2151    tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
2152
2153    info!("Found {} audio tracks in album", tracks.len());
2154
2155    if tracks.is_empty() {
2156        return Err("No audio tracks found in album".to_string());
2157    }
2158
2159    // Debug: Log all track IDs and their indices
2160    info!("Album has {} tracks after sorting:", tracks.len());
2161    for (idx, track) in tracks.iter().enumerate() {
2162        info!("  [{}] {} (ID: {})", idx, track.name, track.id);
2163    }
2164
2165    // Validate the requested track exists in the album (its position in the
2166    // final queue is computed after building, since offline tracks are skipped).
2167    info!("Looking for track_id: {}", request.track_id);
2168    let album_index = tracks
2169        .iter()
2170        .position(|t| t.id == request.track_id)
2171        .ok_or_else(|| format!("Track {} not found in album", request.track_id))?;
2172
2173    info!(
2174        "Track {} is at index {} in album",
2175        request.track_id, album_index
2176    );
2177
2178    // Convert tracks to MediaItems
2179    let mut media_items = Vec::new();
2180    for track in tracks {
2181        // Check for local download first
2182        let jellyfin_id = &track.id;
2183        let local_path = check_for_local_download(&db, jellyfin_id).await?;
2184
2185        let source = if let Some(path) = local_path {
2186            MediaSource::Local {
2187                file_path: PathBuf::from(path),
2188                jellyfin_item_id: Some(jellyfin_id.clone()),
2189            }
2190        } else {
2191            // Non-downloaded track: needs a stream URL from the server. When the
2192            // server is unreachable (offline), skip this track rather than failing
2193            // the whole album — downloaded tracks must still be playable.
2194            match repository.get_audio_stream_url(&track.id).await {
2195                Ok(stream_url) => MediaSource::Remote {
2196                    stream_url,
2197                    jellyfin_item_id: jellyfin_id.clone(),
2198                },
2199                Err(e) => {
2200                    warn!(
2201                        "[Player] Skipping track {} ({}) — no local download and stream URL unavailable: {}",
2202                        track.name, track.id, e
2203                    );
2204                    continue;
2205                }
2206            }
2207        };
2208
2209        let primary_image_tag_for_url = track.primary_image_tag.clone();
2210        let media_item = MediaItem {
2211            id: track.id.clone(),
2212            title: track.name.clone(),
2213            name: Some(track.name.clone()), // Frontend compatibility
2214            artist: track
2215                .album_artist
2216                .clone()
2217                .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
2218            album: Some(request.album_name.clone()),
2219            album_name: Some(request.album_name.clone()), // Frontend compatibility
2220            album_id: Some(request.album_id.clone()),
2221            artist_items: track.artist_items.clone(), // For clickable artist links
2222            artists: track.artists.clone(),           // Fallback artist info
2223            primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
2224            image_id: track.primary_image_tag.clone(),
2225            item_type: Some(track.item_type.clone()), // Frontend compatibility
2226            playlist_id: None,
2227            duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
2228            artwork_url: primary_image_tag_for_url.map(|tag| {
2229                repository.get_image_url(
2230                    &request.album_id,
2231                    ImageType::Primary,
2232                    Some(ImageOptions {
2233                        max_width: Some(300),
2234                        tag: Some(tag),
2235                        ..Default::default()
2236                    }),
2237                )
2238            }),
2239            media_type: MediaType::Audio,
2240            source,
2241            video_codec: None,
2242            needs_transcoding: false,
2243            video_width: None,
2244            video_height: None,
2245            subtitles: vec![],
2246            series_id: None,
2247            server_id: None,
2248        };
2249
2250        media_items.push(media_item);
2251    }
2252
2253    if media_items.is_empty() {
2254        return Err("No playable tracks available (offline and nothing downloaded)".to_string());
2255    }
2256
2257    // Tracks with no local download and no reachable server were skipped above,
2258    // so positions shifted. Re-locate the requested track in the built queue.
2259    // If the tapped track itself was skipped, fall back to the first item.
2260    let start_index = media_items
2261        .iter()
2262        .position(|item| item.id == request.track_id)
2263        .unwrap_or(0);
2264
2265    info!(
2266        "Built queue with {} media items, starting at index {}",
2267        media_items.len(),
2268        start_index
2269    );
2270
2271    // Handle shuffle before setting queue
2272    if request.shuffle {
2273        let controller = player.0.lock().await;
2274        controller.toggle_shuffle();
2275    }
2276
2277    // Start audio session with the first item
2278    if let Some(first_item) = media_items.get(start_index) {
2279        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2280        session_mgr.start_audio_session(first_item.clone());
2281    }
2282
2283    let controller = player.0.lock().await;
2284
2285    // When controlling a remote session, cast the selection there instead of
2286    // starting local MPV playback. We still load the queue locally (below) so
2287    // the queue/context stay in sync for the UI and for transferring back.
2288    let remote_session = match playback_mode.0.get_mode() {
2289        crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id),
2290        _ => None,
2291    };
2292
2293    if let Some(session_id) = &remote_session {
2294        play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
2295        controller
2296            .set_queue(media_items, start_index)
2297            .map_err(|e| e.to_string())?;
2298    } else {
2299        // Local playback is now authoritative (see player_play_tracks); set it
2300        // before starting so the mode-changed event precedes the state events.
2301        playback_mode
2302            .0
2303            .set_mode(crate::playback_mode::PlaybackMode::Local);
2304        controller
2305            .play_queue(media_items, start_index)
2306            .map_err(|e| e.to_string())?;
2307    }
2308
2309    // Set the queue context for remote transfer
2310    {
2311        let queue_arc = controller.queue();
2312        let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
2313        queue.set_context(QueueContext::Album {
2314            album_id: request.album_id.clone(),
2315            album_name: request.album_name.clone(),
2316        });
2317    }
2318
2319    // Emit queue changed event
2320    controller.emit_queue_changed();
2321
2322    // Log final queue state
2323    {
2324        let queue_arc = controller.queue();
2325        let queue_lock = queue_arc.lock().map_err(|e| e.to_string())?;
2326        info!(
2327            "player_play_album_track: Queue now has {} items, current_index: {:?}",
2328            queue_lock.items().len(),
2329            queue_lock.current_index()
2330        );
2331    }
2332
2333    // Emit session changed event
2334    if let Some(emitter) = controller.event_emitter() {
2335        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2336        emitter.emit(PlayerStatusEvent::SessionChanged {
2337            session: session_mgr.current().clone(),
2338        });
2339    }
2340
2341    Ok(get_player_status(&controller))
2342}
2343
2344/// Play tracks by ID - backend fetches all metadata
2345#[tauri::command]
2346#[specta::specta]
2347pub async fn player_play_tracks(
2348    player: State<'_, PlayerStateWrapper>,
2349    session: State<'_, MediaSessionManagerWrapper>,
2350    db: State<'_, DatabaseWrapper>,
2351    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
2352    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2353    repository_handle: String,
2354    request: PlayTracksRequest,
2355) -> Result<PlayerStatus, String> {
2356    info!(
2357        "player_play_tracks called: {} tracks, start_index={}, shuffle={}",
2358        request.track_ids.len(),
2359        request.start_index,
2360        request.shuffle
2361    );
2362
2363    // Validate input
2364    if request.track_ids.is_empty() {
2365        return Err("No tracks provided".to_string());
2366    }
2367
2368    // Get repository
2369    let repository = repository_manager
2370        .0
2371        .get(&repository_handle)
2372        .ok_or("Repository not found - user may need to log in")?;
2373
2374    // Fetch metadata for all tracks
2375    let mut media_items = Vec::new();
2376    for track_id in &request.track_ids {
2377        // Fetch track metadata from repository
2378        let track = repository
2379            .get_item(track_id)
2380            .await
2381            .map_err(|e| format!("Failed to fetch track {}: {}", track_id, e))?;
2382
2383        // Check for local download
2384        let local_path = check_for_local_download(&db, track_id).await?;
2385
2386        // Build MediaSource
2387        let source = if let Some(path) = local_path {
2388            MediaSource::Local {
2389                file_path: PathBuf::from(path),
2390                jellyfin_item_id: Some(track.id.clone()),
2391            }
2392        } else {
2393            let stream_url = repository
2394                .get_audio_stream_url(track_id)
2395                .await
2396                .map_err(|e| format!("Failed to get stream URL: {}", e))?;
2397
2398            MediaSource::Remote {
2399                stream_url,
2400                jellyfin_item_id: track.id.clone(),
2401            }
2402        };
2403
2404        // Transform to MediaItem with frontend-compatible fields
2405        let primary_image_tag_for_url = track.primary_image_tag.clone();
2406        let media_item = MediaItem {
2407            id: track.id.clone(),
2408            title: track.name.clone(),
2409            name: Some(track.name.clone()), // Frontend compatibility
2410            artist: track
2411                .album_artist
2412                .clone()
2413                .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
2414            album: track.album_name.clone(),
2415            album_name: track.album_name.clone(), // Frontend compatibility
2416            album_id: track.album_id.clone(),
2417            artist_items: track.artist_items.clone(), // For clickable artist links
2418            artists: track.artists.clone(),           // Fallback artist info
2419            primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
2420            image_id: track.primary_image_tag.clone(),
2421            item_type: Some(track.item_type.clone()), // Frontend compatibility
2422            playlist_id: None,                        // Set based on context below
2423            duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
2424            artwork_url: primary_image_tag_for_url.and_then(|tag| {
2425                track.album_id.as_ref().map(|album_id| {
2426                    repository.get_image_url(
2427                        album_id,
2428                        ImageType::Primary,
2429                        Some(ImageOptions {
2430                            max_width: Some(300),
2431                            tag: Some(tag),
2432                            ..Default::default()
2433                        }),
2434                    )
2435                })
2436            }),
2437            media_type: MediaType::Audio,
2438            source,
2439            video_codec: None,
2440            needs_transcoding: false,
2441            video_width: None,
2442            video_height: None,
2443            subtitles: vec![],
2444            series_id: None,
2445            server_id: None,
2446        };
2447
2448        media_items.push(media_item);
2449    }
2450
2451    info!("Built queue with {} media items", media_items.len());
2452
2453    // Map context and set playlist_id
2454    let queue_context = match request.context {
2455        PlayTracksContext::Playlist {
2456            playlist_id,
2457            playlist_name,
2458        } => {
2459            for item in &mut media_items {
2460                item.playlist_id = Some(playlist_id.clone());
2461            }
2462            QueueContext::Playlist {
2463                playlist_id,
2464                playlist_name,
2465            }
2466        }
2467        PlayTracksContext::Search { .. } | PlayTracksContext::Custom { .. } => QueueContext::Custom,
2468    };
2469
2470    // Handle shuffle
2471    if request.shuffle {
2472        let controller = player.0.lock().await;
2473        controller.toggle_shuffle();
2474    }
2475
2476    // Start session
2477    if let Some(first_item) = media_items.get(request.start_index) {
2478        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2479        session_mgr.start_audio_session(first_item.clone());
2480    }
2481
2482    let controller = player.0.lock().await;
2483
2484    // When controlling a remote session, cast the selection there instead of
2485    // starting local MPV playback. Skip this while a transfer is in flight: the
2486    // transfer-to-local path calls this command to load the queue locally and
2487    // the mode is still Remote until the transfer completes - routing it back to
2488    // the remote would undo the transfer.
2489    let remote_session = match playback_mode.0.get_mode() {
2490        crate::playback_mode::PlaybackMode::Remote { session_id }
2491            if !playback_mode.0.is_transferring() =>
2492        {
2493            Some(session_id)
2494        }
2495        _ => None,
2496    };
2497
2498    if let Some(session_id) = &remote_session {
2499        play_selection_on_remote(&controller, session_id, &media_items, request.start_index)
2500            .await?;
2501        controller
2502            .set_queue(media_items, request.start_index)
2503            .map_err(|e| e.to_string())?;
2504    } else {
2505        // Starting local playback makes Local the authoritative mode. Without
2506        // this, a prior Remote mode lingers in the manager and later play/pause
2507        // commands route back to the (stopped) remote session. Set it BEFORE
2508        // starting playback so the PlaybackModeChanged event reaches the frontend
2509        // ahead of the state_changed events it will emit — otherwise the frontend
2510        // (still thinking it's remote) filters those state events out. Skip during
2511        // a transfer: transfer_to_local drives the mode itself once complete.
2512        if !playback_mode.0.is_transferring() {
2513            playback_mode
2514                .0
2515                .set_mode(crate::playback_mode::PlaybackMode::Local);
2516        }
2517        controller
2518            .play_queue_from(media_items, request.start_index, request.start_position)
2519            .map_err(|e| e.to_string())?;
2520    }
2521
2522    // Set queue context
2523    {
2524        let queue_arc = controller.queue();
2525        let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
2526        queue.set_context(queue_context);
2527    }
2528
2529    // Emit events
2530    controller.emit_queue_changed();
2531    if let Some(emitter) = controller.event_emitter() {
2532        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2533        emitter.emit(PlayerStatusEvent::SessionChanged {
2534            session: session_mgr.current().clone(),
2535        });
2536    }
2537
2538    info!("player_play_tracks completed successfully");
2539    Ok(get_player_status(&controller))
2540}
2541
2542/// Response for preload operation
2543#[derive(specta::Type, Debug, Serialize)]
2544#[serde(rename_all = "camelCase")]
2545pub struct PreloadResult {
2546    /// Number of tracks queued for preload
2547    pub queued_count: usize,
2548    /// Number of tracks already downloaded
2549    pub already_downloaded: usize,
2550    /// Number of tracks skipped (no jellyfin ID or other reasons)
2551    pub skipped: usize,
2552}
2553
2554/// Preload upcoming tracks from the queue
2555/// This queues background downloads for the next N tracks that aren't already downloaded
2556#[tauri::command]
2557#[specta::specta]
2558pub async fn player_preload_upcoming(
2559    player: State<'_, PlayerStateWrapper>,
2560    db: State<'_, DatabaseWrapper>,
2561    smart_cache: State<'_, SmartCacheWrapper>,
2562    download_manager: State<'_, crate::commands::download::DownloadManagerWrapper>,
2563    app: tauri::AppHandle,
2564    user_id: String,
2565    _download_base_path: String,
2566) -> Result<PreloadResult, String> {
2567    // The pump only starts rows that carry both a stream URL and a target dir,
2568    // so resolve the same storage root the user-initiated download paths use
2569    // (storage_get_path = the database's parent directory).
2570    let (db_service, target_dir) = {
2571        let database = db.0.lock().map_err(|e| e.to_string())?;
2572        let target_dir = database
2573            .path()
2574            .parent()
2575            .ok_or_else(|| "Database path has no parent directory".to_string())?
2576            .to_string_lossy()
2577            .to_string();
2578        (Arc::new(database.service()), target_dir)
2579    };
2580
2581    // Get cache settings
2582    let precache_count = {
2583        let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2584        if !cache.should_precache_queue() {
2585            return Ok(PreloadResult {
2586                queued_count: 0,
2587                already_downloaded: 0,
2588                skipped: 0,
2589            });
2590        }
2591        cache.queue_precache_count()
2592    };
2593
2594    // Get upcoming items from queue
2595    let upcoming_items: Vec<MediaItem> = {
2596        let controller = player.0.lock().await;
2597        let queue = controller.queue();
2598        let queue_lock = queue.lock().map_err(|e| e.to_string())?;
2599        queue_lock
2600            .get_upcoming(precache_count)
2601            .into_iter()
2602            .cloned()
2603            .collect()
2604    };
2605
2606    if upcoming_items.is_empty() {
2607        return Ok(PreloadResult {
2608            queued_count: 0,
2609            already_downloaded: 0,
2610            skipped: 0,
2611        });
2612    }
2613
2614    let mut queued_count = 0;
2615    let mut already_downloaded = 0;
2616    let mut skipped = 0;
2617
2618    // Process each upcoming item
2619    for item in upcoming_items {
2620        // Only process items with Remote source (not already local). The
2621        // source already carries the resolved stream URL — reuse it so the
2622        // pump can start the download without any extra resolution step.
2623        let (jellyfin_id, stream_url) = match &item.source {
2624            MediaSource::Remote {
2625                jellyfin_item_id,
2626                stream_url,
2627            } => (jellyfin_item_id.clone(), stream_url.clone()),
2628            MediaSource::Local { .. } => {
2629                already_downloaded += 1;
2630                continue;
2631            }
2632            MediaSource::DirectUrl { .. } => {
2633                skipped += 1;
2634                continue;
2635            }
2636        };
2637
2638        // Check if already downloaded or actively in flight. Stale pending rows
2639        // without a stream URL are NOT skipped here — the upsert below heals
2640        // them so the pump can finally start them.
2641        let query = Query::with_params(
2642            "SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ?
2643             AND (status IN ('completed', 'downloading')
2644                  OR (status = 'pending' AND stream_url IS NOT NULL)) LIMIT 1",
2645            vec![
2646                QueryParam::String(jellyfin_id.clone()),
2647                QueryParam::String(user_id.clone()),
2648            ],
2649        );
2650
2651        let is_downloaded: Option<String> = db_service
2652            .query_optional(query, |row| row.get(0))
2653            .await
2654            .map_err(|e| e.to_string())?;
2655
2656        if is_downloaded.is_some() {
2657            already_downloaded += 1;
2658            continue;
2659        }
2660
2661        // Queue for download with low priority (preload priority = -100) so
2662        // user-initiated downloads always win a pump slot first.
2663        let album_dir = item
2664            .album
2665            .as_deref()
2666            .filter(|a| !a.is_empty())
2667            .unwrap_or("Unknown Album");
2668        let file_path = format!(
2669            "downloads/{}/{}.mp3",
2670            sanitize_filename(album_dir),
2671            sanitize_filename(&item.title)
2672        );
2673
2674        // Insert with the stream URL + target dir the pump needs to start it.
2675        // On conflict, heal pre-existing rows that were queued without a URL
2676        // (they could never start) instead of leaving them stuck.
2677        let insert_query = Query::with_params(
2678            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, download_source, media_type, stream_url, target_dir)
2679             VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?, 'auto', 'audio', ?, ?)
2680             ON CONFLICT(item_id, user_id) DO UPDATE SET
2681                 stream_url = excluded.stream_url,
2682                 target_dir = excluded.target_dir
2683             WHERE downloads.status = 'pending' AND downloads.stream_url IS NULL",
2684            vec![
2685                QueryParam::String(jellyfin_id),
2686                QueryParam::String(user_id.clone()),
2687                QueryParam::String(file_path),
2688                QueryParam::String(item.title.clone()),
2689                item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
2690                item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
2691                QueryParam::String(stream_url),
2692                QueryParam::String(target_dir.clone()),
2693            ],
2694        );
2695
2696        match db_service.execute(insert_query).await {
2697            Ok(rows) if rows > 0 => {
2698                info!(
2699                    "[Preload] Queued download for: {} - {}",
2700                    item.artist.as_deref().unwrap_or("Unknown"),
2701                    item.title
2702                );
2703                queued_count += 1;
2704            }
2705            Ok(_) => {
2706                // Row already exists (conflict), count as already queued
2707                already_downloaded += 1;
2708            }
2709            Err(e) => {
2710                error!("[Preload] Failed to queue {}: {}", item.title, e);
2711                skipped += 1;
2712            }
2713        }
2714    }
2715
2716    // Kick the pump so the queued preloads actually start; without this they'd
2717    // only begin once some other download activity pumps the queue.
2718    if queued_count > 0 {
2719        let active_downloads = {
2720            let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
2721            manager.get_active_downloads()
2722        };
2723        crate::commands::download::pump_download_queue(app, db_service, active_downloads).await;
2724    }
2725
2726    info!(
2727        "[Preload] Result: queued={}, already_downloaded={}, skipped={}",
2728        queued_count, already_downloaded, skipped
2729    );
2730
2731    Ok(PreloadResult {
2732        queued_count,
2733        already_downloaded,
2734        skipped,
2735    })
2736}
2737
2738/// Sanitize filename by removing invalid characters
2739fn sanitize_filename(name: &str) -> String {
2740    name.chars()
2741        .map(|c| match c {
2742            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
2743            _ => c,
2744        })
2745        .collect()
2746}
2747
2748/// Update SmartCache configuration
2749#[tauri::command]
2750#[specta::specta]
2751pub async fn player_set_cache_config(
2752    smart_cache: State<'_, SmartCacheWrapper>,
2753    config: CacheConfig,
2754) -> Result<(), String> {
2755    let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2756    cache.update_config(config);
2757    Ok(())
2758}
2759
2760/// Get current SmartCache configuration
2761#[tauri::command]
2762#[specta::specta]
2763pub async fn player_get_cache_config(
2764    smart_cache: State<'_, SmartCacheWrapper>,
2765) -> Result<CacheConfig, String> {
2766    let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2767    Ok(cache.get_config().unwrap_or_default())
2768}
2769
2770/// Configure Jellyfin API client for automatic playback reporting
2771#[tauri::command]
2772#[specta::specta]
2773pub async fn player_configure_jellyfin(
2774    player: State<'_, PlayerStateWrapper>,
2775    db: State<'_, DatabaseWrapper>,
2776    server_url: String,
2777    access_token: String,
2778    user_id: String,
2779    device_id: String,
2780) -> Result<(), String> {
2781    log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
2782
2783    let config = JellyfinConfig {
2784        server_url,
2785        access_token,
2786        device_id,
2787    };
2788
2789    // Legacy client (used for remote session control / casting).
2790    let client = JellyfinClient::new(config.clone())?;
2791
2792    // Build the PlaybackReporter the player and backends (MPV + ExoPlayer)
2793    // actually report through. Without this, Start/Progress/Stopped never reach
2794    // Jellyfin, so playback position never syncs and you can't resume on another
2795    // device. The reporter shares the player controller's Arc, so populating it
2796    // here lights up reporting on both desktop and Android, on every auth path
2797    // that configures the player (login / restore / reauth).
2798    let db_service = {
2799        let database = db.0.lock().map_err(|e| e.to_string())?;
2800        Arc::new(database.service())
2801    };
2802    let reporter_client = JellyfinClient::new(config)?;
2803    let reporter = crate::playback_reporting::PlaybackReporter::new(
2804        db_service,
2805        Arc::new(TokioMutex::new(Some(reporter_client))),
2806        user_id,
2807    );
2808
2809    let controller = player.0.lock().await;
2810    controller.set_jellyfin_client(Some(client));
2811    controller.set_playback_reporter(Some(reporter)).await;
2812
2813    log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully");
2814    Ok(())
2815}
2816
2817/// Disable Jellyfin automatic playback reporting
2818#[tauri::command]
2819#[specta::specta]
2820pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> Result<(), String> {
2821    log::info!("[PlayerCommand] Disabling Jellyfin client");
2822
2823    let controller = player.0.lock().await;
2824    controller.set_jellyfin_client(None);
2825    controller.set_playback_reporter(None).await;
2826
2827    log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled");
2828    Ok(())
2829}
2830
2831#[cfg(test)]
2832mod tests {
2833    use crate::utils::lock::MutexSafe;
2834
2835    /// UT-206 — the volume the command hands on is always a real number in
2836    /// 0.0..=1.0.
2837    ///
2838    /// Every backend clamps for itself, but the remote branch of
2839    /// `player_set_volume` reaches no backend at all: it does
2840    /// `(volume * 100.0) as i32`, which turns infinity into `i32::MAX` and NaN
2841    /// into 0. NaN also survives `f32::clamp` unchanged, so clamping alone is
2842    /// not enough — it has to be tested for.
2843    ///
2844    /// TRACES: DR-212 | UT-206
2845    #[test]
2846    fn test_normalize_volume_clamps_and_rejects_nan() {
2847        use super::normalize_volume;
2848
2849        // In-range values pass through untouched.
2850        assert_eq!(normalize_volume(0.0), 0.0);
2851        assert_eq!(normalize_volume(0.5), 0.5);
2852        assert_eq!(normalize_volume(1.0), 1.0);
2853
2854        // Out of range clamps to the same 0.0..=1.0 the backends use.
2855        assert_eq!(normalize_volume(-0.5), 0.0);
2856        assert_eq!(normalize_volume(42.0), 1.0);
2857        assert_eq!(normalize_volume(f32::INFINITY), 1.0);
2858        assert_eq!(normalize_volume(f32::NEG_INFINITY), 0.0);
2859
2860        // NaN is not a volume; it must not reach the Jellyfin percentage
2861        // conversion or a backend.
2862        let from_nan = normalize_volume(f32::NAN);
2863        assert!(!from_nan.is_nan(), "NaN must not pass through the boundary");
2864        assert_eq!(from_nan, 0.0);
2865
2866        // Whatever comes out survives the remote branch's percentage cast.
2867        for input in [-1.0, 0.25, 9.0, f32::INFINITY, f32::NAN] {
2868            let percent = (normalize_volume(input) * 100.0) as i32;
2869            assert!((0..=100).contains(&percent), "input {input} gave {percent}");
2870        }
2871    }
2872
2873    /// The subtitle list the frontend resolved must survive the IPC hop and end
2874    /// up on the `MediaItem` the native backend loads.
2875    ///
2876    /// The bug: `VideoPlayer.svelte` built a fully-resolved subtitle array and
2877    /// then dropped it on the floor — `PlayItemRequest` had no field to put it
2878    /// in — so `create_media_item` always produced `subtitles: vec![]`,
2879    /// `android/mod.rs` serialized `[]` across JNI, and ExoPlayer was handed a
2880    /// `MediaItem` with zero `SubtitleConfiguration`s. Every later
2881    /// `setSubtitleTrack(n)` then found no text track groups and logged
2882    /// "Invalid subtitle track index".
2883    ///
2884    /// The payload below is exactly what the frontend sends: camelCase for the
2885    /// top-level command params (Tauri v2 converts them), and the subtitle
2886    /// entries in the casing of `SubtitleTrack` itself — note `mime_type`.
2887    ///
2888    /// TRACES: UR-020 | IR-016 | UT-145
2889    #[tokio::test]
2890    async fn test_play_item_request_carries_subtitles_into_media_item() {
2891        use super::{create_media_item, PlayItemRequest};
2892
2893        let payload = serde_json::json!({
2894            "id": "ep-1",
2895            "title": "Pilot",
2896            "streamUrl": "https://jelly.example/Videos/ep-1/master.m3u8",
2897            "videoCodec": "h264",
2898            "needsTranscoding": false,
2899            "subtitles": [
2900                {
2901                    "index": 2,
2902                    "url": "https://jelly.example/Videos/ep-1/2/Subtitles/subtitles.vtt",
2903                    "language": "eng",
2904                    "label": "English (SRT)",
2905                    "mime_type": "text/vtt"
2906                },
2907                {
2908                    "index": 3,
2909                    "url": "https://jelly.example/Videos/ep-1/3/Subtitles/subtitles.vtt",
2910                    "language": null,
2911                    "label": null,
2912                    "mime_type": "text/vtt"
2913                }
2914            ]
2915        });
2916
2917        let req: PlayItemRequest =
2918            serde_json::from_value(payload).expect("frontend payload must deserialize");
2919        assert_eq!(
2920            req.subtitles.len(),
2921            2,
2922            "PlayItemRequest must carry the subtitle tracks, not silently ignore them"
2923        );
2924
2925        let media = create_media_item(req, None).await.unwrap();
2926        assert_eq!(
2927            media.subtitles.len(),
2928            2,
2929            "create_media_item must thread the tracks onto the MediaItem the backend loads"
2930        );
2931        assert_eq!(media.subtitles[0].index, 2);
2932        assert_eq!(media.subtitles[0].language.as_deref(), Some("eng"));
2933        assert_eq!(media.subtitles[0].label.as_deref(), Some("English (SRT)"));
2934        assert_eq!(media.subtitles[0].mime_type, "text/vtt");
2935        // Order is the contract: `player_set_subtitle_track(n)` is a position in
2936        // this list (see the note on `PlayItemRequest::subtitles`).
2937        assert_eq!(media.subtitles[1].index, 3);
2938        assert!(media.subtitles[1].language.is_none());
2939    }
2940
2941    /// A request without subtitles must still deserialize — the field is
2942    /// defaulted so the background-audio handoff and the autoplay/next-episode
2943    /// callers keep compiling and sending what they always sent.
2944    ///
2945    /// TRACES: UR-020 | IR-016 | UT-145
2946    #[tokio::test]
2947    async fn test_play_item_request_without_subtitles_defaults_to_empty() {
2948        use super::{create_media_item, PlayItemRequest};
2949
2950        let req: PlayItemRequest = serde_json::from_value(serde_json::json!({
2951            "id": "movie-1",
2952            "title": "Movie",
2953            "streamUrl": "https://jelly.example/Videos/movie-1/stream.mp4",
2954            "videoCodec": "h264",
2955            "needsTranscoding": false
2956        }))
2957        .expect("a subtitle-less payload must still deserialize");
2958
2959        assert!(req.subtitles.is_empty());
2960        assert!(create_media_item(req, None)
2961            .await
2962            .unwrap()
2963            .subtitles
2964            .is_empty());
2965    }
2966
2967    /// The JSON handed to Kotlin over JNI must use the keys
2968    /// `JellyTauPlayer.load()` actually reads.
2969    ///
2970    /// `MediaItem` is `rename_all = "camelCase"`, and the instinct (and the
2971    /// house IPC rule) is to camelCase nested structs too — but
2972    /// `JellyTauPlayer.kt` reads `subtitle.optString("mime_type", …)`. Renaming
2973    /// the field would not fail to compile or fail the IPC; it would silently
2974    /// fall back to the default MIME type for every track, so this is asserted
2975    /// on the exact bytes `android/mod.rs` sends.
2976    ///
2977    /// TRACES: UR-020 | IR-016, JA-008 | UT-146
2978    #[test]
2979    fn test_subtitle_json_for_jni_uses_the_keys_kotlin_reads() {
2980        use crate::player::media::SubtitleTrack;
2981
2982        let subtitles = vec![SubtitleTrack {
2983            index: 2,
2984            url: "https://jelly.example/subs.vtt".to_string(),
2985            language: Some("eng".to_string()),
2986            label: Some("English".to_string()),
2987            mime_type: "text/vtt".to_string(),
2988        }];
2989
2990        // Exactly what player/android/mod.rs passes to loadWithMetadata.
2991        let json = serde_json::to_string(&subtitles).unwrap();
2992        let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
2993        let obj = parsed[0].as_object().unwrap();
2994
2995        for key in ["url", "language", "label", "mime_type"] {
2996            assert!(
2997                obj.contains_key(key),
2998                "JellyTauPlayer.load() reads `{key}`; serialized keys were {:?}",
2999                obj.keys().collect::<Vec<_>>()
3000            );
3001        }
3002        assert!(
3003            !obj.contains_key("mimeType"),
3004            "camelCasing mime_type silently drops every track's MIME type on Android"
3005        );
3006    }
3007
3008    /// The audio-only handoff must play a downloaded file when there is one,
3009    /// rather than fetching an audio-only stream for media already on disk.
3010    ///
3011    /// TRACES: UR-071 | DR-128 | UT-119
3012    #[test]
3013    fn test_background_audio_source_prefers_local_file() {
3014        use super::background_audio_source;
3015        use crate::player::MediaSource;
3016        use std::path::PathBuf;
3017
3018        let local = background_audio_source(
3019            Some("/downloads/ep1.mkv".to_string()),
3020            "https://server/audio-only".to_string(),
3021            "ep-1",
3022        );
3023        match local {
3024            MediaSource::Local {
3025                file_path,
3026                jellyfin_item_id,
3027            } => {
3028                assert_eq!(file_path, PathBuf::from("/downloads/ep1.mkv"));
3029                // The Jellyfin id must survive so progress still syncs back.
3030                assert_eq!(jellyfin_item_id.as_deref(), Some("ep-1"));
3031            }
3032            other => panic!("expected a local source, got {:?}", other),
3033        }
3034
3035        let remote = background_audio_source(None, "https://server/audio-only".to_string(), "ep-1");
3036        match remote {
3037            MediaSource::Remote {
3038                stream_url,
3039                jellyfin_item_id,
3040            } => {
3041                assert_eq!(stream_url, "https://server/audio-only");
3042                assert_eq!(jellyfin_item_id, "ep-1");
3043            }
3044            other => panic!("expected a remote source, got {:?}", other),
3045        }
3046    }
3047
3048    /// The two sources start in different places, so the handoff cannot treat
3049    /// them alike.
3050    ///
3051    /// An audio-only *stream* is built with `StartTimeTicks`, so the server makes
3052    /// the handoff point that stream's zero: the base is the handoff position and
3053    /// seeking would jump past the content. A *downloaded file* has no such
3054    /// parameter — it starts at the episode's own zero — so basing it at the
3055    /// handoff position claims 18 minutes of audio that is about to play from the
3056    /// beginning. That is the downloaded-episode version of "it restarts when the
3057    /// screen sleeps", and it needs the opposite treatment: no base, and a seek.
3058    ///
3059    /// TRACES: UR-040, UR-071 | DR-180 | UT-181
3060    #[test]
3061    fn test_background_audio_plan_seeks_a_file_and_bases_a_stream() {
3062        use super::background_audio_plan;
3063
3064        let local = background_audio_plan(true, 1104.0);
3065        assert_eq!(local.base_seconds, 0.0);
3066        assert_eq!(local.seek_to, Some(1104.0));
3067
3068        let streamed = background_audio_plan(false, 1104.0);
3069        assert_eq!(streamed.base_seconds, 1104.0);
3070        assert_eq!(
3071            streamed.seek_to, None,
3072            "the URL already starts at the handoff point; seeking again skips past it"
3073        );
3074    }
3075
3076    /// Handing off at the very start has nothing to seek to and nothing to base:
3077    /// both sources are already where they need to be.
3078    ///
3079    /// TRACES: UR-040, UR-071 | DR-180 | UT-181
3080    #[test]
3081    fn test_background_audio_plan_at_the_start_neither_seeks_nor_bases() {
3082        use super::background_audio_plan;
3083
3084        for local in [true, false] {
3085            let plan = background_audio_plan(local, 0.0);
3086            assert_eq!(plan.base_seconds, 0.0);
3087            assert_eq!(plan.seek_to, None);
3088        }
3089    }
3090
3091    /// A downloaded item must resolve to its file, and a `downloads` row whose
3092    /// file has gone must resolve to `None` so the caller falls back to
3093    /// streaming instead of handing the player a path that cannot be opened.
3094    ///
3095    /// TRACES: UR-071 | DR-123 | UT-116
3096    #[tokio::test]
3097    async fn test_resolve_local_media_path() {
3098        use super::resolve_local_media_path;
3099        use crate::storage::db_service::{DatabaseService, Query, RusqliteService};
3100        use rusqlite::Connection;
3101        use std::sync::{Arc, Mutex};
3102
3103        let conn = Connection::open_in_memory().unwrap();
3104        conn.execute(
3105            "CREATE TABLE downloads (id INTEGER PRIMARY KEY, item_id TEXT, status TEXT, file_path TEXT)",
3106            [],
3107        )
3108        .unwrap();
3109        let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
3110
3111        // A real file on disk, so the existence check passes.
3112        let present = std::env::temp_dir().join("jellytau-resolve-local-test.mp4");
3113        std::fs::write(&present, b"x").unwrap();
3114        let present_str = present.to_string_lossy().to_string();
3115
3116        for (item, status, path) in [
3117            ("downloaded", "completed", present_str.as_str()),
3118            ("still-going", "downloading", present_str.as_str()),
3119            (
3120                "file-gone",
3121                "completed",
3122                "/nonexistent/jellytau/missing.mp4",
3123            ),
3124        ] {
3125            db_service
3126                .execute(Query::with_params(
3127                    "INSERT INTO downloads (item_id, status, file_path) VALUES (?, ?, ?)",
3128                    vec![
3129                        crate::storage::db_service::QueryParam::String(item.to_string()),
3130                        crate::storage::db_service::QueryParam::String(status.to_string()),
3131                        crate::storage::db_service::QueryParam::String(path.to_string()),
3132                    ],
3133                ))
3134                .await
3135                .unwrap();
3136        }
3137
3138        assert_eq!(
3139            resolve_local_media_path(&db_service, "downloaded")
3140                .await
3141                .unwrap()
3142                .as_deref(),
3143            Some(present_str.as_str()),
3144            "a completed download with its file present must resolve"
3145        );
3146
3147        assert_eq!(
3148            resolve_local_media_path(&db_service, "still-going")
3149                .await
3150                .unwrap(),
3151            None,
3152            "an in-progress download is not playable from disk"
3153        );
3154
3155        assert_eq!(
3156            resolve_local_media_path(&db_service, "file-gone")
3157                .await
3158                .unwrap(),
3159            None,
3160            "a row whose file has gone must fall back to streaming, not hand over a dead path"
3161        );
3162
3163        assert_eq!(
3164            resolve_local_media_path(&db_service, "never-heard-of-it")
3165                .await
3166                .unwrap(),
3167            None
3168        );
3169
3170        let _ = std::fs::remove_file(&present);
3171    }
3172
3173    /// Queue items enqueued as Remote must flip to Local once a completed
3174    /// download exists on disk — this is what makes preloaded tracks (and
3175    /// offline playback after a connection drop) actually use the cache.
3176    #[tokio::test]
3177    async fn test_refresh_queue_local_sources_switches_completed_downloads() {
3178        use super::{refresh_queue_local_sources, DatabaseWrapper};
3179        use crate::player::{MediaItem, MediaSource, MediaType, PlayerController};
3180        use crate::storage::Database;
3181        use std::sync::Mutex;
3182
3183        // A real file on disk for the completed download; a missing file for
3184        // the second entry to prove nonexistent files are not switched.
3185        let dir = std::env::temp_dir().join("jellytau-test-refresh-sources");
3186        std::fs::create_dir_all(&dir).unwrap();
3187        let existing = dir.join("track-a.mp3");
3188        std::fs::write(&existing, b"audio").unwrap();
3189        let missing = dir.join("track-b-missing.mp3");
3190        let _ = std::fs::remove_file(&missing);
3191
3192        let database = Database::open_in_memory().unwrap();
3193        {
3194            let conn = database.connection();
3195            let conn = conn.lock_safe();
3196            conn.execute_batch(&format!(
3197                r#"
3198                INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
3199                INSERT INTO users (id, server_id, username) VALUES ('user1', 'srv', 'tester');
3200                INSERT INTO downloads (item_id, user_id, file_path, status)
3201                    VALUES ('track-a', 'user1', '{}', 'completed');
3202                INSERT INTO downloads (item_id, user_id, file_path, status)
3203                    VALUES ('track-b', 'user1', '{}', 'completed');
3204                "#,
3205                existing.display(),
3206                missing.display()
3207            ))
3208            .unwrap();
3209        }
3210        let db = DatabaseWrapper(Mutex::new(database));
3211
3212        let make_item = |id: &str| MediaItem {
3213            id: id.to_string(),
3214            title: id.to_string(),
3215            name: None,
3216            artist: None,
3217            album: None,
3218            album_name: None,
3219            album_id: None,
3220            artist_items: None,
3221            artists: None,
3222            primary_image_tag: None,
3223            image_id: None,
3224            item_type: None,
3225            playlist_id: None,
3226            duration: None,
3227            artwork_url: None,
3228            media_type: MediaType::Audio,
3229            source: MediaSource::Remote {
3230                stream_url: format!("http://test/Audio/{}/stream", id),
3231                jellyfin_item_id: id.to_string(),
3232            },
3233            video_codec: None,
3234            needs_transcoding: false,
3235            video_width: None,
3236            video_height: None,
3237            subtitles: vec![],
3238            series_id: None,
3239            server_id: None,
3240        };
3241
3242        let controller = PlayerController::default();
3243        controller
3244            .set_queue(vec![make_item("track-a"), make_item("track-b")], 0)
3245            .unwrap();
3246
3247        let switched = refresh_queue_local_sources(&controller, &db).await.unwrap();
3248        assert_eq!(switched, 1, "only the download whose file exists switches");
3249
3250        let queue = controller.queue();
3251        let queue_lock = queue.lock_safe();
3252        match &queue_lock.items()[0].source {
3253            MediaSource::Local {
3254                file_path,
3255                jellyfin_item_id,
3256            } => {
3257                assert_eq!(file_path, &existing);
3258                assert_eq!(jellyfin_item_id.as_deref(), Some("track-a"));
3259            }
3260            other => panic!("track-a should be local, got {:?}", other),
3261        }
3262        assert!(
3263            matches!(queue_lock.items()[1].source, MediaSource::Remote { .. }),
3264            "track-b's file is missing, it must stay remote"
3265        );
3266    }
3267
3268    /// Test track index finding in album
3269    /// This reproduces the bug where clicking songs 1-5 always played song 13
3270    #[test]
3271    fn test_find_track_index_in_album() {
3272        // Create mock album tracks
3273        #[derive(Clone)]
3274        struct MockTrack {
3275            id: String,
3276            name: String,
3277            index_number: Option<i32>,
3278        }
3279
3280        let mut tracks = [
3281            MockTrack {
3282                id: "track1".to_string(),
3283                name: "Song 1".to_string(),
3284                index_number: Some(1),
3285            },
3286            MockTrack {
3287                id: "track2".to_string(),
3288                name: "Song 2".to_string(),
3289                index_number: Some(2),
3290            },
3291            MockTrack {
3292                id: "track3".to_string(),
3293                name: "Song 3".to_string(),
3294                index_number: Some(3),
3295            },
3296            MockTrack {
3297                id: "track4".to_string(),
3298                name: "Song 4".to_string(),
3299                index_number: Some(4),
3300            },
3301            MockTrack {
3302                id: "track5".to_string(),
3303                name: "Song 5".to_string(),
3304                index_number: Some(5),
3305            },
3306        ];
3307
3308        // Sort by index (same as the real code does)
3309        tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3310
3311        // Test finding track 1 (should be index 0)
3312        let index1 = tracks.iter().position(|t| t.id == "track1");
3313        assert_eq!(index1, Some(0), "Track 1 should be at index 0");
3314        assert_eq!(
3315            tracks[0].name, "Song 1",
3316            "Track at index 0 should carry its name"
3317        );
3318
3319        // Test finding track 3 (should be index 2)
3320        let index3 = tracks.iter().position(|t| t.id == "track3");
3321        assert_eq!(index3, Some(2), "Track 3 should be at index 2");
3322
3323        // Test finding track 5 (should be index 4)
3324        let index5 = tracks.iter().position(|t| t.id == "track5");
3325        assert_eq!(index5, Some(4), "Track 5 should be at index 4");
3326
3327        // Test finding non-existent track
3328        let index_none = tracks.iter().position(|t| t.id == "nonexistent");
3329        assert_eq!(index_none, None, "Non-existent track should return None");
3330    }
3331
3332    /// Test that track order is preserved when iterating
3333    #[test]
3334    fn test_track_iteration_order() {
3335        // Simulate the loop that builds MediaItems
3336        let track_ids = vec!["id1", "id2", "id3", "id4", "id5"];
3337
3338        // Find where "id3" is in the original list
3339        let target_index = track_ids.iter().position(|&id| id == "id3");
3340        assert_eq!(
3341            target_index,
3342            Some(2),
3343            "id3 should be at index 2 in original list"
3344        );
3345
3346        // Simulate building the MediaItems vector
3347        let mut media_items = Vec::new();
3348        for id in &track_ids {
3349            media_items.push(id.to_string());
3350        }
3351
3352        // Verify the order is preserved
3353        assert_eq!(media_items.len(), 5);
3354        assert_eq!(media_items[0], "id1");
3355        assert_eq!(media_items[2], "id3");
3356        assert_eq!(media_items[4], "id5");
3357
3358        // The start_index found earlier should still be valid
3359        assert_eq!(media_items[target_index.unwrap()], "id3");
3360    }
3361
3362    /// Test album track sorting behavior
3363    #[test]
3364    fn test_album_track_sorting() {
3365        #[derive(Clone, Debug)]
3366        struct MockTrack {
3367            id: String,
3368            name: String,
3369            index_number: Option<i32>,
3370        }
3371
3372        // Create tracks in random order (not sorted)
3373        let mut tracks = [
3374            MockTrack {
3375                id: "id5".to_string(),
3376                name: "Track 5".to_string(),
3377                index_number: Some(5),
3378            },
3379            MockTrack {
3380                id: "id1".to_string(),
3381                name: "Track 1".to_string(),
3382                index_number: Some(1),
3383            },
3384            MockTrack {
3385                id: "id3".to_string(),
3386                name: "Track 3".to_string(),
3387                index_number: Some(3),
3388            },
3389            MockTrack {
3390                id: "id2".to_string(),
3391                name: "Track 2".to_string(),
3392                index_number: Some(2),
3393            },
3394            MockTrack {
3395                id: "id4".to_string(),
3396                name: "Track 4".to_string(),
3397                index_number: Some(4),
3398            },
3399        ];
3400
3401        // User clicks track "id1" before sorting - what index is it?
3402        let requested_track_id = "id1";
3403
3404        // Sort the tracks (same as real code)
3405        tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3406
3407        // Now find the index AFTER sorting
3408        let start_index = tracks.iter().position(|t| t.id == requested_track_id);
3409
3410        // Track "id1" should be at index 0 after sorting
3411        assert_eq!(
3412            start_index,
3413            Some(0),
3414            "After sorting, track id1 should be at index 0"
3415        );
3416
3417        // Verify all tracks are in correct order
3418        assert_eq!(tracks[0].id, "id1");
3419        assert_eq!(
3420            tracks[0].name, "Track 1",
3421            "Sorted track should retain its name"
3422        );
3423        assert_eq!(tracks[1].id, "id2");
3424        assert_eq!(tracks[2].id, "id3");
3425        assert_eq!(tracks[3].id, "id4");
3426        assert_eq!(tracks[4].id, "id5");
3427    }
3428}