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