Skip to main content

#
jellytau_lib/repository/
online.rs

    1//! TRACES: UR-002, UR-007 | DR-013 | IR-010
    2
    3use async_trait::async_trait;
    4use log::{debug, error, info, warn};
    5use serde::{Deserialize, Serialize};
    6use std::sync::{Arc, RwLock};
    7
    8use super::stream_selection::{
    9    quality_options_for_source, PlaybackKind, Rendition, StreamSelection, Transport,
    10};
    11use super::{types::*, MediaRepository};
    12use crate::connectivity::ConnectivityReporter;
    13use crate::jellyfin::HttpClient;
    14use crate::settings::StreamingQuality;
    15use crate::utils::lock::RwLockSafe;
    16
    17/// The viewer's **durable** bandwidth ceiling — the device default.
    18///
    19/// Process-wide rather than a field on [`OnlineRepository`] because it is a user
    20/// preference about *this device's connection*, not about a server session: it
    21/// must survive a repository being rebuilt on re-login, and every URL builder and
    22/// the `PlaybackInfo` negotiation have to agree on it or the cap leaks (the
    23/// negotiation would authorise a direct play the URL builder then never gets to
    24/// constrain). Same shape as `offline::INCLUDE_CATALOG_BROWSE`.
    25///
    26/// Set from `player_set_video_settings` (Settings), and restored from the
    27/// database at startup. **Not** set by the in-player picker — see
    28/// [`PLAYBACK_QUALITY_OVERRIDE`].
    29///
    30/// TRACES: UR-074 | DR-162
    31static STREAMING_QUALITY: RwLock<StreamingQuality> = RwLock::new(StreamingQuality::Original);
    32
    33/// The ceiling in force for *the playback happening right now*, when the viewer
    34/// has moved this one item off the device default.
    35///
    36/// This exists because a single global cannot express "this 4K remux needs a
    37/// ceiling, that podcast does not". The in-player picker is a "this film, this
    38/// connection" control — its own doc has said so since it was written — but it
    39/// was implemented by writing the device default, so choosing 2 Mbps to get one
    40/// awkward film moving silently capped every video played afterwards, for the
    41/// rest of the process, with the Settings screen still displaying the old value.
    42///
    43/// Cleared when a new item starts playing, so the override cannot outlive the
    44/// playback it was chosen for. The device default is never touched by it.
    45///
    46/// TRACES: UR-074, UR-079 | DR-226
    47static PLAYBACK_QUALITY_OVERRIDE: RwLock<Option<StreamingQuality>> = RwLock::new(None);
    48
    49/// Set the durable device default. Applies to every stream opened afterwards
    50/// that has no per-playback override.
    51///
    52/// Streams already playing keep the bitrate they were opened at — a cap is a
    53/// property of the URL the server is transcoding for, so changing it mid-stream
    54/// requires re-opening at the new quality (`player_set_stream_quality`).
    55///
    56/// TRACES: UR-074 | DR-162
    57pub fn set_streaming_quality(quality: StreamingQuality) {
    58    *STREAMING_QUALITY.write_safe() = quality;
    59}
    60
    61/// The durable device default, ignoring any per-playback override.
    62///
    63/// Read this only where the *setting* is the subject — the Settings screen, and
    64/// persistence. Everything that opens a stream wants
    65/// [`effective_streaming_quality`] instead.
    66///
    67/// TRACES: UR-074 | DR-162
    68pub fn streaming_quality() -> StreamingQuality {
    69    *STREAMING_QUALITY.read_safe()
    70}
    71
    72/// Move *this playback* to a different ceiling without disturbing the default.
    73///
    74/// TRACES: UR-074, UR-079 | DR-226
    75pub fn set_playback_quality_override(quality: StreamingQuality) {
    76    *PLAYBACK_QUALITY_OVERRIDE.write_safe() = Some(quality);
    77}
    78
    79/// Drop any per-playback override, returning to the device default.
    80///
    81/// Called when playback moves to a new item: a ceiling chosen for one film must
    82/// not silently govern the next one. Autoplaying the next episode is the case
    83/// that matters — nobody re-opens the picker between episodes.
    84///
    85/// TRACES: UR-074, UR-079 | DR-226
    86pub fn clear_playback_quality_override() {
    87    *PLAYBACK_QUALITY_OVERRIDE.write_safe() = None;
    88}
    89
    90/// The per-playback override, if one is in force.
    91///
    92/// TRACES: UR-074, UR-079 | DR-226
    93pub fn playback_quality_override() -> Option<StreamingQuality> {
    94    *PLAYBACK_QUALITY_OVERRIDE.read_safe()
    95}
    96
    97/// The ceiling that actually applies to a stream opened now: the per-playback
    98/// override if the viewer set one, otherwise the device default.
    99///
    100/// This is the single resolution point. Every URL builder and the `PlaybackInfo`
    101/// negotiation must go through it, for the same reason they all had to agree on
    102/// the old static: a negotiation that authorises a direct play the URL builder
    103/// then constrains (or vice versa) leaks the cap.
    104///
    105/// TRACES: UR-074, UR-079 | DR-226
    106pub fn effective_streaming_quality() -> StreamingQuality {
    107    playback_quality_override().unwrap_or_else(streaming_quality)
    108}
    109
    110/// Every request this app makes identifies the same device, so the device id
    111/// alone cannot tell two transcodes of the same item apart — see
    112/// [`begin_video_play_session`].
    113const DEVICE_ID: &str = "jellytau-tauri";
    114
    115/// The `PlaySessionId` of the video transcode most recently opened, so the next
    116/// open can stop it.
    117///
    118/// Process-wide for the same reason as [`STREAMING_QUALITY`]: it describes what
    119/// *this device* currently has running on the server, and must survive the
    120/// repository being rebuilt on re-login.
    121///
    122/// TRACES: UR-074 | DR-162
    123static VIDEO_PLAY_SESSION: RwLock<Option<String>> = RwLock::new(None);
    124
    125/// Claim a transcode identity for a stream about to be opened, returning the new
    126/// `PlaySessionId` and the one it replaces (if any).
    127///
    128/// Jellyfin keys a transcode job by device *and* play session. Without a session
    129/// id every open of the same item on this device looked like the same job, so
    130/// re-opening a stream — a quality switch, a transcoded seek, an audio-track
    131/// switch — left the old ffmpeg running and the server intermittently rejected
    132/// segment requests for the new one (`400` on `hls1/main/0.ts`) while the two
    133/// fought over one transcode path. The caller stops the returned previous
    134/// session before the new stream's segments are fetched.
    135///
    136/// TRACES: UR-074 | DR-177 | UT-173
    137pub fn begin_video_play_session() -> (String, Option<String>) {
    138    let new_session = uuid::Uuid::new_v4().to_string();
    139    let mut current = VIDEO_PLAY_SESSION.write_safe();
    140    let previous = current.replace(new_session.clone());
    141    (new_session, previous)
    142}
    143
    144/// Take ownership of a transcode this process did not build a URL for, returning
    145/// the session it replaces.
    146///
    147/// When `PlaybackInfo` answers with a `TranscodingUrl` the server has already
    148/// started the job and named the session; that id is the only handle on it we
    149/// will ever have. Without adopting it, the first re-open of that stream has no
    150/// previous session to stop and collides with the very job that was playing.
    151///
    152/// TRACES: UR-074 | DR-177 | UT-173
    153pub fn adopt_video_play_session(session_id: String) -> Option<String> {
    154    VIDEO_PLAY_SESSION.write_safe().replace(session_id)
    155}
    156
    157/// A single actor returned by the JRay plugin's "context at time t" endpoint.
    158///
    159/// Mirrors the `actors[]` objects from `GET /Plugins/JRay/Items/{id}/jray?t=`.
    160/// `jellyfin_id` (a Jellyfin Person item GUID) is preferred for navigation;
    161/// the IMDb/TMDb ids are informational fallbacks. Unknown ids are `""`.
    162#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
    163pub struct JRayActor {
    164    pub name: String,
    165    #[serde(default)]
    166    pub imdb_id: String,
    167    #[serde(default)]
    168    pub tmdb_id: String,
    169    #[serde(default)]
    170    pub jellyfin_id: String,
    171}
    172
    173/// Envelope returned by the JRay `jray?t=` endpoint. Extra keys (future
    174/// `locations`, `trivia`, …) are ignored so the client tolerates schema growth.
    175#[derive(Debug, Clone, Deserialize)]
    176struct JRayContext {
    177    #[serde(default)]
    178    actors: Vec<JRayActor>,
    179}
    180
    181/// Online repository - fetches data from Jellyfin server via HTTP
    182pub struct OnlineRepository {
    183    http_client: Arc<HttpClient>,
    184    server_url: String,
    185    user_id: String,
    186    access_token: String,
    187    /// Reports the outcome of every server request to the connectivity monitor.
    188    /// This is the source of truth for the offline/online banner. `None` in
    189    /// tests / contexts where connectivity tracking isn't wired up.
    190    connectivity: Option<ConnectivityReporter>,
    191}
    192
    193impl OnlineRepository {
    194    /// The signed-in user these requests are made as. Needed by the favourites
    195    /// drain, which reads this user's queued rows. TRACES: UR-069 | DR-120
    196    pub fn user_id(&self) -> &str {
    197        &self.user_id
    198    }
    199
    200    pub fn new(
    201        http_client: Arc<HttpClient>,
    202        server_url: String,
    203        user_id: String,
    204        access_token: String,
    205    ) -> Self {
    206        Self {
    207            http_client,
    208            server_url,
    209            user_id,
    210            access_token,
    211            connectivity: None,
    212        }
    213    }
    214
    215    /// Attach a connectivity reporter so server outcomes drive the reachability
    216    /// state observed by the UI. See `report_outcome`.
    217    pub fn with_connectivity(mut self, reporter: ConnectivityReporter) -> Self {
    218        self.connectivity = Some(reporter);
    219        self
    220    }
    221
    222    /// Feed a request outcome into the connectivity monitor.
    223    ///
    224    /// Classification (matches docs/architecture/07-connectivity.md):
    225    /// - `Ok` / `Authentication` / `NotFound` / `Server` → the server answered,
    226    ///   so it is reachable → `report_success` (instant recovery).
    227    /// - `Network` → connection-level failure → `report_network_failure`
    228    ///   (subject to the time-window debounce before going offline).
    229    /// - `Database` → not a server signal → ignored.
    230    async fn report_outcome<T>(&self, result: &Result<T, RepoError>) {
    231        let Some(reporter) = &self.connectivity else {
    232            return;
    233        };
    234
    235        match result {
    236            Ok(_)
    237            | Err(RepoError::Authentication { .. })
    238            | Err(RepoError::NotFound { .. })
    239            | Err(RepoError::Server { .. }) => {
    240                reporter.report_success().await;
    241            }
    242            Err(RepoError::Network { message }) => {
    243                reporter.report_network_failure(Some(message.clone())).await;
    244            }
    245            Err(RepoError::Database { .. }) | Err(RepoError::Offline) => {
    246                // Local-side errors (cache failure / already-offline) — not a
    247                // statement about the server's reachability, so ignore them.
    248            }
    249        }
    250    }
    251
    252    /// Build authorization header
    253    fn auth_header(&self) -> String {
    254        HttpClient::build_auth_header(Some(&self.access_token), "jellytau-device")
    255    }
    256
    257    /// Download raw bytes from a URL using the shared authenticated HTTP client.
    258    /// Used by thumbnail cache to download images with proper auth and connection reuse.
    259    pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
    260        let request = self
    261            .http_client
    262            .client
    263            .get(url)
    264            .header("X-Emby-Authorization", self.auth_header())
    265            .build()
    266            .map_err(|e| format!("Failed to build request: {}", e))?;
    267
    268        let response = self
    269            .http_client
    270            .request_with_retry(request)
    271            .await
    272            .map_err(|e| format!("Download failed: {}", e))?;
    273
    274        if !response.status().is_success() {
    275            let status = response.status();
    276            let body = response.text().await.unwrap_or_default();
    277            let body_preview = if body.len() > 200 {
    278                &body[..200]
    279            } else {
    280                &body
    281            };
    282            return Err(format!("HTTP {} ({})", status, body_preview.trim()));
    283        }
    284
    285        response
    286            .bytes()
    287            .await
    288            .map(|b| b.to_vec())
    289            .map_err(|e| format!("Failed to read bytes: {}", e))
    290    }
    291
    292    /// Query the JRay plugin for the actors on screen at time `t` (seconds) in
    293    /// the given item. Returns an empty list when the plugin isn't installed or
    294    /// has no truth data for the item (HTTP 404), so callers can treat "no JRay"
    295    /// and "nobody on screen" identically. Other failures propagate.
    296    pub async fn get_jray_actors(
    297        &self,
    298        item_id: &str,
    299        t: f64,
    300    ) -> Result<Vec<JRayActor>, RepoError> {
    301        let endpoint = format!(
    302            "/Plugins/JRay/Items/{}/jray?t={}",
    303            urlencoding::encode(item_id),
    304            t
    305        );
    306        match self.get_json::<JRayContext>(&endpoint).await {
    307            Ok(context) => Ok(context.actors),
    308            // No plugin / no truth data for this item — not an error to the user.
    309            Err(RepoError::NotFound { .. }) => Ok(Vec::new()),
    310            Err(e) => Err(e),
    311        }
    312    }
    313
    314    /// Make authenticated GET request
    315    async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
    316        // Fast-fail when connectivity is known-offline. Without this every request
    317        // still runs the full HTTP retry/backoff cycle (~7s) before giving up,
    318        // which stalls cache-miss paths and makes offline browsing feel janky.
    319        // The offline recovery probe (connectivity monitor) flips us back to
    320        // reachable the moment the server returns, so this never sticks.
    321        if let Some(reporter) = &self.connectivity {
    322            if !reporter.is_reachable().await {
    323                return Err(RepoError::Offline);
    324            }
    325        }
    326
    327        let result = self.get_json_inner(endpoint).await;
    328        self.report_outcome(&result).await;
    329        result
    330    }
    331
    332    async fn get_json_inner<T: for<'de> Deserialize<'de>>(
    333        &self,
    334        endpoint: &str,
    335    ) -> Result<T, RepoError> {
    336        let url = format!("{}{}", self.server_url, endpoint);
    337
    338        let request = self
    339            .http_client
    340            .client
    341            .get(&url)
    342            .header("X-Emby-Authorization", self.auth_header())
    343            .build()
    344            .map_err(|e| RepoError::Network {
    345                message: format!("Failed to build request: {}", e),
    346            })?;
    347
    348        let response = self
    349            .http_client
    350            .request_with_retry(request)
    351            .await
    352            .map_err(|e| RepoError::Network {
    353                message: e.to_string(),
    354            })?;
    355
    356        if !response.status().is_success() {
    357            let status = response.status();
    358            if status.as_u16() == 401 || status.as_u16() == 403 {
    359                return Err(RepoError::Authentication {
    360                    message: format!("HTTP {}", status),
    361                });
    362            } else if status.as_u16() == 404 {
    363                return Err(RepoError::NotFound {
    364                    message: "Resource not found".to_string(),
    365                });
    366            } else {
    367                return Err(RepoError::Server {
    368                    message: format!("HTTP {}", status),
    369                });
    370            }
    371        }
    372
    373        // Get the response text first for better error reporting
    374        let text = response.text().await.map_err(|e| RepoError::Server {
    375            message: format!("Failed to read response: {}", e),
    376        })?;
    377
    378        // Try to deserialize and log the raw JSON on error
    379        serde_json::from_str(&text).map_err(|e| {
    380            error!(
    381                "[OnlineRepo] Failed to deserialize {} response: {}",
    382                endpoint, e
    383            );
    384            error!(
    385                "[OnlineRepo] Response body (first 1000 chars): {}",
    386                if text.len() > 1000 {
    387                    &text[..1000]
    388                } else {
    389                    &text
    390                }
    391            );
    392            RepoError::Server {
    393                message: format!("Failed to parse response: {}", e),
    394            }
    395        })
    396    }
    397
    398    /// Make authenticated POST request
    399    async fn post_json<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
    400        let result = self.post_json_inner(endpoint, body).await;
    401        self.report_outcome(&result).await;
    402        result
    403    }
    404
    405    async fn post_json_inner<T: Serialize>(
    406        &self,
    407        endpoint: &str,
    408        body: &T,
    409    ) -> Result<(), RepoError> {
    410        let url = format!("{}{}", self.server_url, endpoint);
    411
    412        let request = self
    413            .http_client
    414            .client
    415            .post(&url)
    416            .header("Content-Type", "application/json")
    417            .header("X-Emby-Authorization", self.auth_header())
    418            .json(body)
    419            .build()
    420            .map_err(|e| RepoError::Network {
    421                message: format!("Failed to build request: {}", e),
    422            })?;
    423
    424        let response = self
    425            .http_client
    426            .request_with_retry(request)
    427            .await
    428            .map_err(|e| RepoError::Network {
    429                message: e.to_string(),
    430            })?;
    431
    432        if !response.status().is_success() {
    433            let status = response.status();
    434            if status.as_u16() == 401 || status.as_u16() == 403 {
    435                return Err(RepoError::Authentication {
    436                    message: format!("HTTP {}", status),
    437                });
    438            } else {
    439                return Err(RepoError::Server {
    440                    message: format!("HTTP {}", status),
    441                });
    442            }
    443        }
    444
    445        Ok(())
    446    }
    447
    448    /// Make authenticated POST request and return response
    449    async fn post_json_response<T: Serialize, R: for<'de> Deserialize<'de>>(
    450        &self,
    451        endpoint: &str,
    452        body: &T,
    453    ) -> Result<R, RepoError> {
    454        let result = self.post_json_response_inner(endpoint, body).await;
    455        self.report_outcome(&result).await;
    456        result
    457    }
    458
    459    async fn post_json_response_inner<T: Serialize, R: for<'de> Deserialize<'de>>(
    460        &self,
    461        endpoint: &str,
    462        body: &T,
    463    ) -> Result<R, RepoError> {
    464        let url = format!("{}{}", self.server_url, endpoint);
    465
    466        // Log request body for debugging
    467        if let Ok(json) = serde_json::to_string_pretty(body) {
    468            debug!("[HTTP] POST {}", endpoint);
    469            debug!("[HTTP] Request body:\n{}", json);
    470        }
    471
    472        let request = self
    473            .http_client
    474            .client
    475            .post(&url)
    476            .header("Content-Type", "application/json")
    477            .header("X-Emby-Authorization", self.auth_header())
    478            .json(body)
    479            .build()
    480            .map_err(|e| RepoError::Network {
    481                message: format!("Failed to build request: {}", e),
    482            })?;
    483
    484        let response = self
    485            .http_client
    486            .request_with_retry(request)
    487            .await
    488            .map_err(|e| RepoError::Network {
    489                message: e.to_string(),
    490            })?;
    491
    492        if !response.status().is_success() {
    493            let status = response.status();
    494
    495            // Capture response body for error details
    496            let error_body = response
    497                .text()
    498                .await
    499                .unwrap_or_else(|_| "Failed to read error body".to_string());
    500            error!("[HTTP] Error response ({}): {}", status, error_body);
    501
    502            if status.as_u16() == 401 || status.as_u16() == 403 {
    503                return Err(RepoError::Authentication {
    504                    message: format!("HTTP {}: {}", status, error_body),
    505                });
    506            } else if status.as_u16() == 404 {
    507                return Err(RepoError::NotFound {
    508                    message: format!("Resource not found: {}", error_body),
    509                });
    510            } else {
    511                return Err(RepoError::Server {
    512                    message: format!("HTTP {}: {}", status, error_body),
    513                });
    514            }
    515        }
    516
    517        response.json().await.map_err(|e| RepoError::Server {
    518            message: format!("Failed to parse response: {}", e),
    519        })
    520    }
    521
    522    /// Ask the server to tear down a transcode this device started.
    523    ///
    524    /// Best-effort and deliberately un-retried: it runs on the path that opens a
    525    /// replacement stream, so a slow or failed stop must not delay playback. The
    526    /// worst case if it does fail is the job Jellyfin would have reaped on its
    527    /// own idle timer anyway — the new stream still has its own session id, so it
    528    /// no longer collides with the old one.
    529    ///
    530    /// TRACES: UR-074 | DR-177
    531    async fn stop_transcode(&self, play_session_id: &str) {
    532        let url = format!(
    533            "{}/Videos/ActiveEncodings?deviceId={}&playSessionId={}",
    534            self.server_url, DEVICE_ID, play_session_id
    535        );
    536
    537        let request = self
    538            .http_client
    539            .client
    540            .delete(&url)
    541            .header("X-Emby-Authorization", self.auth_header())
    542            .send();
    543
    544        match request.await {
    545            Ok(response) if response.status().is_success() => {
    546                debug!("[Transcode] Stopped previous encoding {}", play_session_id);
    547            }
    548            Ok(response) => {
    549                debug!(
    550                    "[Transcode] Server declined to stop encoding {}: HTTP {}",
    551                    play_session_id,
    552                    response.status()
    553                );
    554            }
    555            Err(e) => {
    556                debug!(
    557                    "[Transcode] Could not stop encoding {}: {}",
    558                    play_session_id, e
    559                );
    560            }
    561        }
    562    }
    563
    564    /// Get a video stream URL (initial play, resume, transcoded seeking,
    565    /// audio-track switching).
    566    ///
    567    /// Returns an HLS master playlist (`/Videos/{id}/master.m3u8`) transcoded to
    568    /// h264/aac. HLS is used rather than a progressive `stream.mp4` because the
    569    /// HTML5 `<video>` element (via HLS.js) starts playing within seconds and can
    570    /// seek within the stream, whereas a progressive MP4 transcode of HEVC source
    571    /// forces the server to transcode the whole file before playback can begin —
    572    /// which manifests as playback never starting.
    573    ///
    574    /// **There is deliberately no start-position parameter.** A playlist covers
    575    /// the whole item and asking for segment N *is* the seek, so a position would
    576    /// be redundant — and actively fatal: Jellyfin builds every segment URI by
    577    /// echoing this playlist's query string into it, while its segment handler
    578    /// rejects `StartTimeTicks > 0` outright (`ArgumentException` → `400`). One
    579    /// resume position here therefore 400s every segment of the stream, which
    580    /// presents as a resumed episode that simply never plays while the same
    581    /// episode from the beginning is fine. Resume by seeking the player once it
    582    /// has loaded. (The progressive `/Audio/universal` builder below has no
    583    /// segments and keeps its `StartTimeTicks`.)
    584    ///
    585    /// The stream is built against the current [`effective_streaming_quality`]
    586    /// ceiling (the per-playback override if one is set, else the device default):
    587    /// `MaxStreamingBitrate`/`VideoBitrate`/`AudioBitrate`, plus a `MaxHeight`
    588    /// that suits the budget. `Original` keeps the historical 20/18 Mbps
    589    /// allowance, which is a transcode ceiling rather than a user-facing limit.
    590    ///
    591    /// TRACES: UR-004, UR-074 | DR-140, DR-162, DR-177, DR-181 | UT-130, UT-156, UT-173, UT-182
    592    pub async fn get_video_stream_url(
    593        &self,
    594        item_id: &str,
    595        media_source_id: Option<&str>,
    596        audio_stream_index: Option<i32>,
    597    ) -> Result<String, RepoError> {
    598        let quality = effective_streaming_quality();
    599        // `Original` is uncapped as a *user* setting, but a transcode still needs
    600        // a ceiling to encode against — keep the values this endpoint has always
    601        // used so nothing changes for the default.
    602        let max_bitrate = quality.max_bitrate().unwrap_or(20_000_000);
    603        let video_bitrate = quality.video_bitrate().unwrap_or(18_000_000);
    604
    605        // Claim a distinct transcode identity and retire the one it replaces, so
    606        // the server is never running two jobs for this device at once. Doing it
    607        // here covers every path that re-opens a stream (quality switch,
    608        // transcoded seek, audio-track switch) rather than each remembering to.
    609        let (play_session_id, superseded) = begin_video_play_session();
    610        if let Some(previous) = superseded {
    611            self.stop_transcode(&previous).await;
    612        }
    613
    614        // Build an HLS transcode URL, naming every codec this renderer can
    615        // decode rather than only h264.
    616        //
    617        // The list is what lets the server *copy* the video stream instead of
    618        // re-encoding it. Hardcoding h264 meant an hevc source whose only
    619        // problem was its audio — eac3 on a device with no Dolby licence — got
    620        // its picture fully re-encoded to satisfy a sound problem. The server's
    621        // own transcoding URL already did the right thing (`h264,hevc`, video
    622        // copied, `TranscodeReasons=AudioCodecNotSupported`); this builder,
    623        // which takes over whenever a quality change or track switch re-opens
    624        // the stream, quietly did not — so changing quality turned a cheap
    625        // remux into a full transcode.
    626        //
    627        // On the webview path this still resolves to "h264" alone, so nothing
    628        // changes there.
    629        //
    630        // TRACES: UR-004, UR-080 | DR-234
    631        let (renderer_video_codecs, _) = super::device_profile::renderer_codecs();
    632        let mut params = vec![
    633            ("api_key", self.access_token.clone()),
    634            ("DeviceId", DEVICE_ID.to_string()),
    635            ("PlaySessionId", play_session_id),
    636            ("VideoCodec", renderer_video_codecs),
    637            ("AudioCodec", "aac".to_string()),
    638            ("MaxStreamingBitrate", max_bitrate.to_string()),
    639            ("VideoBitrate", video_bitrate.to_string()),
    640            ("AudioBitrate", quality.audio_bitrate().to_string()),
    641            (
    642                "TranscodingMaxAudioChannels",
    643                super::device_profile::max_audio_channels().to_string(),
    644            ),
    645            ("SegmentContainer", "ts".to_string()),
    646            ("TranscodingContainer", "ts".to_string()),
    647            ("TranscodingProtocol", "hls".to_string()),
    648            // Say "no subtitle" rather than leaving the choice open. An omitted
    649            // index is not neutral: the server then picks the source's own
    650            // default/forced track, and an image-based one can only be delivered
    651            // by burning it into the picture (DR-176). The negotiation already
    652            // sends this sentinel, but most streams are opened by rebuilding
    653            // *this* URL — a quality switch, a transcoded seek, an audio-track
    654            // switch — so it has to hold here too, independently of whatever
    655            // session state the server still holds.
    656            (
    657                "SubtitleStreamIndex",
    658                super::device_profile::playback_subtitle_stream_index().to_string(),
    659            ),
    660        ];
    661
    662        // Scale the picture down to what the budget can carry. Omitted for the
    663        // uncapped steps so the source resolution is preserved.
    664        if let Some(height) = quality.max_height() {
    665            params.push(("MaxHeight", height.to_string()));
    666        }
    667
    668        // Only pin an audio track when the user actually picked one. Jellyfin's
    669        // `MediaStream.Index` is global across *all* streams in a media source, so
    670        // index 0 is the video stream on virtually every file — defaulting to 0
    671        // asks the server to transcode the video stream as the audio track, which
    672        // yields a picture with no sound. Omitting the param lets the server use
    673        // the source's `DefaultAudioStreamIndex`.
    674        if let Some(index) = audio_stream_index {
    675            params.push(("AudioStreamIndex", index.to_string()));
    676        }
    677
    678        if let Some(source_id) = media_source_id {
    679            params.push(("MediaSourceId", source_id.to_string()));
    680        }
    681
    682        // Build query string (values are already safe, no encoding needed)
    683        let query = params
    684            .iter()
    685            .map(|(k, v)| format!("{}={}", k, v))
    686            .collect::<Vec<_>>()
    687            .join("&");
    688
    689        let url = format!(
    690            "{}/Videos/{}/master.m3u8?{}",
    691            self.server_url, item_id, query
    692        );
    693
    694        Ok(url)
    695    }
    696
    697    /// Get an **audio-only** stream URL for a *video* item, for the
    698    /// background-audio handoff (UR-040).
    699    ///
    700    /// TRACES: UR-040 | JA-032, DR-140 | UT-059, UT-130
    701    ///
    702    /// This deliberately targets `/Audio/{id}/universal`, NOT the video stream:
    703    /// the server extracts/transcodes only the item's audio track and streams
    704    /// pure audio bytes — no video frames reach the device, so there is no client
    705    /// video decode while backgrounded. Do NOT "optimize" this to reuse the
    706    /// `/Videos/.../master.m3u8` URL: that would keep the device decoding video,
    707    /// defeating the entire point of the feature.
    708    ///
    709    /// `AudioStreamIndex` carries the user's currently-selected audio track over
    710    /// from the video player; `StartTimeTicks` resumes at the handoff position.
    711    /// `universal` lets the server pick direct-play vs transcode per codec/device.
    712    ///
    713    /// The stream is a **progressive** container (mp3 over plain HTTP), NOT HLS:
    714    /// ExoPlayer plays this natively, whereas an HLS/`ts` transcode on the
    715    /// `/universal` endpoint (no `.m3u8` in the path) fails its progressive
    716    /// loader with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. mp3 is universally
    717    /// decodable and supports mid-stream `StartTimeTicks`.
    718    pub async fn build_audio_only_stream_url_for_video(
    719        &self,
    720        item_id: &str,
    721        media_source_id: Option<&str>,
    722        start_time_seconds: Option<f64>,
    723        audio_stream_index: Option<i32>,
    724    ) -> Result<String, RepoError> {
    725        let mut params = vec![
    726            ("UserId", self.user_id.clone()),
    727            ("api_key", self.access_token.clone()),
    728            ("DeviceId", DEVICE_ID.to_string()),
    729            // Progressive mp3 over HTTP — ExoPlayer-friendly; no HLS/ts.
    730            ("Container", "mp3".to_string()),
    731            ("AudioCodec", "mp3".to_string()),
    732            ("TranscodingContainer", "mp3".to_string()),
    733            ("TranscodingProtocol", "http".to_string()),
    734            // Audio-only is already far under any video cap, but a user on the
    735            // bottom rungs of the ladder asked for *less traffic*, so take the
    736            // lower of the two rather than always 384 kbps.
    737            // TRACES: UR-074 | DR-162
    738            (
    739                "MaxStreamingBitrate",
    740                effective_streaming_quality()
    741                    .audio_bitrate()
    742                    .min(384_000)
    743                    .to_string(),
    744            ),
    745        ];
    746
    747        // Carry the track over only if one was actually selected — index 0 is the
    748        // video stream, not "the first audio track" (see `get_video_stream_url`).
    749        if let Some(index) = audio_stream_index {
    750            params.push(("AudioStreamIndex", index.to_string()));
    751        }
    752
    753        if let Some(source_id) = media_source_id {
    754            params.push(("MediaSourceId", source_id.to_string()));
    755        }
    756
    757        if let Some(seconds) = start_time_seconds {
    758            let ticks = (seconds * 10_000_000.0) as i64;
    759            params.push(("StartTimeTicks", ticks.to_string()));
    760        }
    761
    762        let query = params
    763            .iter()
    764            .map(|(k, v)| format!("{}={}", k, v))
    765            .collect::<Vec<_>>()
    766            .join("&");
    767
    768        let url = format!("{}/Audio/{}/universal?{}", self.server_url, item_id, query);
    769
    770        Ok(url)
    771    }
    772
    773    /// Ask the server what it will do with this item, under our device profile
    774    /// and the ceiling currently in force.
    775    ///
    776    /// The single place the device profile is built and POSTed. Both
    777    /// [`get_playback_info`](Self::get_playback_info) (the legacy shape) and
    778    /// `get_stream_selection` (the DR-225 contract) go through it, so the
    779    /// profile they negotiate under cannot drift apart.
    780    ///
    781    /// TRACES: UR-004, UR-074, UR-079 | DR-225, DR-228
    782    async fn negotiate_playback(
    783        &self,
    784        item_id: &str,
    785    ) -> Result<(NegotiatedSource, String), RepoError> {
    786        let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
    787
    788        // What the renderer that will decode this can play. One source, shared
    789        // with the transcode URL builder and the client-side audio override, so
    790        // the profile we advertise and the stream we then ask for cannot
    791        // disagree. TRACES: UR-004, UR-080 | DR-234
    792        let (video_codecs, audio_codecs) = super::device_profile::renderer_codecs();
    793
    794        // Video plays in a webview <video> element on every platform, which
    795        // decodes a narrower audio set than the platform does — so the video
    796        // profile must claim less than the audio-only profile. Without this a
    797        // Dolby-licensed device advertises eac3, gets a direct play, and shows
    798        // picture with no sound.
    799        let video_audio_codecs = super::device_profile::video_audio_codecs(&audio_codecs);
    800
    801        info!("[DeviceProfile] Using video codecs: {}", video_codecs);
    802        info!("[DeviceProfile] Using audio codecs: {}", audio_codecs);
    803        info!(
    804            "[DeviceProfile] Audio codecs for video direct play: {}",
    805            video_audio_codecs
    806        );
    807
    808        // Bound every profile by what the audio route can actually voice, so a
    809        // multichannel track is downmixed by the server rather than direct-played
    810        // into a sink that has nowhere to put the extra channels.
    811        let max_audio_channels = super::device_profile::max_audio_channels().to_string();
    812        info!("[DeviceProfile] Max audio channels: {}", max_audio_channels);
    813
    814        // The user's bandwidth ceiling has to be part of the *negotiation*, not
    815        // just the transcode URL: `max_static_bitrate` is what makes the server
    816        // refuse to direct-play a source fatter than the cap, and without it a
    817        // 30 Mbps remux is handed over untouched and every URL parameter
    818        // downstream is moot. `Original` keeps the historical "no ceiling"
    819        // sentinel so the default path negotiates exactly as before.
    820        //
    821        // TRACES: UR-074 | DR-162
    822        let quality = effective_streaming_quality();
    823        let negotiated_bitrate = quality.max_bitrate().unwrap_or(999_999_999) as i64;
    824        if let Some(cap) = quality.max_bitrate() {
    825            info!(
    826                "[DeviceProfile] Streaming quality cap active: {} ({} bps)",
    827                quality.label(),
    828                cap
    829            );
    830        }
    831
    832        // Create device profile with detected hardware capabilities
    833        let device_profile = DeviceProfile {
    834            name: "JellyTau Native Player".to_string(),
    835            max_streaming_bitrate: negotiated_bitrate,
    836            max_static_bitrate: negotiated_bitrate,
    837            max_audio_channels: max_audio_channels.clone(),
    838            direct_play_profiles: vec![
    839                DirectPlayProfile {
    840                    profile_type: "Video".to_string(),
    841                    container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp".to_string(),
    842                    video_codec: Some(video_codecs.clone()),
    843                    // The webview decodes this stream, not ExoPlayer/MPV.
    844                    audio_codec: video_audio_codecs.clone(),
    845                },
    846                DirectPlayProfile {
    847                    profile_type: "Audio".to_string(),
    848                    container: "mp3,aac,flac,alac,wav,ogg,wma,opus".to_string(),
    849                    video_codec: None,
    850                    // Audio-only really is the native player's, so it keeps the
    851                    // full platform list — narrowing it would transcode music
    852                    // that plays perfectly well.
    853                    audio_codec: audio_codecs.clone(),
    854                },
    855            ],
    856            transcoding_profiles: vec![
    857                TranscodingProfile {
    858                    profile_type: "Video".to_string(),
    859                    context: "Streaming".to_string(),
    860                    protocol: "hls".to_string(),
    861                    container: "ts".to_string(),
    862                    // The server may only transcode *to* something this renderer
    863                    // can decode. This said "h264,hevc" unconditionally while the
    864                    // direct-play profile claims h264 alone on the webview path —
    865                    // a straight contradiction: it tells the server "I cannot
    866                    // play hevc, so re-encode it" and then "re-encoding it to
    867                    // hevc is fine". When the server took that option the webview
    868                    // got a stream it could not decode, which presents as video
    869                    // stuck on its first frame rather than as an error.
    870                    //
    871                    // Capped at the two codecs a Jellyfin server actually
    872                    // encodes, so a wider decode list never asks it for an av1
    873                    // encode. TRACES: UR-004, UR-080 | DR-234
    874                    video_codec: Some(
    875                        if video_codecs.contains("hevc") {
    876                            "h264,hevc"
    877                        } else {
    878                            "h264"
    879                        }
    880                        .to_string(),
    881                    ),
    882                    audio_codec: "aac,mp3".to_string(),
    883                    max_audio_channels: max_audio_channels.clone(),
    884                },
    885                TranscodingProfile {
    886                    profile_type: "Audio".to_string(),
    887                    context: "Streaming".to_string(),
    888                    protocol: "http".to_string(),
    889                    container: "mp3".to_string(),
    890                    video_codec: None,
    891                    audio_codec: "mp3".to_string(),
    892                    max_audio_channels: max_audio_channels.clone(),
    893                },
    894            ],
    895            subtitle_profiles: super::device_profile::subtitle_profiles()
    896                .into_iter()
    897                .map(|(format, method)| SubtitleProfile {
    898                    format: format.to_string(),
    899                    method: method.to_string(),
    900                })
    901                .collect(),
    902        };
    903
    904        // POST to PlaybackInfo with device profile containing detected codecs
    905        let request_body = PlaybackInfoRequest {
    906            user_id: self.user_id.clone(),
    907            audio_stream_index: None, // Let the server pick the source default
    908            // Never let the server choose a subtitle track for us. Omitting this
    909            // makes it honour the source's default/forced flag, and an image-based
    910            // default (PGS) it cannot send as a sidecar becomes SubtitleMethod=Encode
    911            // — burn-in, which forces a full video re-encode of a stream that would
    912            // otherwise be remuxed. The app renders subtitles itself (UR-020).
    913            //
    914            // TRACES: UR-020, UR-004 | DR-176 | UT-168
    915            subtitle_stream_index: Some(super::device_profile::playback_subtitle_stream_index()),
    916            start_time_ticks: 0,
    917            is_playback: true,
    918            auto_open_live_stream: true,
    919            // The user's cap, or the historical 20 Mbps allowance when uncapped.
    920            max_streaming_bitrate: quality.max_bitrate().unwrap_or(20_000_000) as i64,
    921            device_profile: Some(device_profile), // Now sending profile with detected codecs
    922        };
    923
    924        let response: PlaybackInfoResponse =
    925            self.post_json_response(&endpoint, &request_body).await?;
    926        let source = response
    927            .media_sources
    928            .into_iter()
    929            .next()
    930            .ok_or(RepoError::NotFound {
    931                message: "No media sources available".to_string(),
    932            })?;
    933
    934        Ok((source, response.play_session_id))
    935    }
    936
    937    /// Decide what stream to play, and describe it fully enough that no consumer
    938    /// has to guess.
    939    ///
    940    /// This replaces `get_video_stream_url`'s "always build an HLS transcode"
    941    /// with an actual decision. Measured against the development server, that
    942    /// distinction is worth 85% of plays on Android (ExoPlayer decodes hevc, and
    943    /// the sampled library is ~80% hevc) and about 7% on Linux, where the
    944    /// WebKitGTK profile can only claim h264 until the native-video work lands.
    945    /// The Linux figure is a property of the renderer, not of this code.
    946    ///
    947    /// The returned [`StreamSelection`] carries the transport explicitly so the
    948    /// frontend stops testing the URL for `.m3u8`, and the quality ladder for
    949    /// *this* source so the picker stops offering rungs that mean nothing for it.
    950    ///
    951    /// TRACES: UR-070, UR-079 | DR-225, DR-226, DR-227, DR-228 | UT-213
    952    pub async fn get_stream_selection(
    953        &self,
    954        item_id: &str,
    955        media_source_id: Option<&str>,
    956        audio_stream_index: Option<i32>,
    957    ) -> Result<StreamSelection, RepoError> {
    958        let (source, play_session_id) = self.negotiate_playback(item_id).await?;
    959
    960        let quality = effective_streaming_quality();
    961        let source_bitrate = source.bitrate.and_then(|b| u64::try_from(b).ok());
    962        let available = quality_options_for_source(source_bitrate);
    963
    964        // Judge the audio track we would actually be served against what this
    965        // platform's video renderer can decode, and override the server's
    966        // answer. Jellyfin 10.11.5 honours a DirectPlayProfile's container and
    967        // video codec but ignores its audio codec, so it will offer direct play
    968        // for an E-AC-3 track and the webview renders the picture in silence.
    969        let audio_streams: Vec<(Option<&str>, bool)> = source
    970            .media_streams
    971            .iter()
    972            .filter(|stream| stream.stream_type == "Audio")
    973            .map(|stream| (stream.codec.as_deref(), stream.is_default))
    974            .collect();
    975        let audio_forces_transcode = super::device_profile::audio_forces_transcode(&audio_streams);
    976
    977        // A pinned audio track is a request the *source file* cannot satisfy: a
    978        // direct play serves the file's own default track, so honouring the
    979        // choice means having the server produce a stream built around it.
    980        let track_pinned = audio_stream_index.is_some();
    981
    982        let effective_source_id = media_source_id.unwrap_or(&source.id).to_string();
    983
    984        let decided = decide_playback_kind(&source, audio_forces_transcode, track_pinned);
    985
    986        let selection = match decided {
    987            PlaybackKind::Transcode => {
    988                let url = if let Some(transcoding_url) = source
    989                    .transcoding_url
    990                    .as_deref()
    991                    .filter(|_| !track_pinned && audio_stream_index.is_none())
    992                {
    993                    // The server started this job and named the session — adopt
    994                    // it, or a later quality switch / seek has no previous job to
    995                    // stop and contends with the one currently playing.
    996                    if let Some(previous) = adopt_video_play_session(play_session_id.clone()) {
    997                        self.stop_transcode(&previous).await;
    998                    }
    999                    // The server built this URL from its *own* subtitle verdict,
    1000                    // so it can hand back the burn-in the request declined.
    1001                    // TRACES: UR-020, UR-004 | DR-176
    1002                    format!(
    1003                        "{}{}",
    1004                        self.server_url,
    1005                        super::device_profile::without_server_chosen_subtitle(transcoding_url)
    1006                    )
    1007                } else {
    1008                    // Build our own transcode URL: either the server offered
    1009                    // none, or the viewer pinned an audio track the server's URL
    1010                    // does not carry.
    1011                    self.get_video_stream_url(
    1012                        item_id,
    1013                        Some(&effective_source_id),
    1014                        audio_stream_index,
    1015                    )
    1016                    .await?
    1017                };
    1018
    1019                StreamSelection {
    1020                    url,
    1021                    // Every transcode this app requests is HLS — a progressive
    1022                    // transcode of an HEVC source makes the server encode the
    1023                    // whole file before playback starts, which presents as
    1024                    // playback never beginning (DR-140).
    1025                    transport: Transport::Hls,
    1026                    playback_kind: PlaybackKind::Transcode,
    1027                    rendition: Some(Rendition {
    1028                        quality,
    1029                        max_bitrate: quality.max_bitrate(),
    1030                        max_height: quality.max_height(),
    1031                        video_codec: Some("h264".to_string()),
    1032                        audio_codec: Some("aac".to_string()),
    1033                    }),
    1034                    available,
    1035                    media_source_id: Some(effective_source_id),
    1036                    play_session_id: Some(play_session_id),
    1037                    needs_transcoding: PlaybackKind::Transcode.needs_transcoding(),
    1038                }
    1039            }
    1040            kind @ (PlaybackKind::DirectPlay | PlaybackKind::DirectStream) => {
    1041                // The original bytes, or a remux of them. No `AudioStreamIndex`:
    1042                // `static=true` serves the file untouched, and pinning index 0
    1043                // (which is the *video* stream — the index is global across all
    1044                // streams) only misleads servers that do honour it.
    1045                let url = format!(
    1046                    "{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId={}&api_key={}&userId={}",
    1047                    self.server_url,
    1048                    item_id,
    1049                    effective_source_id,
    1050                    DEVICE_ID,
    1051                    self.access_token,
    1052                    self.user_id
    1053                );
    1054
    1055                StreamSelection {
    1056                    url,
    1057                    // A static file over HTTP, seekable by byte range — not a
    1058                    // playlist. This is the case the `.m3u8` sniff got wrong in
    1059                    // the safe direction only by accident.
    1060                    transport: Transport::Progressive,
    1061                    playback_kind: kind,
    1062                    // Direct play *is* the source; there is no chosen rendition
    1063                    // to report, and reporting the ceiling that happens to be set
    1064                    // would misdescribe what the viewer is receiving.
    1065                    rendition: None,
    1066                    available,
    1067                    media_source_id: Some(effective_source_id),
    1068                    play_session_id: Some(play_session_id),
    1069                    needs_transcoding: kind.needs_transcoding(),
    1070                }
    1071            }
    1072        };
    1073
    1074        info!(
    1075            "[StreamSelection] {} → {:?} over {:?} (source bitrate {:?}, ceiling {})",
    1076            item_id,
    1077            selection.playback_kind,
    1078            selection.transport,
    1079            source_bitrate,
    1080            quality.label(),
    1081        );
    1082
    1083        Ok(selection)
    1084    }
    1085}
    1086
    1087// Jellyfin API response types (PascalCase from server)
    1088#[derive(Debug, Deserialize)]
    1089#[serde(rename_all = "PascalCase")]
    1090struct ItemsResponse {
    1091    items: Vec<JellyfinItem>,
    1092    total_record_count: usize,
    1093}
    1094
    1095/// Jellyfin playlist creation response
    1096#[derive(Debug, Deserialize)]
    1097#[serde(rename_all = "PascalCase")]
    1098struct CreatePlaylistResponse {
    1099    id: String,
    1100}
    1101
    1102/// Jellyfin playlist items response — items include PlaylistItemId
    1103#[derive(Debug, Deserialize)]
    1104#[serde(rename_all = "PascalCase")]
    1105#[allow(dead_code)]
    1106struct PlaylistItemsResponse {
    1107    items: Vec<JellyfinPlaylistItem>,
    1108    total_record_count: usize,
    1109}
    1110
    1111/// A playlist item from Jellyfin — wraps a regular item with an entry-scoped ID
    1112#[derive(Debug, Deserialize)]
    1113#[serde(rename_all = "PascalCase")]
    1114struct JellyfinPlaylistItem {
    1115    playlist_item_id: String,
    1116    #[serde(flatten)]
    1117    item: JellyfinItem,
    1118}
    1119
    1120#[derive(Debug, Deserialize)]
    1121#[serde(rename_all = "PascalCase")]
    1122struct JellyfinItem {
    1123    id: String,
    1124    name: String,
    1125    #[serde(rename = "Type")]
    1126    item_type: String,
    1127    #[serde(default)]
    1128    is_folder: bool,
    1129    parent_id: Option<String>,
    1130    overview: Option<String>,
    1131    genres: Option<Vec<String>>,
    1132    production_year: Option<i32>,
    1133    premiere_date: Option<String>,
    1134    community_rating: Option<f64>,
    1135    official_rating: Option<String>,
    1136    run_time_ticks: Option<i64>,
    1137    image_tags: Option<ImageTags>,
    1138    backdrop_image_tags: Option<Vec<String>>,
    1139    parent_backdrop_image_tags: Option<Vec<String>>,
    1140    album_id: Option<String>,
    1141    album: Option<String>,
    1142    album_artist: Option<String>,
    1143    artists: Option<Vec<String>>,
    1144    artist_items: Option<Vec<crate::repository::types::ArtistItem>>,
    1145    index_number: Option<i32>,
    1146    parent_index_number: Option<i32>,
    1147    series_id: Option<String>,
    1148    series_name: Option<String>,
    1149    season_id: Option<String>,
    1150    season_name: Option<String>,
    1151    media_streams: Option<Vec<JellyfinMediaStream>>,
    1152    media_sources: Option<Vec<JellyfinMediaSource>>,
    1153    people: Option<Vec<crate::repository::types::Person>>,
    1154    user_data: Option<JellyfinUserData>,
    1155}
    1156
    1157/// Per-user state Jellyfin attaches to an item (favourite, played, resume).
    1158///
    1159/// Returned on every `/Users/{uid}/Items*` response; we additionally name
    1160/// `UserData` in the `Fields=` list so the shape is explicit rather than
    1161/// dependent on the server's default field set.
    1162///
    1163/// `PlaybackPositionTicks` is the server's resume position for the item, and the
    1164/// only place it is published — Jellyfin has no per-item "resume position"
    1165/// endpoint, so reading `UserData` *is* how a resume point is obtained.
    1166///
    1167/// TRACES: UR-019, UR-069 | DR-113, JA-013, JA-034 | UT-099
    1168#[derive(Debug, Deserialize, Clone)]
    1169#[serde(rename_all = "PascalCase")]
    1170struct JellyfinUserData {
    1171    playback_position_ticks: Option<i64>,
    1172    #[serde(rename = "Played")]
    1173    is_played: Option<bool>,
    1174    is_favorite: Option<bool>,
    1175    play_count: Option<i32>,
    1176    last_played_date: Option<String>,
    1177}
    1178
    1179impl From<JellyfinUserData> for UserData {
    1180    fn from(jf: JellyfinUserData) -> Self {
    1181        UserData {
    1182            playback_position_ticks: jf.playback_position_ticks,
    1183            playback_position_ms: jf.playback_position_ticks.map(crate::domain::ticks_to_ms),
    1184            is_played: jf.is_played,
    1185            is_favorite: jf.is_favorite,
    1186            play_count: jf.play_count,
    1187            last_played_date: jf.last_played_date,
    1188            playback_context_type: None,
    1189            playback_context_id: None,
    1190        }
    1191    }
    1192}
    1193
    1194/// Build the Jellyfin endpoint for a folder listing.
    1195///
    1196/// Extracted from `get_items` so the query it produces — in particular the
    1197/// favourites filter — can be asserted without standing up an HTTP server.
    1198///
    1199/// TRACES: UR-007, UR-067 | DR-116 | UT-104
    1200fn build_get_items_endpoint(
    1201    user_id: &str,
    1202    parent_id: &str,
    1203    options: Option<&GetItemsOptions>,
    1204) -> String {
    1205    // Every value below is percent-encoded before it goes into the query
    1206    // string, the same way `Genres` and `SearchTerm` already are: these are
    1207    // values, not URL syntax, so a space or an `&` in one must not split it
    1208    // into another parameter.
    1209    //
    1210    // TRACES: UR-007 | DR-212 | UT-206
    1211    let mut endpoint = format!(
    1212        "/Users/{}/Items?ParentId={}",
    1213        user_id,
    1214        urlencoding::encode(parent_id)
    1215    );
    1216
    1217    if let Some(opts) = options {
    1218        if let Some(limit) = opts.limit {
    1219            endpoint.push_str(&format!("&Limit={}", limit));
    1220        }
    1221        if let Some(start_index) = opts.start_index {
    1222            endpoint.push_str(&format!("&StartIndex={}", start_index));
    1223        }
    1224        if let Some(types) = &opts.include_item_types {
    1225            // Encode each type, not the joined string: the comma is the
    1226            // list separator Jellyfin splits on.
    1227            let encoded: Vec<String> = types
    1228                .iter()
    1229                .map(|t| urlencoding::encode(t).into_owned())
    1230                .collect();
    1231            endpoint.push_str(&format!("&IncludeItemTypes={}", encoded.join(",")));
    1232        }
    1233        // An explicit sort always wins; the container's default only fills the
    1234        // gap when the caller named none. A caller that names neither gets no
    1235        // SortBy at all, leaving the server's own order intact.
    1236        //
    1237        // TRACES: UR-007 | DR-257 | UT-229
    1238        let default_sort = default_listing_sort(opts.parent_kind);
    1239        let sort_by = opts
    1240            .sort_by
    1241            .as_deref()
    1242            .or(default_sort.map(|(field, _)| field));
    1243        let sort_order = opts
    1244            .sort_order
    1245            .as_deref()
    1246            .or(default_sort.map(|(_, order)| order));
    1247
    1248        if let Some(sort_by) = sort_by {
    1249            // SortBy is likewise a comma-delimited list (`hybrid.rs` sends
    1250            // "ParentIndexNumber,IndexNumber,SortName"), so encode per field.
    1251            let encoded: Vec<String> = sort_by
    1252                .split(',')
    1253                .map(|field| urlencoding::encode(field).into_owned())
    1254                .collect();
    1255            endpoint.push_str(&format!("&SortBy={}", encoded.join(",")));
    1256        }
    1257        if let Some(sort_order) = sort_order {
    1258            endpoint.push_str(&format!("&SortOrder={}", urlencoding::encode(sort_order)));
    1259        }
    1260        if let Some(recursive) = opts.recursive {
    1261            endpoint.push_str(&format!("&Recursive={}", recursive));
    1262        }
    1263        if let Some(genres) = &opts.genres {
    1264            if !genres.is_empty() {
    1265                // Genre names may contain spaces/ampersands, so percent-encode each.
    1266                let encoded: Vec<String> = genres
    1267                    .iter()
    1268                    .map(|g| urlencoding::encode(g).into_owned())
    1269                    .collect();
    1270                endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
    1271            }
    1272        }
    1273        // TRACES: UR-067 | DR-116 | UT-104
    1274        if opts.favorites_only == Some(true) {
    1275            endpoint.push_str("&Filters=IsFavorite");
    1276        }
    1277    }
    1278
    1279    // Request image fields for list views (People only needed in get_item
    1280    // detail view). Genres is needed so cached items carry their genres,
    1281    // which lets the offline store derive genre lists + per-genre counts.
    1282    endpoint
    1283        .push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
    1284    endpoint
    1285}
    1286
    1287/// Build the Jellyfin endpoint for a "recently added" listing.
    1288///
    1289/// `GroupItems=true` is the load-bearing parameter: Jellyfin defaults it to
    1290/// `false`, which returns each newly-added *leaf* separately, so importing one
    1291/// 14-track album pushed 14 rows into "recently added" and buried everything
    1292/// else. With grouping on, the server collapses children into the container
    1293/// that was added — an album appears once, while movies (which have no such
    1294/// container) are unaffected.
    1295///
    1296/// Pulled out of `get_latest_items` so the query can be asserted without an
    1297/// HTTP server, matching `build_favorites_endpoint`.
    1298///
    1299/// TRACES: UR-024, UR-034 | IR-024, JA-016
    1300fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
    1301    format!(
    1302        "/Users/{}/Items/Latest?ParentId={}&Limit={}&GroupItems=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
    1303        user_id,
    1304        parent_id,
    1305        limit.unwrap_or(16)
    1306    )
    1307}
    1308
    1309/// How many rows to ask the server for, given how many the row will show.
    1310///
    1311/// Collapsing only ever shrinks a listing, so a request for exactly the number
    1312/// of cards the row shows can come back as a handful after one freshly-ripped
    1313/// album folds its tracks together. Over-fetch and truncate after collapsing.
    1314///
    1315/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-244
    1316fn latest_items_fetch_limit(limit: usize) -> usize {
    1317    limit.saturating_mul(3)
    1318}
    1319
    1320/// Collapse newly-added *tracks* into the album they belong to.
    1321///
    1322/// `GroupItems=true` asks Jellyfin to do this server-side, but it only groups a
    1323/// track whose parent chain actually resolves a `MusicAlbum`, and older servers
    1324/// ignore the parameter outright — so "Recently Added" still filled up with one
    1325/// card per song of a single import. Grouping again here makes the row's shape
    1326/// a property of this app rather than of the server it is talking to.
    1327///
    1328/// Rules: a track collapses only when it names an `album_id` (without one there
    1329/// is no album to open, so a standalone track stays a track); if the server did
    1330/// return the album row itself, that row wins and its tracks are dropped; the
    1331/// album takes the position of the first of its tracks, so recency order
    1332/// survives. Everything else — movies, episodes, folders — passes through
    1333/// untouched.
    1334///
    1335/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241, UT-242, UT-243
    1336fn collapse_tracks_into_albums(items: Vec<MediaItem>) -> Vec<MediaItem> {
    1337    use std::collections::HashSet;
    1338
    1339    // Albums the server already returned in their own right: their tracks are
    1340    // redundant, and the real row carries detail a stand-in cannot.
    1341    let server_albums: HashSet<String> = items
    1342        .iter()
    1343        .filter(|i| i.kind == crate::domain::MediaKind::Album)
    1344        .map(|i| i.id.clone())
    1345        .collect();
    1346
    1347    let mut seen_albums: HashSet<String> = HashSet::new();
    1348    let mut collapsed = Vec::with_capacity(items.len());
    1349
    1350    for item in items {
    1351        let album_id = match (&item.kind, &item.album_id) {
    1352            (crate::domain::MediaKind::Track, Some(id)) => id.clone(),
    1353            _ => {
    1354                collapsed.push(item);
    1355                continue;
    1356            }
    1357        };
    1358
    1359        if server_albums.contains(&album_id) || !seen_albums.insert(album_id.clone()) {
    1360            continue;
    1361        }
    1362        collapsed.push(album_from_track(&item, album_id));
    1363    }
    1364
    1365    collapsed
    1366}
    1367
    1368/// Build the album card a collapsed group of tracks stands for.
    1369///
    1370/// The track's own artwork tag is reused: Jellyfin serves an item's primary
    1371/// image by id and treats the tag as a cache key, and an embedded-art track
    1372/// carries the album cover anyway.
    1373///
    1374/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241
    1375fn album_from_track(track: &MediaItem, album_id: String) -> MediaItem {
    1376    MediaItem {
    1377        id: album_id,
    1378        name: track
    1379            .album_name
    1380            .clone()
    1381            .unwrap_or_else(|| "Unknown Album".to_string()),
    1382        item_type: "MusicAlbum".to_string(),
    1383        kind: crate::domain::MediaKind::Album,
    1384        is_folder: true,
    1385        server_id: track.server_id.clone(),
    1386        parent_id: None,
    1387        library_id: track.library_id.clone(),
    1388        overview: None,
    1389        genres: track.genres.clone(),
    1390        production_year: track.production_year,
    1391        premiere_date: track.premiere_date.clone(),
    1392        community_rating: None,
    1393        official_rating: None,
    1394        // A track's duration says nothing about the album's, and its track
    1395        // number, album link and streams belong to the leaf alone.
    1396        runtime_ticks: None,
    1397        duration_ms: None,
    1398        primary_image_tag: track.primary_image_tag.clone(),
    1399        image_id: track.image_id.clone(),
    1400        backdrop_image_tags: track.backdrop_image_tags.clone(),
    1401        parent_backdrop_image_tags: track.parent_backdrop_image_tags.clone(),
    1402        album_id: None,
    1403        album_name: None,
    1404        album_artist: track.album_artist.clone(),
    1405        artists: track.artists.clone(),
    1406        artist_items: track.artist_items.clone(),
    1407        index_number: None,
    1408        parent_index_number: None,
    1409        series_id: None,
    1410        series_name: None,
    1411        season_id: None,
    1412        season_name: None,
    1413        user_data: None,
    1414        media_streams: None,
    1415        media_sources: None,
    1416        people: None,
    1417    }
    1418}
    1419
    1420/// Build the Jellyfin endpoint for a Next Up listing.
    1421///
    1422/// `EnableResumable=false` is the point of this query: the server default is
    1423/// `true`, which makes a partially-watched episode its own series' "next up" —
    1424/// the very episode `/Items/Resume` returns — so Continue Watching and Next Up
    1425/// end up showing the same cards. Next Up should only ever offer episodes the
    1426/// viewer has not started. Servers predating the parameter ignore it, which is
    1427/// why the frontend also drops in-progress entries (DR-197).
    1428///
    1429/// Pulled out of `get_next_up_episodes` so the query can be asserted without an
    1430/// HTTP server, matching `build_favorites_endpoint`.
    1431///
    1432/// TRACES: UR-023, UR-059 | DR-197, JA-014, JA-036 | UT-190, UT-191
    1433fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
    1434    let mut endpoint = format!(
    1435        "/Shows/NextUp?UserId={}&Limit={}&EnableResumable=false&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
    1436        user_id,
    1437        limit.unwrap_or(16)
    1438    );
    1439
    1440    if let Some(sid) = series_id {
    1441        endpoint.push_str(&format!("&SeriesId={}", sid));
    1442    }
    1443
    1444    endpoint
    1445}
    1446
    1447/// Build the Jellyfin endpoint for a favourites listing.
    1448///
    1449/// Pulled out of `get_favorites` so the query can be asserted without an HTTP
    1450/// server. `scope` is expanded here — `SearchScope::All` yields `None`, and the
    1451/// `IncludeItemTypes` filter is then **omitted entirely** rather than sent as a
    1452/// union, which would silently drop every type nobody enumerated (see
    1453/// `SearchScope::item_types`).
    1454///
    1455/// TRACES: UR-067 | DR-115, JA-033 | UT-100
    1456fn build_favorites_endpoint(
    1457    user_id: &str,
    1458    scope: SearchScope,
    1459    options: Option<&GetItemsOptions>,
    1460) -> String {
    1461    let mut endpoint = format!("/Users/{}/Items?Filters=IsFavorite&Recursive=true", user_id);
    1462
    1463    if let Some(types) = scope.item_types() {
    1464        endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
    1465    }
    1466
    1467    // Jellyfin has no "date favourited", so name order is the only stable sort
    1468    // available; callers may still override it.
    1469    let sort_by = options
    1470        .and_then(|o| o.sort_by.as_deref())
    1471        .unwrap_or("SortName");
    1472    let sort_order = options
    1473        .and_then(|o| o.sort_order.as_deref())
    1474        .unwrap_or("Ascending");
    1475    endpoint.push_str(&format!("&SortBy={}&SortOrder={}", sort_by, sort_order));
    1476
    1477    if let Some(limit) = options.and_then(|o| o.limit) {
    1478        endpoint.push_str(&format!("&Limit={}", limit));
    1479    }
    1480    if let Some(start_index) = options.and_then(|o| o.start_index) {
    1481        endpoint.push_str(&format!("&StartIndex={}", start_index));
    1482    }
    1483
    1484    endpoint
    1485        .push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
    1486    endpoint
    1487}
    1488
    1489// ImageTags from Jellyfin API - can be a HashMap with various image type keys
    1490// We use a wrapper to extract just the Primary tag we need
    1491#[derive(Debug, Deserialize)]
    1492#[serde(untagged)]
    1493enum ImageTags {
    1494    // Modern format: HashMap
    1495    Map(std::collections::HashMap<String, String>),
    1496    // Legacy/alternative format: structured
    1497    Structured {
    1498        #[serde(rename = "Primary")]
    1499        primary: Option<String>,
    1500    },
    1501}
    1502
    1503impl ImageTags {
    1504    fn primary(&self) -> Option<String> {
    1505        match self {
    1506            ImageTags::Map(map) => map.get("Primary").cloned(),
    1507            ImageTags::Structured { primary } => primary.clone(),
    1508        }
    1509    }
    1510}
    1511
    1512#[derive(Debug, Deserialize, Clone)]
    1513#[serde(rename_all = "PascalCase")]
    1514struct JellyfinMediaStream {
    1515    #[serde(rename = "Type")]
    1516    stream_type: String,
    1517    codec: Option<String>,
    1518    language: Option<String>,
    1519    display_title: Option<String>,
    1520    index: i32,
    1521    is_default: bool,
    1522    #[serde(default)]
    1523    is_forced: bool,
    1524}
    1525
    1526#[derive(Debug, Deserialize, Clone)]
    1527#[serde(rename_all = "PascalCase")]
    1528struct JellyfinMediaSource {
    1529    id: String,
    1530    name: String,
    1531    container: Option<String>,
    1532    size: Option<i64>,
    1533    bitrate: Option<i32>,
    1534    supports_direct_play: bool,
    1535    supports_direct_stream: bool,
    1536    supports_transcoding: bool,
    1537    direct_stream_url: Option<String>,
    1538}
    1539
    1540impl JellyfinItem {
    1541    fn into_media_item(self, server_id: String) -> MediaItem {
    1542        // Extract image tags before consuming self
    1543        let primary_tag = self.image_tags.as_ref().and_then(|tags| tags.primary());
    1544        let backdrop_tags = self.backdrop_image_tags;
    1545
    1546        let kind = crate::domain::kind_from_jellyfin(&self.item_type, self.is_folder);
    1547
    1548        MediaItem {
    1549            id: self.id,
    1550            name: self.name,
    1551            item_type: self.item_type,
    1552            kind,
    1553            is_folder: self.is_folder,
    1554            server_id,
    1555            parent_id: self.parent_id,
    1556            library_id: None, // Not provided by Jellyfin API directly
    1557            overview: self.overview,
    1558            genres: self.genres,
    1559            production_year: self.production_year,
    1560            premiere_date: self.premiere_date,
    1561            community_rating: self.community_rating,
    1562            official_rating: self.official_rating,
    1563            runtime_ticks: self.run_time_ticks,
    1564            duration_ms: self.run_time_ticks.map(crate::domain::ticks_to_ms),
    1565            primary_image_tag: primary_tag.clone(),
    1566            image_id: primary_tag,
    1567            backdrop_image_tags: backdrop_tags,
    1568            parent_backdrop_image_tags: self.parent_backdrop_image_tags,
    1569            album_id: self.album_id,
    1570            album_name: self.album,
    1571            album_artist: self.album_artist,
    1572            artists: self.artists,
    1573            artist_items: self.artist_items,
    1574            index_number: self.index_number,
    1575            parent_index_number: self.parent_index_number,
    1576            series_id: self.series_id,
    1577            series_name: self.series_name,
    1578            season_id: self.season_id,
    1579            season_name: self.season_name,
    1580            // Favourite/played/resume state as the server sees it. TRACES:
    1581            // UR-069 | DR-113, JA-034
    1582            user_data: self.user_data.map(UserData::from),
    1583            media_streams: self.media_streams.map(|streams| {
    1584                streams
    1585                    .into_iter()
    1586                    .map(|s| {
    1587                        let kind = crate::domain::stream_kind_from_jellyfin(&s.stream_type);
    1588                        // Only a subtitle can be a sidecar; asked of anything
    1589                        // else the question has no answer. TRACES: UR-020 |
    1590                        // DR-176 | UT-168
    1591                        let supports_external_delivery =
    1592                            (kind == crate::domain::StreamKind::Subtitle).then(|| {
    1593                                super::device_profile::subtitle_supports_external_delivery(
    1594                                    s.codec.as_deref(),
    1595                                )
    1596                            });
    1597                        crate::repository::types::MediaStream {
    1598                            kind,
    1599                            stream_type: s.stream_type,
    1600                            codec: s.codec,
    1601                            language: s.language,
    1602                            display_title: s.display_title,
    1603                            index: s.index,
    1604                            is_default: s.is_default,
    1605                            is_forced: s.is_forced,
    1606                            supports_external_delivery,
    1607                        }
    1608                    })
    1609                    .collect()
    1610            }),
    1611            media_sources: self.media_sources.map(|sources| {
    1612                sources
    1613                    .into_iter()
    1614                    .map(|s| crate::repository::types::MediaSource {
    1615                        id: s.id,
    1616                        name: s.name,
    1617                        container: s.container,
    1618                        size: s.size,
    1619                        bitrate: s.bitrate,
    1620                        supports_direct_play: s.supports_direct_play,
    1621                        supports_direct_stream: s.supports_direct_stream,
    1622                        supports_transcoding: s.supports_transcoding,
    1623                        direct_stream_url: s.direct_stream_url,
    1624                    })
    1625                    .collect()
    1626            }),
    1627            people: self.people,
    1628        }
    1629    }
    1630}
    1631
    1632// ---------------------------------------------------------------------------
    1633// PlaybackInfo negotiation
    1634//
    1635// These types were local to `get_playback_info`. They are module-scope now
    1636// because `get_stream_selection` negotiates with the same request and has to
    1637// read the same answer — restating the device profile in a second place is
    1638// exactly how the cap used to leak (a negotiation authorising a direct play the
    1639// URL builder then never got to constrain).
    1640//
    1641// TRACES: UR-079 | DR-225, DR-228
    1642// ---------------------------------------------------------------------------
    1643
    1644#[derive(Debug, Serialize)]
    1645#[serde(rename_all = "PascalCase")]
    1646struct PlaybackInfoRequest {
    1647    user_id: String,
    1648    /// Omitted so the server resolves the source's default audio stream.
    1649    /// Never send 0 here: the index is global across all streams, so 0 is
    1650    /// the video stream and the negotiated source comes back soundless.
    1651    #[serde(skip_serializing_if = "Option::is_none")]
    1652    audio_stream_index: Option<i32>,
    1653    #[serde(skip_serializing_if = "Option::is_none")]
    1654    subtitle_stream_index: Option<i32>,
    1655    start_time_ticks: i64,
    1656    is_playback: bool,
    1657    auto_open_live_stream: bool,
    1658    max_streaming_bitrate: i64,
    1659    #[serde(skip_serializing_if = "Option::is_none")]
    1660    device_profile: Option<DeviceProfile>,
    1661}
    1662
    1663#[derive(Debug, Serialize)]
    1664#[serde(rename_all = "PascalCase")]
    1665struct DeviceProfile {
    1666    name: String,
    1667    max_streaming_bitrate: i64,
    1668    max_static_bitrate: i64,
    1669    /// Channels the device's audio route can actually voice. Without it
    1670    /// the server may direct-play a 5.1 track to a two-channel sink,
    1671    /// which is silence or inaudible dialogue depending on the device.
    1672    max_audio_channels: String,
    1673    direct_play_profiles: Vec<DirectPlayProfile>,
    1674    transcoding_profiles: Vec<TranscodingProfile>,
    1675    subtitle_profiles: Vec<SubtitleProfile>,
    1676}
    1677
    1678#[derive(Debug, Serialize)]
    1679#[serde(rename_all = "PascalCase")]
    1680struct DirectPlayProfile {
    1681    #[serde(rename = "Type")]
    1682    profile_type: String,
    1683    container: String,
    1684    #[serde(skip_serializing_if = "Option::is_none")]
    1685    video_codec: Option<String>,
    1686    audio_codec: String,
    1687}
    1688
    1689#[derive(Debug, Serialize)]
    1690#[serde(rename_all = "PascalCase")]
    1691struct TranscodingProfile {
    1692    #[serde(rename = "Type")]
    1693    profile_type: String,
    1694    context: String,
    1695    protocol: String,
    1696    container: String,
    1697    #[serde(skip_serializing_if = "Option::is_none")]
    1698    video_codec: Option<String>,
    1699    audio_codec: String,
    1700    max_audio_channels: String,
    1701}
    1702
    1703#[derive(Debug, Serialize)]
    1704#[serde(rename_all = "PascalCase")]
    1705struct SubtitleProfile {
    1706    format: String,
    1707    method: String,
    1708}
    1709
    1710#[derive(Debug, Deserialize)]
    1711#[serde(rename_all = "PascalCase")]
    1712struct PlaybackInfoResponse {
    1713    media_sources: Vec<NegotiatedSource>,
    1714    play_session_id: String,
    1715}
    1716
    1717#[derive(Debug, Deserialize)]
    1718#[serde(rename_all = "PascalCase")]
    1719pub struct NegotiatedSource {
    1720    pub id: String,
    1721    pub supports_direct_play: bool,
    1722    /// The container can be repackaged without re-encoding — a remux. Distinct
    1723    /// from direct play (which copies the file untouched) and from transcoding
    1724    /// (which spends encoder time); the distinction is what
    1725    /// [`PlaybackKind`](super::stream_selection::PlaybackKind) reports, and it is
    1726    /// the difference between "costs the server nothing" and "costs it a core".
    1727    #[serde(default)]
    1728    pub supports_direct_stream: bool,
    1729    pub supports_transcoding: bool,
    1730    pub transcoding_url: Option<String>,
    1731    /// The source's own bitrate, when the server reports one.
    1732    ///
    1733    /// Fills the quality picker's "this rung is the same as Original" judgement
    1734    /// (DR-227). Absent for some containers — the sampled library has `avi`
    1735    /// files with no bitrate at all — in which case nothing is judged redundant
    1736    /// and every rung stays offered.
    1737    #[serde(default)]
    1738    pub bitrate: Option<i64>,
    1739    #[serde(default)]
    1740    pub media_streams: Vec<NegotiatedStream>,
    1741}
    1742
    1743#[derive(Debug, Deserialize)]
    1744#[serde(rename_all = "PascalCase")]
    1745pub struct NegotiatedStream {
    1746    #[serde(rename = "Type")]
    1747    stream_type: String,
    1748    #[serde(default)]
    1749    index: i32,
    1750    #[serde(default)]
    1751    codec: Option<String>,
    1752    /// The track the server serves when the client pins none.
    1753    #[serde(default)]
    1754    is_default: bool,
    1755}
    1756
    1757/// The direct-play / direct-stream / transcode decision.
    1758///
    1759/// A free function, and pure, so every branch can be tested against
    1760/// `PlaybackInfo` fixtures without a server standing behind it.
    1761///
    1762/// Order matters: the two client-side overrides come first, because both
    1763/// describe cases where the *server's* answer is right about the file and wrong
    1764/// about what this app will do with it. The server judges the file against the
    1765/// profile we sent; it cannot know that this renderer will be handed the audio
    1766/// separately, or that the viewer has pinned a track the file does not default
    1767/// to.
    1768///
    1769/// TRACES: UR-079 | DR-228 | UT-213
    1770pub fn decide_playback_kind(
    1771    source: &NegotiatedSource,
    1772    audio_forces_transcode: bool,
    1773    audio_track_pinned: bool,
    1774) -> PlaybackKind {
    1775    if audio_forces_transcode {
    1776        warn!(
    1777            "[StreamSelection] Server offered direct play for audio this renderer cannot decode — forcing a transcode"
    1778        );
    1779        return PlaybackKind::Transcode;
    1780    }
    1781    if audio_track_pinned {
    1782        // Not a defect in the server's answer — a different question. The
    1783        // file has one default track; the viewer asked for another.
    1784        return PlaybackKind::Transcode;
    1785    }
    1786    if source.supports_direct_play {
    1787        PlaybackKind::DirectPlay
    1788    } else if source.supports_direct_stream {
    1789        PlaybackKind::DirectStream
    1790    } else {
    1791        PlaybackKind::Transcode
    1792    }
    1793}
    1794
    1795#[async_trait]
    1796impl MediaRepository for OnlineRepository {
    1797    async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
    1798        #[derive(Debug, Deserialize)]
    1799        #[serde(rename_all = "PascalCase")]
    1800        struct LibrariesResponse {
    1801            items: Vec<JellyfinLibrary>,
    1802        }
    1803
    1804        #[derive(Debug, Deserialize)]
    1805        #[serde(rename_all = "PascalCase")]
    1806        struct JellyfinLibrary {
    1807            id: String,
    1808            name: String,
    1809            collection_type: Option<String>,
    1810            image_tags: Option<ImageTags>,
    1811        }
    1812
    1813        let endpoint = format!("/Users/{}/Views", self.user_id);
    1814        let response: LibrariesResponse = self.get_json(&endpoint).await?;
    1815
    1816        Ok(response
    1817            .items
    1818            .into_iter()
    1819            .map(|lib| {
    1820                Library::new(
    1821                    lib.id,
    1822                    lib.name,
    1823                    lib.collection_type.unwrap_or_else(|| "unknown".to_string()),
    1824                    lib.image_tags.and_then(|tags| tags.primary()),
    1825                )
    1826            })
    1827            .collect())
    1828    }
    1829
    1830    async fn get_items(
    1831        &self,
    1832        parent_id: &str,
    1833        options: Option<GetItemsOptions>,
    1834    ) -> Result<SearchResult, RepoError> {
    1835        let endpoint = build_get_items_endpoint(&self.user_id, parent_id, options.as_ref());
    1836
    1837        let response: ItemsResponse = self.get_json(&endpoint).await?;
    1838
    1839        Ok(SearchResult {
    1840            items: response
    1841                .items
    1842                .into_iter()
    1843                .map(|item| item.into_media_item(self.user_id.clone()))
    1844                .collect(),
    1845            total_record_count: response.total_record_count,
    1846        })
    1847    }
    1848
    1849    /// Fetch one item with every field the detail and player screens need.
    1850    ///
    1851    /// The `Fields=` list is the load-bearing part: Jellyfin omits these unless
    1852    /// they are named. `MediaStreams` is what makes the item's **audio and
    1853    /// subtitle tracks** knowable at all — there is no separate "tracks"
    1854    /// endpoint, so this single call is how the player learns which audio tracks
    1855    /// an item offers (`to_media_item` maps them, and the player's selector
    1856    /// filters them by `kind`). `People` is likewise how **cast and crew** are
    1857    /// obtained.
    1858    ///
    1859    /// TRACES: UR-021, UR-035 | IR-016, IR-022, JA-005, JA-009
    1860    async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
    1861        let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, urlencoding::encode(item_id));
    1862
    1863        let item: JellyfinItem = self.get_json(&endpoint).await?;
    1864        let media_item = item.into_media_item(self.user_id.clone());
    1865
    1866        Ok(media_item)
    1867    }
    1868
    1869    /// Recently Added, one card per thing that was added.
    1870    ///
    1871    /// The server is asked to group (`GroupItems=true`) *and* the answer is
    1872    /// grouped again here — see `collapse_tracks_into_albums` for why trusting
    1873    /// the server alone left the row full of one album's songs.
    1874    ///
    1875    /// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241, UT-244
    1876    async fn get_latest_items(
    1877        &self,
    1878        parent_id: &str,
    1879        limit: Option<usize>,
    1880    ) -> Result<Vec<MediaItem>, RepoError> {
    1881        let limit_val = limit.unwrap_or(16);
    1882        let endpoint = build_latest_items_endpoint(
    1883            &self.user_id,
    1884            parent_id,
    1885            Some(latest_items_fetch_limit(limit_val)),
    1886        );
    1887
    1888        let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
    1889        let items = items
    1890            .into_iter()
    1891            .map(|item| item.into_media_item(self.user_id.clone()))
    1892            .collect();
    1893
    1894        let mut collapsed = collapse_tracks_into_albums(items);
    1895        collapsed.truncate(limit_val);
    1896        Ok(collapsed)
    1897    }
    1898
    1899    /// Continue Watching: the items this user has started and not finished.
    1900    ///
    1901    /// `/Users/{uid}/Items/Resume` is the server-side answer to both "what goes
    1902    /// in the Continue Watching row" and "where was this left off" — each item
    1903    /// carries its own `UserData.PlaybackPositionTicks`, which is why `UserData`
    1904    /// is named in `Fields=` rather than left to the server's default field set.
    1905    ///
    1906    /// TRACES: UR-019, UR-023 | IR-024, JA-013, JA-015
    1907    async fn get_resume_items(
    1908        &self,
    1909        parent_id: Option<&str>,
    1910        limit: Option<usize>,
    1911    ) -> Result<Vec<MediaItem>, RepoError> {
    1912        let limit_str = limit.unwrap_or(16);
    1913        let mut endpoint = format!(
    1914            "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
    1915            self.user_id, limit_str
    1916        );
    1917
    1918        if let Some(pid) = parent_id {
    1919            endpoint.push_str(&format!("&ParentId={}", pid));
    1920        }
    1921
    1922        let response: ItemsResponse = self.get_json(&endpoint).await?;
    1923        Ok(response
    1924            .items
    1925            .into_iter()
    1926            .map(|item| item.into_media_item(self.user_id.clone()))
    1927            .collect())
    1928    }
    1929
    1930    /// "Next Up": the episode that follows the ones this user has finished,
    1931    /// per series — the Shows-scoped counterpart to Continue Watching.
    1932    ///
    1933    /// TRACES: UR-023, UR-059 | IR-024, JA-014
    1934    async fn get_next_up_episodes(
    1935        &self,
    1936        series_id: Option<&str>,
    1937        limit: Option<usize>,
    1938    ) -> Result<Vec<MediaItem>, RepoError> {
    1939        let endpoint = build_next_up_endpoint(&self.user_id, series_id, limit);
    1940
    1941        let response: ItemsResponse = self.get_json(&endpoint).await?;
    1942        Ok(response
    1943            .items
    1944            .into_iter()
    1945            .map(|item| item.into_media_item(self.user_id.clone()))
    1946            .collect())
    1947    }
    1948
    1949    async fn get_recently_played_audio(
    1950        &self,
    1951        limit: Option<usize>,
    1952    ) -> Result<Vec<MediaItem>, RepoError> {
    1953        let limit_val = limit.unwrap_or(12);
    1954        // Fetch more items to account for grouping reducing the count
    1955        let fetch_limit = limit_val * 3;
    1956        let endpoint = format!(
    1957            "/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
    1958            self.user_id, fetch_limit
    1959        );
    1960
    1961        let response: ItemsResponse = self.get_json(&endpoint).await?;
    1962        let items: Vec<MediaItem> = response
    1963            .items
    1964            .into_iter()
    1965            .map(|item| item.into_media_item(self.user_id.clone()))
    1966            .collect();
    1967
    1968        debug!("[get_recently_played_audio] Fetched {} items", items.len());
    1969        for item in &items {
    1970            debug!("[get_recently_played_audio] Item: name={}, type={}, album_id={:?}, album_name={:?}",
    1971                item.name, item.item_type, item.album_id, item.album_name);
    1972        }
    1973
    1974        // Group by album - create pseudo-album entries for tracks with same albumId
    1975        use std::collections::BTreeMap;
    1976        let mut album_map: BTreeMap<String, Vec<MediaItem>> = BTreeMap::new();
    1977        let mut ungrouped = Vec::new();
    1978
    1979        for item in items {
    1980            // Use album_id if available, fall back to album_name for grouping
    1981            let group_key = item.album_id.clone().or_else(|| item.album_name.clone());
    1982
    1983            if let Some(key) = group_key {
    1984                debug!(
    1985                    "[get_recently_played_audio] Grouping item '{}' into album '{}'",
    1986                    item.name, key
    1987                );
    1988                album_map.entry(key).or_default().push(item);
    1989            } else {
    1990                debug!(
    1991                    "[get_recently_played_audio] No album_id or album_name for item: '{}'",
    1992                    item.name
    1993                );
    1994                ungrouped.push(item);
    1995            }
    1996        }
    1997
    1998        // Create album entries from grouped tracks
    1999        let mut result: Vec<MediaItem> = album_map
    2000            .into_iter()
    2001            .map(|(album_id, tracks)| {
    2002                let first_track = &tracks[0];
    2003                let most_recent = tracks
    2004                    .iter()
    2005                    .max_by(|a, b| {
    2006                        let date_a = a
    2007                            .user_data
    2008                            .as_ref()
    2009                            .and_then(|ud| ud.last_played_date.as_deref())
    2010                            .unwrap_or("");
    2011                        let date_b = b
    2012                            .user_data
    2013                            .as_ref()
    2014                            .and_then(|ud| ud.last_played_date.as_deref())
    2015                            .unwrap_or("");
    2016                        date_b.cmp(date_a)
    2017                    })
    2018                    .unwrap_or(first_track);
    2019
    2020                MediaItem {
    2021                    id: album_id,
    2022                    name: first_track
    2023                        .album_name
    2024                        .clone()
    2025                        .unwrap_or_else(|| "Unknown Album".to_string()),
    2026                    item_type: "MusicAlbum".to_string(),
    2027                    kind: crate::domain::MediaKind::Album,
    2028                    is_folder: true,
    2029                    server_id: first_track.server_id.clone(),
    2030                    parent_id: None,
    2031                    library_id: None,
    2032                    overview: None,
    2033                    genres: None,
    2034                    production_year: None,
    2035                    premiere_date: None,
    2036                    community_rating: None,
    2037                    official_rating: None,
    2038                    runtime_ticks: None,
    2039                    duration_ms: None,
    2040                    primary_image_tag: first_track.primary_image_tag.clone(),
    2041                    image_id: first_track.primary_image_tag.clone(),
    2042                    backdrop_image_tags: None,
    2043                    parent_backdrop_image_tags: None,
    2044                    album_id: None,
    2045                    album_name: None,
    2046                    album_artist: None,
    2047                    artists: first_track.artists.clone(),
    2048                    artist_items: first_track.artist_items.clone(),
    2049                    index_number: None,
    2050                    parent_index_number: None,
    2051                    series_id: None,
    2052                    series_name: None,
    2053                    season_id: None,
    2054                    season_name: None,
    2055                    user_data: most_recent.user_data.clone(),
    2056                    media_streams: None,
    2057                    media_sources: None,
    2058                    people: None,
    2059                }
    2060            })
    2061            .collect();
    2062
    2063        // Append ungrouped tracks
    2064        result.extend(ungrouped);
    2065
    2066        // Return only the requested limit
    2067        let final_result: Vec<MediaItem> = result.into_iter().take(limit_val).collect();
    2068        debug!(
    2069            "[get_recently_played_audio] Returning {} items after grouping",
    2070            final_result.len()
    2071        );
    2072        for item in &final_result {
    2073            debug!(
    2074                "[get_recently_played_audio] Return: name={}, type={}",
    2075                item.name, item.item_type
    2076            );
    2077        }
    2078        Ok(final_result)
    2079    }
    2080
    2081    async fn get_rediscover_albums(
    2082        &self,
    2083        parent_id: Option<&str>,
    2084        limit: Option<usize>,
    2085    ) -> Result<Vec<MediaItem>, RepoError> {
    2086        let limit_val = limit.unwrap_or(12);
    2087        // Ask Jellyfin for played albums sorted by least-recently played first.
    2088        // Filters=IsPlayed keeps only albums the user has actually listened to,
    2089        // and SortBy=DatePlayed ascending surfaces the ones they've neglected.
    2090        let mut endpoint = format!(
    2091            "/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
    2092            self.user_id, limit_val
    2093        );
    2094
    2095        if let Some(pid) = parent_id {
    2096            endpoint.push_str(&format!("&ParentId={}", pid));
    2097        }
    2098
    2099        let response: ItemsResponse = self.get_json(&endpoint).await?;
    2100        Ok(response
    2101            .items
    2102            .into_iter()
    2103            .map(|item| item.into_media_item(self.user_id.clone()))
    2104            .collect())
    2105    }
    2106
    2107    /// Continue Watching, narrowed to movies — the home screen's movie row and
    2108    /// the movie library's own hero both want the unfinished films without the
    2109    /// episodes mixed in.
    2110    ///
    2111    /// TRACES: UR-019, UR-034 | IR-024, JA-013, JA-015
    2112    async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
    2113        let limit_str = limit.unwrap_or(16);
    2114        let endpoint = format!(
    2115            "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
    2116            self.user_id, limit_str
    2117        );
    2118
    2119        let response: ItemsResponse = self.get_json(&endpoint).await?;
    2120        Ok(response
    2121            .items
    2122            .into_iter()
    2123            .map(|item| item.into_media_item(self.user_id.clone()))
    2124            .collect())
    2125    }
    2126
    2127    async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
    2128        // Ask Jellyfin to scope counts to albums and include them, so the
    2129        // frontend can rank genres by popularity without probing each one.
    2130        let mut endpoint = format!(
    2131            "/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
    2132            self.user_id
    2133        );
    2134
    2135        if let Some(pid) = parent_id {
    2136            endpoint.push_str(&format!("&ParentId={}", pid));
    2137        }
    2138
    2139        #[derive(Debug, Deserialize)]
    2140        #[serde(rename_all = "PascalCase")]
    2141        struct GenresResponse {
    2142            items: Vec<JellyfinGenre>,
    2143        }
    2144
    2145        #[derive(Debug, Deserialize)]
    2146        #[serde(rename_all = "PascalCase")]
    2147        struct JellyfinGenre {
    2148            id: String,
    2149            name: String,
    2150            // Which count field Jellyfin populates for a genre under
    2151            // Fields=ItemCounts varies by server/version: scoped queries may
    2152            // fill AlbumCount, others only ChildCount. Read whichever is
    2153            // present so ranking still works. Absent on servers that ignore
    2154            // Fields=ItemCounts entirely, so all stay optional.
    2155            album_count: Option<u32>,
    2156            child_count: Option<u32>,
    2157        }
    2158
    2159        let response: GenresResponse = self.get_json(&endpoint).await?;
    2160        let genres: Vec<Genre> = response
    2161            .items
    2162            .into_iter()
    2163            .map(|g| Genre {
    2164                id: g.id,
    2165                name: g.name,
    2166                album_count: g.album_count.or(g.child_count),
    2167            })
    2168            .collect();
    2169
    2170        let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
    2171        // TEMP DIAGNOSTIC: dump the first few genres with their counts so we can
    2172        // see whether the server populates any count field. Remove once known.
    2173        log::warn!(
    2174            "get_genres: {} genres, {} carry counts. sample: {:?}",
    2175            genres.len(),
    2176            with_counts,
    2177            genres
    2178                .iter()
    2179                .take(8)
    2180                .map(|g| (g.name.as_str(), g.album_count))
    2181                .collect::<Vec<_>>()
    2182        );
    2183
    2184        Ok(genres)
    2185    }
    2186
    2187    /// Search every library the user can see.
    2188    ///
    2189    /// `Recursive=true` with no `ParentId` is what makes this cross-library
    2190    /// rather than folder-scoped; a caller narrowing the search passes the item
    2191    /// types through `SearchOptions` (already expanded from an opaque
    2192    /// `SearchScope` on this side of the boundary).
    2193    ///
    2194    /// TRACES: UR-008 | IR-010, JA-006
    2195    async fn search(
    2196        &self,
    2197        query: &str,
    2198        options: Option<SearchOptions>,
    2199    ) -> Result<SearchResult, RepoError> {
    2200        let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50);
    2201        // SearchTerm is arbitrary user input and must be percent-encoded so that
    2202        // spaces, ampersands, etc. don't corrupt the query string (a multi-word
    2203        // search like "Star Wars" would otherwise produce a malformed URL).
    2204        let mut endpoint = format!(
    2205            "/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
    2206            self.user_id,
    2207            urlencoding::encode(query),
    2208            limit
    2209        );
    2210
    2211        if let Some(opts) = options {
    2212            if let Some(types) = opts.include_item_types {
    2213                let encoded_types = types
    2214                    .iter()
    2215                    .map(|t| urlencoding::encode(t).into_owned())
    2216                    .collect::<Vec<_>>()
    2217                    .join(",");
    2218                endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
    2219            }
    2220        }
    2221
    2222        // Request image fields for list views (plus Genres so cached items
    2223        // carry genres for offline genre lists/counts).
    2224        endpoint.push_str(
    2225            "&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData",
    2226        );
    2227
    2228        let response: ItemsResponse = self.get_json(&endpoint).await?;
    2229        Ok(SearchResult {
    2230            items: response
    2231                .items
    2232                .into_iter()
    2233                .map(|item| item.into_media_item(self.user_id.clone()))
    2234                .collect(),
    2235            total_record_count: response.total_record_count,
    2236        })
    2237    }
    2238
    2239    async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
    2240        let (source, play_session_id) = self.negotiate_playback(item_id).await?;
    2241
    2242        // Log available media streams for debugging
    2243        info!(
    2244            "PlaybackInfo MediaSource has {} streams",
    2245            source.media_streams.len()
    2246        );
    2247        for stream in &source.media_streams {
    2248            info!(
    2249                "  Stream type={}, index={}, codec={:?}",
    2250                stream.stream_type, stream.index, stream.codec
    2251            );
    2252        }
    2253
    2254        // Name the tracks we are declining to have the server composite. Burn-in
    2255        // rules out remuxing the video, so a single image-based track can turn a
    2256        // free passthrough into a full re-encode; when that used to happen there
    2257        // was nothing in the log connecting the stall to the subtitle.
    2258        for stream in &source.media_streams {
    2259            if stream.stream_type == "Subtitle" {
    2260                if let Some(codec) = stream.codec.as_deref() {
    2261                    if super::device_profile::subtitle_forces_burn_in(codec) {
    2262                        info!(
    2263                            "  Subtitle index={} ({}) is image-based — not requested; the app renders text tracks itself rather than have the server burn it in (which would force a video re-encode)",
    2264                            stream.index, codec
    2265                        );
    2266                    }
    2267                }
    2268            }
    2269        }
    2270
    2271        // Jellyfin 10.11.5 honours a DirectPlayProfile's container and video codec
    2272        // but ignores its audio codec, so it offers an E-AC-3 track for direct
    2273        // play even though DR-148 advertises only AAC — and the webview renders
    2274        // the picture in silence. Judge the track we would actually be served
    2275        // against what the webview can decode, and override the server's answer.
    2276        let audio_streams: Vec<(Option<&str>, bool)> = source
    2277            .media_streams
    2278            .iter()
    2279            .filter(|stream| stream.stream_type == "Audio")
    2280            .map(|stream| (stream.codec.as_deref(), stream.is_default))
    2281            .collect();
    2282        let audio_forces_transcode = super::device_profile::audio_forces_transcode(&audio_streams);
    2283
    2284        // Use TranscodingUrl from response if available (Streamyfin pattern)
    2285        let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
    2286            // The server started this job and named the session — adopt it, or a
    2287            // later quality switch / seek on this stream has no previous job to
    2288            // stop and ends up contending with the one currently playing.
    2289            if let Some(previous) = adopt_video_play_session(play_session_id.clone()) {
    2290                self.stop_transcode(&previous).await;
    2291            }
    2292            // The server built this URL from its *own* subtitle verdict, so it can
    2293            // hand back the burn-in the request above just declined. Strip it: the
    2294            // negotiated answer only holds for the stream we actually open.
    2295            //
    2296            // TRACES: UR-020, UR-004 | DR-176 | UT-168
    2297            format!(
    2298                "{}{}",
    2299                self.server_url,
    2300                super::device_profile::without_server_chosen_subtitle(transcoding_url)
    2301            )
    2302        } else if audio_forces_transcode {
    2303            warn!(
    2304                "[PlaybackInfo] Server offered direct play for audio the webview cannot decode ({:?}) — forcing an HLS transcode",
    2305                audio_streams.first().and_then(|(codec, _)| *codec)
    2306            );
    2307            self.get_video_stream_url(item_id, Some(&source.id), None)
    2308                .await?
    2309        } else {
    2310            // Fall back to direct stream URL. No audioStreamIndex: static=true
    2311            // serves the original file untouched, and pinning index 0 (the video
    2312            // stream) only misleads servers that do honour it.
    2313            format!(
    2314                "{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&userId={}",
    2315                self.server_url,
    2316                item_id,
    2317                source.id,
    2318                self.access_token,
    2319                self.user_id
    2320            )
    2321        };
    2322
    2323        info!("Final stream URL: {}", stream_url);
    2324
    2325        Ok(PlaybackInfo {
    2326            media_source_id: source.id.clone(),
    2327            play_session_id,
    2328            stream_url,
    2329            direct_play: source.supports_direct_play && !audio_forces_transcode,
    2330            needs_transcoding: audio_forces_transcode
    2331                || (!source.supports_direct_play && source.supports_transcoding),
    2332        })
    2333    }
    2334
    2335    async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
    2336        // Construct direct audio stream URL
    2337        let url = format!(
    2338            "{}/Audio/{}/stream?UserId={}&api_key={}&Static=true",
    2339            self.server_url, item_id, self.user_id, self.access_token
    2340        );
    2341        Ok(url)
    2342    }
    2343
    2344    async fn get_audio_only_stream_url_for_video(
    2345        &self,
    2346        item_id: &str,
    2347        media_source_id: Option<&str>,
    2348        start_time_seconds: Option<f64>,
    2349        audio_stream_index: Option<i32>,
    2350    ) -> Result<String, RepoError> {
    2351        self.build_audio_only_stream_url_for_video(
    2352            item_id,
    2353            media_source_id,
    2354            start_time_seconds,
    2355            audio_stream_index,
    2356        )
    2357        .await
    2358    }
    2359
    2360    async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
    2361        // Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
    2362        // type "TvChannel" — playable via open_live_stream.
    2363        let endpoint = format!(
    2364            "/LiveTv/Channels?UserId={}&Fields=PrimaryImageAspectRatio,Overview&EnableImageTypes=Primary",
    2365            self.user_id
    2366        );
    2367        let response: ItemsResponse = self.get_json(&endpoint).await?;
    2368        Ok(response
    2369            .items
    2370            .into_iter()
    2371            .map(|item| item.into_media_item(self.server_url.clone()))
    2372            .collect())
    2373    }
    2374
    2375    async fn get_channels(&self) -> Result<SearchResult, RepoError> {
    2376        // Root list of plugin "Channels". Drill-down into a channel folder reuses
    2377        // get_items(channel_id, ...).
    2378        let endpoint = format!("/Channels?UserId={}", self.user_id);
    2379        let response: ItemsResponse = self.get_json(&endpoint).await?;
    2380        let total = response.total_record_count;
    2381        let items = response
    2382            .items
    2383            .into_iter()
    2384            .map(|item| item.into_media_item(self.server_url.clone()))
    2385            .collect();
    2386        Ok(SearchResult {
    2387            items,
    2388            total_record_count: total,
    2389        })
    2390    }
    2391
    2392    async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
    2393        // Live channels require a PlaybackInfo call with AutoOpenLiveStream so the
    2394        // server opens the live stream and returns a ready-to-play transcoding URL.
    2395        // We send a minimal request; the server applies its own defaults for live.
    2396        #[derive(Debug, Serialize)]
    2397        #[serde(rename_all = "PascalCase")]
    2398        struct OpenLiveStreamRequest {
    2399            user_id: String,
    2400            #[serde(rename = "AutoOpenLiveStream")]
    2401            auto_open_live_stream: bool,
    2402            is_playback: bool,
    2403            max_streaming_bitrate: u64,
    2404            /// "No subtitle", for the same reason as everywhere else: omitting it
    2405            /// lets the server apply the channel's default track, and broadcast
    2406            /// subtitles are DVB bitmaps — deliverable only by burning them in,
    2407            /// which forces a full re-encode of a stream that is already tight.
    2408            ///
    2409            /// TRACES: UR-020, UR-004 | DR-176 | UT-168
    2410            subtitle_stream_index: i32,
    2411        }
    2412
    2413        #[derive(Debug, Deserialize)]
    2414        #[serde(rename_all = "PascalCase")]
    2415        struct OpenLiveStreamResponse {
    2416            #[serde(default)]
    2417            media_sources: Vec<LiveMediaSource>,
    2418            play_session_id: Option<String>,
    2419        }
    2420
    2421        #[derive(Debug, Deserialize)]
    2422        #[serde(rename_all = "PascalCase")]
    2423        struct LiveMediaSource {
    2424            id: String,
    2425            transcoding_url: Option<String>,
    2426            live_stream_id: Option<String>,
    2427        }
    2428
    2429        let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
    2430        let request = OpenLiveStreamRequest {
    2431            user_id: self.user_id.clone(),
    2432            auto_open_live_stream: true,
    2433            is_playback: true,
    2434            // Live TV is video like any other, so the user's cap applies here
    2435            // too — a channel opened at the source bitrate would walk straight
    2436            // past a limit set for the connection. TRACES: UR-074 | DR-162
    2437            max_streaming_bitrate: effective_streaming_quality()
    2438                .max_bitrate()
    2439                .unwrap_or(20_000_000),
    2440            subtitle_stream_index: super::device_profile::playback_subtitle_stream_index(),
    2441        };
    2442
    2443        let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?;
    2444
    2445        let source = response
    2446            .media_sources
    2447            .into_iter()
    2448            .next()
    2449            .ok_or(RepoError::NotFound {
    2450                message: "No live media source returned".to_string(),
    2451            })?;
    2452
    2453        // The transcoding URL is server-relative; make it absolute. If the server
    2454        // did not provide one (rare for live), fall back to the HLS master endpoint.
    2455        let stream_url = match source.transcoding_url {
    2456            // As in `get_playback_info`: the server chose the subtitle in this
    2457            // URL, so decline it here too. TRACES: UR-020 | DR-176 | UT-168
    2458            Some(url) => format!(
    2459                "{}{}",
    2460                self.server_url,
    2461                super::device_profile::without_server_chosen_subtitle(&url)
    2462            ),
    2463            None => format!(
    2464                "{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts&SubtitleStreamIndex={}",
    2465                self.server_url,
    2466                item_id,
    2467                self.access_token,
    2468                source.id,
    2469                source.live_stream_id.clone().unwrap_or_default(),
    2470                super::device_profile::playback_subtitle_stream_index(),
    2471            ),
    2472        };
    2473
    2474        Ok(LiveStreamInfo {
    2475            stream_url,
    2476            play_session_id: response.play_session_id,
    2477            live_stream_id: source.live_stream_id,
    2478            media_source_id: Some(source.id),
    2479            // Both branches above produce a playlist: the server's own
    2480            // TranscodingUrl, or our `master.m3u8` fallback.
    2481            transport: Transport::Hls,
    2482        })
    2483    }
    2484
    2485    async fn report_playback_start(
    2486        &self,
    2487        item_id: &str,
    2488        position_ticks: i64,
    2489    ) -> Result<(), RepoError> {
    2490        #[derive(Serialize)]
    2491        #[serde(rename_all = "PascalCase")]
    2492        struct PlaybackStartRequest {
    2493            item_id: String,
    2494            position_ticks: i64,
    2495            play_command: String,
    2496            is_paused: bool,
    2497        }
    2498
    2499        let request = PlaybackStartRequest {
    2500            item_id: item_id.to_string(),
    2501            position_ticks,
    2502            play_command: "PlayNow".to_string(),
    2503            is_paused: false,
    2504        };
    2505
    2506        self.post_json("/Sessions/Playing", &request).await
    2507    }
    2508
    2509    async fn report_playback_progress(
    2510        &self,
    2511        item_id: &str,
    2512        position_ticks: i64,
    2513    ) -> Result<(), RepoError> {
    2514        #[derive(Serialize)]
    2515        #[serde(rename_all = "PascalCase")]
    2516        struct PlaybackProgressRequest {
    2517            item_id: String,
    2518            position_ticks: i64,
    2519            is_paused: bool,
    2520        }
    2521
    2522        let request = PlaybackProgressRequest {
    2523            item_id: item_id.to_string(),
    2524            position_ticks,
    2525            is_paused: false,
    2526        };
    2527
    2528        self.post_json("/Sessions/Playing/Progress", &request).await
    2529    }
    2530
    2531    async fn report_playback_stopped(
    2532        &self,
    2533        item_id: &str,
    2534        position_ticks: i64,
    2535    ) -> Result<(), RepoError> {
    2536        #[derive(Serialize)]
    2537        #[serde(rename_all = "PascalCase")]
    2538        struct PlaybackStoppedRequest {
    2539            item_id: String,
    2540            position_ticks: i64,
    2541        }
    2542
    2543        let request = PlaybackStoppedRequest {
    2544            item_id: item_id.to_string(),
    2545            position_ticks,
    2546        };
    2547
    2548        self.post_json("/Sessions/Playing/Stopped", &request).await
    2549    }
    2550
    2551    fn get_image_url(
    2552        &self,
    2553        item_id: &str,
    2554        image_type: ImageType,
    2555        options: Option<ImageOptions>,
    2556    ) -> String {
    2557        let mut url = format!(
    2558            "{}/Items/{}/Images/{}",
    2559            self.server_url,
    2560            item_id,
    2561            image_type.as_str()
    2562        );
    2563
    2564        // Authentication is handled by X-Emby-Authorization header in download_bytes()
    2565        // Do NOT include api_key here — some Jellyfin servers reject requests when
    2566        // api_key is present but the token doesn't match the expected format.
    2567        let mut params: Vec<String> = Vec::new();
    2568
    2569        if let Some(opts) = options {
    2570            if let Some(width) = opts.max_width {
    2571                params.push(format!("maxWidth={}", width));
    2572            }
    2573            if let Some(height) = opts.max_height {
    2574                params.push(format!("maxHeight={}", height));
    2575            }
    2576            if let Some(quality) = opts.quality {
    2577                params.push(format!("quality={}", quality));
    2578            }
    2579            if let Some(tag) = opts.tag {
    2580                params.push(format!("tag={}", tag));
    2581            }
    2582        }
    2583
    2584        if !params.is_empty() {
    2585            url.push('?');
    2586            url.push_str(&params.join("&"));
    2587        }
    2588
    2589        url
    2590    }
    2591
    2592    fn get_subtitle_url(
    2593        &self,
    2594        item_id: &str,
    2595        media_source_id: &str,
    2596        stream_index: i32,
    2597        format: &str,
    2598    ) -> String {
    2599        // `Stream.{format}` is the route, not a filename we get to choose:
    2600        // Jellyfin exposes the subtitle as
    2601        // `/Videos/{item}/{source}/Subtitles/{index}/Stream.{format}`, and
    2602        // stopping at the format alone matches no route and 404s. Every
    2603        // sideloaded subtitle failed to load on Android because of it, leaving
    2604        // ExoPlayer with no text tracks to select.
    2605        // TRACES: UR-020 | JA-008, DR-259 | UT-234
    2606        format!(
    2607            "{}/Videos/{}/{}/Subtitles/{}/Stream.{}",
    2608            self.server_url, item_id, media_source_id, stream_index, format
    2609        )
    2610    }
    2611
    2612    /// TRACES: UR-071 | DR-123
    2613    fn get_video_download_url(
    2614        &self,
    2615        item_id: &str,
    2616        quality: &str,
    2617        media_source_id: Option<&str>,
    2618        source_audio_codec: Option<&str>,
    2619    ) -> String {
    2620        // NOTE: Jellyfin's `/Videos/{id}/download` endpoint is not universally
    2621        // available (returns 404 on many server configs), which silently broke
    2622        // every movie/TV download. Use the progressive `stream.mp4` endpoint
    2623        // instead — it is always present and supports HTTP Range, which the
    2624        // download worker relies on for resume.
    2625        let mut url = format!("{}/Videos/{}/stream.mp4", self.server_url, item_id);
    2626        let mut params = vec![format!("api_key={}", self.access_token)];
    2627
    2628        // Map the frontend quality preset to concrete transcode params. For
    2629        // "original" we request a direct static copy (no transcode) which is
    2630        // byte-range resumable; other presets ask the server to transcode.
    2631        //
    2632        // 🔴 It is `videoBitRate`/`audioBitRate` — **capital R**. Jellyfin binds
    2633        // query keys case-insensitively, so `maxHeight`/`videoCodec` casing is
    2634        // free, but `videoBitrate` (lowercase r) is a *different token*: it
    2635        // fails to bind, is silently dropped, and the requested cap vanishes
    2636        // with no error. That is why every "480p"/"720p" download came back at
    2637        // full original quality. See `Jellyfin.Api` BaseEncodingJobOptions.
    2638        //
    2639        // `allowVideoStreamCopy=false` forces a real re-encode. Without it the
    2640        // server may stream-copy the source when it already satisfies the cap —
    2641        // fine in itself, but it also means a mis-typed cap degrades silently.
    2642        // Note `enableAutoStreamCopy=false` alone does NOT stop a *video* copy;
    2643        // video copy is gated by `allowVideoStreamCopy`.
    2644        match quality {
    2645            "high" => {
    2646                params.push("videoBitRate=8000000".to_string());
    2647                params.push("maxHeight=1080".to_string());
    2648                params.push("audioBitRate=384000".to_string());
    2649                params.push("videoCodec=h264".to_string());
    2650                params.push("audioCodec=aac".to_string());
    2651                params.push("allowVideoStreamCopy=false".to_string());
    2652            }
    2653            "medium" => {
    2654                params.push("videoBitRate=4000000".to_string());
    2655                params.push("maxHeight=720".to_string());
    2656                params.push("audioBitRate=256000".to_string());
    2657                params.push("videoCodec=h264".to_string());
    2658                params.push("audioCodec=aac".to_string());
    2659                params.push("allowVideoStreamCopy=false".to_string());
    2660            }
    2661            "low" => {
    2662                params.push("videoBitRate=1500000".to_string());
    2663                params.push("maxHeight=480".to_string());
    2664                params.push("audioBitRate=128000".to_string());
    2665                params.push("videoCodec=h264".to_string());
    2666                params.push("audioCodec=aac".to_string());
    2667                params.push("allowVideoStreamCopy=false".to_string());
    2668            }
    2669            // "original" (and any unknown value) → direct, resumable copy —
    2670            // unless the audio in that copy is undecodable where the file will
    2671            // be played back. A download is watched with no server in reach, so
    2672            // it has to satisfy the same constraint DR-149 applies to streams:
    2673            // the webview `<video>` element renders video on both platforms and
    2674            // decodes none of AC-3/E-AC-3/DTS/TrueHD. Copying those bytes to
    2675            // disk is what made a downloaded film play offline as picture with
    2676            // no sound while the same film had sound when streamed.
    2677            //
    2678            // Only the *audio* is re-encoded. `allowVideoStreamCopy` keeps an
    2679            // h264 source's picture byte-for-byte, so "original" still means
    2680            // original quality, and no bitrate or resolution cap is added. A
    2681            // source the webview could not have rendered anyway (HEVC) is
    2682            // re-encoded to h264 as a side effect, which is the only form of it
    2683            // that would have played.
    2684            //
    2685            // The cost of the transcode is that the response is no longer
    2686            // range-resumable, which is exactly why this is decided per item
    2687            // rather than applied to every `original` download.
    2688            //
    2689            // TRACES: UR-071, UR-004 | DR-171 | UT-166
    2690            _ => match source_audio_codec {
    2691                Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
    2692                    params.push("videoCodec=h264".to_string());
    2693                    params.push("allowVideoStreamCopy=true".to_string());
    2694                    params.push("audioCodec=aac".to_string());
    2695                    params.push("audioBitRate=384000".to_string());
    2696                }
    2697                // Decodable, or unknown: an unknown codec must not provoke a
    2698                // transcode — that would burn server CPU on a guess for files
    2699                // that play perfectly well.
    2700                _ => params.push("Static=true".to_string()),
    2701            },
    2702        }
    2703
    2704        // Add media source ID if provided
    2705        if let Some(source_id) = media_source_id {
    2706            params.push(format!("mediaSourceId={}", source_id));
    2707        }
    2708
    2709        url.push('?');
    2710        url.push_str(&params.join("&"));
    2711
    2712        url
    2713    }
    2714
    2715    async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
    2716        let endpoint = format!(
    2717            "/Users/{}/FavoriteItems/{}",
    2718            self.user_id,
    2719            urlencoding::encode(item_id)
    2720        );
    2721        self.post_json(&endpoint, &serde_json::json!({})).await
    2722    }
    2723
    2724    /// TRACES: UR-067 | DR-115, JA-033 | UT-100
    2725    async fn get_favorites(
    2726        &self,
    2727        scope: SearchScope,
    2728        options: Option<GetItemsOptions>,
    2729    ) -> Result<SearchResult, RepoError> {
    2730        let endpoint = build_favorites_endpoint(&self.user_id, scope, options.as_ref());
    2731        let response: ItemsResponse = self.get_json(&endpoint).await?;
    2732
    2733        Ok(SearchResult {
    2734            items: response
    2735                .items
    2736                .into_iter()
    2737                .map(|item| item.into_media_item(self.user_id.clone()))
    2738                .collect(),
    2739            total_record_count: response.total_record_count,
    2740        })
    2741    }
    2742
    2743    /// Un-favourite an item: the same `/Users/{uid}/FavoriteItems/{id}` resource
    2744    /// as [`Self::mark_favorite`], removed rather than posted. Written out by
    2745    /// hand rather than through `post_json` because it is the one favourite call
    2746    /// that needs `DELETE`.
    2747    ///
    2748    /// TRACES: UR-017 | JA-018, DR-021
    2749    async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
    2750        let endpoint = format!(
    2751            "/Users/{}/FavoriteItems/{}",
    2752            self.user_id,
    2753            urlencoding::encode(item_id)
    2754        );
    2755        let url = format!("{}{}", self.server_url, endpoint);
    2756
    2757        let result = async {
    2758            let request = self
    2759                .http_client
    2760                .client
    2761                .delete(&url)
    2762                .header("X-Emby-Authorization", self.auth_header())
    2763                .build()
    2764                .map_err(|e| RepoError::Network {
    2765                    message: format!("Failed to build request: {}", e),
    2766                })?;
    2767
    2768            let response = self
    2769                .http_client
    2770                .request_with_retry(request)
    2771                .await
    2772                .map_err(|e| RepoError::Network {
    2773                    message: e.to_string(),
    2774                })?;
    2775
    2776            if !response.status().is_success() {
    2777                return Err(RepoError::Server {
    2778                    message: format!("HTTP {}", response.status()),
    2779                });
    2780            }
    2781
    2782            Ok(())
    2783        }
    2784        .await;
    2785
    2786        self.report_outcome(&result).await;
    2787        result
    2788    }
    2789
    2790    /// `DELETE /Users/{userId}/PlayedItems/{itemId}` — Jellyfin's "mark
    2791    /// unplayed", which also zeroes the resume position. On a folder (series,
    2792    /// season) the server applies it recursively to the children.
    2793    ///
    2794    /// TRACES: UR-064 | DR-106, JA-033
    2795    async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
    2796        let endpoint = format!(
    2797            "/Users/{}/PlayedItems/{}",
    2798            self.user_id,
    2799            urlencoding::encode(item_id)
    2800        );
    2801        let url = format!("{}{}", self.server_url, endpoint);
    2802
    2803        let result = async {
    2804            let request = self
    2805                .http_client
    2806                .client
    2807                .delete(&url)
    2808                .header("X-Emby-Authorization", self.auth_header())
    2809                .build()
    2810                .map_err(|e| RepoError::Network {
    2811                    message: format!("Failed to build request: {}", e),
    2812                })?;
    2813
    2814            let response = self
    2815                .http_client
    2816                .request_with_retry(request)
    2817                .await
    2818                .map_err(|e| RepoError::Network {
    2819                    message: e.to_string(),
    2820                })?;
    2821
    2822            if !response.status().is_success() {
    2823                return Err(RepoError::Server {
    2824                    message: format!("HTTP {}", response.status()),
    2825                });
    2826            }
    2827
    2828            Ok(())
    2829        }
    2830        .await;
    2831
    2832        self.report_outcome(&result).await;
    2833        result
    2834    }
    2835
    2836    /// `POST /Users/{userId}/PlayedItems/{itemId}` — the mirror image of
    2837    /// `clear_watch_history`.
    2838    ///
    2839    /// TRACES: UR-025 | DR-131 | JA-035
    2840    async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
    2841        let endpoint = format!(
    2842            "/Users/{}/PlayedItems/{}",
    2843            self.user_id,
    2844            urlencoding::encode(item_id)
    2845        );
    2846        let url = format!("{}{}", self.server_url, endpoint);
    2847
    2848        let result = async {
    2849            let request = self
    2850                .http_client
    2851                .client
    2852                .post(&url)
    2853                .header("X-Emby-Authorization", self.auth_header())
    2854                .header("Content-Length", "0")
    2855                .build()
    2856                .map_err(|e| RepoError::Network {
    2857                    message: format!("Failed to build request: {}", e),
    2858                })?;
    2859
    2860            let response = self
    2861                .http_client
    2862                .request_with_retry(request)
    2863                .await
    2864                .map_err(|e| RepoError::Network {
    2865                    message: e.to_string(),
    2866                })?;
    2867
    2868            if !response.status().is_success() {
    2869                return Err(RepoError::Server {
    2870                    message: format!("HTTP {}", response.status()),
    2871                });
    2872            }
    2873
    2874            Ok(())
    2875        }
    2876        .await;
    2877
    2878        self.report_outcome(&result).await;
    2879        result
    2880    }
    2881
    2882    /// A single Person item (actor, director, …) by id.
    2883    ///
    2884    /// Jellyfin models people as ordinary items, so this is the plain item
    2885    /// endpoint rather than anything under `/Persons`; the cast entries returned
    2886    /// on an item's `People` field carry the ids this is called with.
    2887    ///
    2888    /// TRACES: UR-035, UR-036 | IR-022, JA-030
    2889    async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
    2890        let endpoint = format!(
    2891            "/Users/{}/Items/{}",
    2892            self.user_id,
    2893            urlencoding::encode(person_id)
    2894        );
    2895        let item: JellyfinItem = self.get_json(&endpoint).await?;
    2896        Ok(item.into_media_item(self.user_id.clone()))
    2897    }
    2898
    2899    /// A person's filmography — every item they are credited on.
    2900    ///
    2901    /// TRACES: UR-036 | IR-022, JA-031
    2902    async fn get_items_by_person(
    2903        &self,
    2904        person_id: &str,
    2905        options: Option<GetItemsOptions>,
    2906    ) -> Result<SearchResult, RepoError> {
    2907        let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
    2908
    2909        let mut endpoint = format!(
    2910            "/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
    2911            self.user_id, person_id, limit
    2912        );
    2913
    2914        // Add item type filtering if specified in options
    2915        if let Some(ref opts) = options {
    2916            if let Some(ref include_types) = opts.include_item_types {
    2917                if !include_types.is_empty() {
    2918                    let types_param = include_types.join(",");
    2919                    endpoint.push_str(&format!("&IncludeItemTypes={}", types_param));
    2920                }
    2921            }
    2922        }
    2923
    2924        let response: ItemsResponse = self.get_json(&endpoint).await?;
    2925        Ok(SearchResult {
    2926            items: response
    2927                .items
    2928                .into_iter()
    2929                .map(|item| item.into_media_item(self.user_id.clone()))
    2930                .collect(),
    2931            total_record_count: response.total_record_count,
    2932        })
    2933    }
    2934
    2935    async fn get_similar_items(
    2936        &self,
    2937        item_id: &str,
    2938        limit: Option<usize>,
    2939    ) -> Result<SearchResult, RepoError> {
    2940        let limit_str = limit.unwrap_or(20);
    2941
    2942        // Try the /Similar endpoint which works for most items
    2943        let endpoint = format!(
    2944            "/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
    2945            item_id, self.user_id, limit_str
    2946        );
    2947
    2948        let response: ItemsResponse = self.get_json(&endpoint).await?;
    2949        Ok(SearchResult {
    2950            items: response
    2951                .items
    2952                .into_iter()
    2953                .map(|item| item.into_media_item(self.user_id.clone()))
    2954                .collect(),
    2955            total_record_count: response.total_record_count,
    2956        })
    2957    }
    2958
    2959    // ===== Playlist Methods =====
    2960
    2961    async fn create_playlist(
    2962        &self,
    2963        name: &str,
    2964        item_ids: &[String],
    2965    ) -> Result<PlaylistCreatedResult, RepoError> {
    2966        info!(
    2967            "[OnlineRepo] Creating playlist '{}' with {} items",
    2968            name,
    2969            item_ids.len()
    2970        );
    2971        let body = serde_json::json!({
    2972            "Name": name,
    2973            "Ids": item_ids,
    2974            "MediaType": "Audio",
    2975            "UserId": self.user_id,
    2976        });
    2977        let response: CreatePlaylistResponse = self.post_json_response("/Playlists", &body).await?;
    2978        Ok(PlaylistCreatedResult { id: response.id })
    2979    }
    2980
    2981    async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
    2982        info!("[OnlineRepo] Deleting playlist {}", playlist_id);
    2983        let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
    2984        let url = format!("{}{}", self.server_url, endpoint);
    2985
    2986        let request = self
    2987            .http_client
    2988            .client
    2989            .delete(&url)
    2990            .header("X-Emby-Authorization", self.auth_header())
    2991            .build()
    2992            .map_err(|e| RepoError::Network {
    2993                message: format!("Failed to build request: {}", e),
    2994            })?;
    2995
    2996        let response = self
    2997            .http_client
    2998            .request_with_retry(request)
    2999            .await
    3000            .map_err(|e| RepoError::Network {
    3001                message: e.to_string(),
    3002            })?;
    3003
    3004        if !response.status().is_success() {
    3005            return Err(RepoError::Server {
    3006                message: format!("HTTP {}", response.status()),
    3007            });
    3008        }
    3009
    3010        Ok(())
    3011    }
    3012
    3013    async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
    3014        info!(
    3015            "[OnlineRepo] Renaming playlist {} to '{}'",
    3016            playlist_id, name
    3017        );
    3018        let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
    3019        self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
    3020            .await
    3021    }
    3022
    3023    async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
    3024        let endpoint = format!(
    3025            "/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000",
    3026            playlist_id, self.user_id
    3027        );
    3028
    3029        let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
    3030        debug!(
    3031            "[OnlineRepo] Got {} playlist items for {}",
    3032            response.items.len(),
    3033            playlist_id
    3034        );
    3035
    3036        Ok(response
    3037            .items
    3038            .into_iter()
    3039            .map(|pi| PlaylistEntry {
    3040                playlist_item_id: pi.playlist_item_id,
    3041                item: pi.item.into_media_item(self.user_id.clone()),
    3042            })
    3043            .collect())
    3044    }
    3045
    3046    async fn add_to_playlist(
    3047        &self,
    3048        playlist_id: &str,
    3049        item_ids: &[String],
    3050    ) -> Result<(), RepoError> {
    3051        info!(
    3052            "[OnlineRepo] Adding {} items to playlist {}",
    3053            item_ids.len(),
    3054            playlist_id
    3055        );
    3056        // Encode each id, not the joined string: the comma separates the list.
    3057        let ids_param = item_ids
    3058            .iter()
    3059            .map(|id| urlencoding::encode(id).into_owned())
    3060            .collect::<Vec<_>>()
    3061            .join(",");
    3062        let endpoint = format!(
    3063            "/Playlists/{}/Items?Ids={}",
    3064            urlencoding::encode(playlist_id),
    3065            ids_param
    3066        );
    3067        self.post_json(&endpoint, &serde_json::json!({})).await
    3068    }
    3069
    3070    async fn remove_from_playlist(
    3071        &self,
    3072        playlist_id: &str,
    3073        entry_ids: &[String],
    3074    ) -> Result<(), RepoError> {
    3075        info!(
    3076            "[OnlineRepo] Removing {} entries from playlist {}",
    3077            entry_ids.len(),
    3078            playlist_id
    3079        );
    3080        let ids_param = entry_ids
    3081            .iter()
    3082            .map(|id| urlencoding::encode(id).into_owned())
    3083            .collect::<Vec<_>>()
    3084            .join(",");
    3085        let endpoint = format!(
    3086            "/Playlists/{}/Items?EntryIds={}",
    3087            urlencoding::encode(playlist_id),
    3088            ids_param
    3089        );
    3090        let url = format!("{}{}", self.server_url, endpoint);
    3091
    3092        let request = self
    3093            .http_client
    3094            .client
    3095            .delete(&url)
    3096            .header("X-Emby-Authorization", self.auth_header())
    3097            .build()
    3098            .map_err(|e| RepoError::Network {
    3099                message: format!("Failed to build request: {}", e),
    3100            })?;
    3101
    3102        let response = self
    3103            .http_client
    3104            .request_with_retry(request)
    3105            .await
    3106            .map_err(|e| RepoError::Network {
    3107                message: e.to_string(),
    3108            })?;
    3109
    3110        if !response.status().is_success() {
    3111            return Err(RepoError::Server {
    3112                message: format!("HTTP {}", response.status()),
    3113            });
    3114        }
    3115
    3116        Ok(())
    3117    }
    3118
    3119    async fn move_playlist_item(
    3120        &self,
    3121        playlist_id: &str,
    3122        item_id: &str,
    3123        new_index: u32,
    3124    ) -> Result<(), RepoError> {
    3125        info!(
    3126            "[OnlineRepo] Moving item {} in playlist {} to index {}",
    3127            item_id, playlist_id, new_index
    3128        );
    3129        let endpoint = format!(
    3130            "/Playlists/{}/Items/{}/Move/{}",
    3131            playlist_id, item_id, new_index
    3132        );
    3133        self.post_json(&endpoint, &serde_json::json!({})).await
    3134    }
    3135}
    3136
    3137#[cfg(test)]
    3138mod tests {
    3139    use super::*;
    3140    use crate::domain::MediaKind;
    3141    use crate::utils::lock::MutexSafe;
    3142    use std::sync::Arc;
    3143
    3144    fn create_test_repository() -> OnlineRepository {
    3145        let http_config = crate::jellyfin::HttpConfig::default();
    3146        let http_client =
    3147            Arc::new(HttpClient::new(http_config).expect("Failed to create HTTP client for test"));
    3148        OnlineRepository::new(
    3149            http_client,
    3150            "https://test.server.com".to_string(),
    3151            "test-user-id".to_string(),
    3152            "test-access-token".to_string(),
    3153        )
    3154    }
    3155
    3156    /// The reported bug: on Android every subtitle track was inert — the menu
    3157    /// listed 42 languages and picking one changed nothing.
    3158    ///
    3159    /// The cause is here rather than in the player. ExoPlayer sideloads each
    3160    /// subtitle as its own media source, and since media3 1.5 a sideloaded text
    3161    /// track only becomes a *track group* once its file has been fetched and
    3162    /// parsed. Every fetch 404ed, so `Tracks` carried no text group at all and
    3163    /// `setSubtitleTrack(1)` warned `available: 0` and dropped the request.
    3164    ///
    3165    /// Jellyfin's route is `/Videos/{item}/{source}/Subtitles/{index}/Stream.{fmt}`
    3166    /// (verified against a live server: this shape answers 200, the one built
    3167    /// here answered 404). The `Stream.` segment is not decoration — without it
    3168    /// the path matches no route.
    3169    ///
    3170    /// The old mock-based URL tests could not catch this: they asserted the
    3171    /// shape of a *test helper* that duplicated the format string, not of the
    3172    /// URL the app actually requests.
    3173    ///
    3174    /// TRACES: UR-020 | JA-008, DR-259 | UT-234
    3175    #[test]
    3176    fn subtitle_url_uses_jellyfins_stream_route() {
    3177        let repo = create_test_repository();
    3178
    3179        assert_eq!(
    3180            repo.get_subtitle_url("item123", "source456", 2, "vtt"),
    3181            "https://test.server.com/Videos/item123/source456/Subtitles/2/Stream.vtt"
    3182        );
    3183    }
    3184
    3185    /// Build a repository wired to a real ConnectivityReporter so we can assert
    3186    /// how `report_outcome` classifies each `RepoError` into reachability.
    3187    /// (No app handle → event emission is a harmless no-op.)
    3188    fn create_test_repository_with_connectivity(
    3189    ) -> (OnlineRepository, crate::connectivity::ConnectivityReporter) {
    3190        let monitor_http = HttpClient::new(crate::jellyfin::HttpConfig::default())
    3191            .expect("Failed to create HTTP client for monitor");
    3192        let monitor = crate::connectivity::ConnectivityMonitor::new(monitor_http);
    3193        let reporter = monitor.reporter();
    3194        let repo = create_test_repository().with_connectivity(reporter.clone());
    3195        (repo, reporter)
    3196    }
    3197
    3198    /// `report_outcome` is the seam between repository traffic and the
    3199    /// connectivity monitor. Verify each `RepoError` variant routes correctly:
    3200    /// - the server answering at all (Ok / 401 / 404 / 5xx) ⇒ reachable
    3201    /// - a network-level failure ⇒ marked unreachable (debounce reduced for test)
    3202    /// - local-side errors (Database / Offline) ⇒ no effect on reachability
    3203    ///
    3204    /// @req-test: UR-002 - Access media when online or offline
    3205    /// @req-test: DR-013 - Repository pattern for online/offline data access
    3206    #[tokio::test]
    3207    async fn test_report_outcome_classifies_server_answered_as_reachable() {
    3208        let (repo, reporter) = create_test_repository_with_connectivity();
    3209
    3210        // Drive offline first so we can observe "recover to reachable".
    3211        for err in [
    3212            RepoError::Authentication {
    3213                message: "401".into(),
    3214            },
    3215            RepoError::NotFound {
    3216                message: "404".into(),
    3217            },
    3218            RepoError::Server {
    3219                message: "500".into(),
    3220            },
    3221        ] {
    3222            reporter.mark_unreachable_for_test().await;
    3223            assert!(!reporter.is_reachable().await, "precondition: offline");
    3224
    3225            let result: Result<(), RepoError> = Err(err);
    3226            repo.report_outcome(&result).await;
    3227
    3228            assert!(
    3229                reporter.is_reachable().await,
    3230                "a server that answers should be reported reachable"
    3231            );
    3232        }
    3233
    3234        // Ok should also report reachable.
    3235        reporter.mark_unreachable_for_test().await;
    3236        let ok: Result<(), RepoError> = Ok(());
    3237        repo.report_outcome(&ok).await;
    3238        assert!(reporter.is_reachable().await, "Ok ⇒ reachable");
    3239    }
    3240
    3241    /// Local-side errors must NOT flip reachability — they say nothing about the
    3242    /// server.
    3243    #[tokio::test]
    3244    async fn test_report_outcome_ignores_local_errors() {
    3245        let (repo, reporter) = create_test_repository_with_connectivity();
    3246
    3247        // Force offline, then a Database/Offline error must leave it offline
    3248        // (not falsely report reachable).
    3249        reporter.mark_unreachable_for_test().await;
    3250        for err in [
    3251            RepoError::Database {
    3252                message: "cache".into(),
    3253            },
    3254            RepoError::Offline,
    3255        ] {
    3256            let result: Result<(), RepoError> = Err(err);
    3257            repo.report_outcome(&result).await;
    3258            assert!(
    3259                !reporter.is_reachable().await,
    3260                "local-side error must not change reachability"
    3261            );
    3262        }
    3263    }
    3264
    3265    /// When connectivity is known-offline, `get_json` must fast-fail with
    3266    /// `RepoError::Offline` instead of running the full HTTP retry cycle (~7s).
    3267    /// This is what keeps offline browsing snappy. `test.server.com` is
    3268    /// unroutable, so if the guard were absent this would hang on retries; the
    3269    /// assertion returning promptly with `Offline` proves the short-circuit.
    3270    #[tokio::test]
    3271    async fn test_get_json_fast_fails_when_offline() {
    3272        let (repo, reporter) = create_test_repository_with_connectivity();
    3273        reporter.mark_unreachable_for_test().await;
    3274        assert!(!reporter.is_reachable().await, "precondition: offline");
    3275
    3276        let result: Result<serde_json::Value, RepoError> = repo.get_json("/System/Info").await;
    3277        assert!(
    3278            matches!(result, Err(RepoError::Offline)),
    3279            "known-offline get_json should return Offline immediately, got {:?}",
    3280            result
    3281        );
    3282    }
    3283
    3284    /// A network error routes through the debounced path. A single failure stays
    3285    /// online (debounce window not yet elapsed).
    3286    #[tokio::test]
    3287    async fn test_report_outcome_network_error_is_debounced() {
    3288        let (repo, reporter) = create_test_repository_with_connectivity();
    3289        assert!(reporter.is_reachable().await, "starts online");
    3290
    3291        let result: Result<(), RepoError> = Err(RepoError::Network {
    3292            message: "timeout".into(),
    3293        });
    3294        repo.report_outcome(&result).await;
    3295
    3296        assert!(
    3297            reporter.is_reachable().await,
    3298            "a single network failure stays online (debounced)"
    3299        );
    3300    }
    3301
    3302    #[tokio::test]
    3303    async fn test_get_audio_stream_url_formats_correctly() {
    3304        let repo = create_test_repository();
    3305        let item_id = "test-track-123";
    3306
    3307        let result = repo.get_audio_stream_url(item_id).await;
    3308
    3309        assert!(result.is_ok());
    3310        let url = result.unwrap();
    3311        assert_eq!(
    3312            url,
    3313            "https://test.server.com/Audio/test-track-123/stream?UserId=test-user-id&api_key=test-access-token&Static=true"
    3314        );
    3315    }
    3316
    3317    /// Serialises every test whose expectations depend on the process-wide
    3318    /// streaming ceiling, and restores the uncapped default afterwards — without
    3319    /// it, a capped test running concurrently changes what an uncapped one sees.
    3320    ///
    3321    /// TRACES: UR-074 | DR-162
    3322    static QUALITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
    3323
    3324    struct QualityFixture(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
    3325
    3326    impl QualityFixture {
    3327        fn set(quality: StreamingQuality) -> Self {
    3328            let guard = QUALITY_LOCK.lock_safe();
    3329            set_streaming_quality(quality);
    3330            Self(guard)
    3331        }
    3332    }
    3333
    3334    impl Drop for QualityFixture {
    3335        fn drop(&mut self) {
    3336            set_streaming_quality(StreamingQuality::Original);
    3337            // A leaked per-playback override would cap every later test's
    3338            // expectations without appearing anywhere in its setup.
    3339            // TRACES: UR-074, UR-079 | DR-226
    3340            clear_playback_quality_override();
    3341        }
    3342    }
    3343
    3344    /// A cap has to reach the transcode URL as all four of its parts: the total
    3345    /// ceiling, the split between video and audio, and the resolution the budget
    3346    /// can carry. Capping only `MaxStreamingBitrate` would leave the server
    3347    /// encoding 1080p into 2 Mbps.
    3348    ///
    3349    /// TRACES: UR-074 | DR-162 | UT-156
    3350    #[tokio::test]
    3351    async fn test_video_stream_url_applies_bitrate_cap() {
    3352        let _fixture = QualityFixture::set(StreamingQuality::Mbps2);
    3353        let repo = create_test_repository();
    3354
    3355        let url = repo
    3356            .get_video_stream_url("vid-1", None, None)
    3357            .await
    3358            .unwrap();
    3359
    3360        assert!(url.contains("MaxStreamingBitrate=2000000"), "url: {url}");
    3361        // 2 Mbps total less the 192 kbps audio share — the two must not sum to
    3362        // more than the cap the user asked for.
    3363        assert!(url.contains("VideoBitrate=1808000"), "url: {url}");
    3364        assert!(url.contains("AudioBitrate=192000"), "url: {url}");
    3365        assert!(url.contains("MaxHeight=720"), "url: {url}");
    3366    }
    3367
    3368    /// The uncapped default must keep the exact transcode allowance this
    3369    /// endpoint has always used, and must not start constraining resolution.
    3370    ///
    3371    /// TRACES: UR-074 | DR-162 | UT-156
    3372    #[tokio::test]
    3373    async fn test_video_stream_url_uncapped_keeps_legacy_allowance() {
    3374        let _fixture = QualityFixture::set(StreamingQuality::Original);
    3375        let repo = create_test_repository();
    3376
    3377        let url = repo
    3378            .get_video_stream_url("vid-1", None, None)
    3379            .await
    3380            .unwrap();
    3381
    3382        assert!(url.contains("MaxStreamingBitrate=20000000"), "url: {url}");
    3383        assert!(url.contains("VideoBitrate=18000000"), "url: {url}");
    3384        assert!(url.contains("AudioBitrate=384000"), "url: {url}");
    3385        assert!(
    3386            !url.contains("MaxHeight"),
    3387            "uncapped must not scale the picture down: {url}"
    3388        );
    3389    }
    3390
    3391    /// The background-audio handoff is already cheap, but someone who capped the
    3392    /// connection at 720 kbps asked for less traffic than its fixed 384 kbps.
    3393    ///
    3394    /// TRACES: UR-040, UR-074 | DR-162 | UT-156
    3395    #[tokio::test]
    3396    async fn test_audio_only_stream_url_takes_the_lower_of_cap_and_default() {
    3397        {
    3398            let _fixture = QualityFixture::set(StreamingQuality::Kbps720);
    3399            let repo = create_test_repository();
    3400            let url = repo
    3401                .get_audio_only_stream_url_for_video("vid-1", None, None, None)
    3402                .await
    3403                .unwrap();
    3404            assert!(url.contains("MaxStreamingBitrate=96000"), "url: {url}");
    3405        }
    3406
    3407        let _fixture = QualityFixture::set(StreamingQuality::Original);
    3408        let repo = create_test_repository();
    3409        let url = repo
    3410            .get_audio_only_stream_url_for_video("vid-1", None, None, None)
    3411            .await
    3412            .unwrap();
    3413        assert!(url.contains("MaxStreamingBitrate=384000"), "url: {url}");
    3414    }
    3415
    3416    /// Transcoded video must be an HLS master playlist, not a progressive
    3417    /// `stream.mp4`: a progressive transcode of an HEVC source makes the server
    3418    /// convert the whole file before serving a byte, which presents as playback
    3419    /// that never starts. The chosen source and audio track ride along with it.
    3420    ///
    3421    /// This is the surviving half of the old
    3422    /// `test_get_video_stream_url_returns_hls_with_position`, whose other half
    3423    /// asserted the `StartTimeTicks` that DR-181 removed — the position now
    3424    /// belongs to a seek after load, never to this URL, so the assertion for it
    3425    /// is gone rather than inverted (its inverse is UT-182's own test).
    3426    ///
    3427    /// TRACES: UR-004 | DR-140, DR-181 | UT-130
    3428    #[tokio::test]
    3429    async fn test_get_video_stream_url_returns_an_hls_master_playlist() {
    3430        let _fixture = QualityFixture::set(StreamingQuality::Original);
    3431        let repo = create_test_repository();
    3432
    3433        let url = repo
    3434            .get_video_stream_url("vid-1", Some("source-1"), Some(1))
    3435            .await
    3436            .unwrap();
    3437
    3438        assert!(
    3439            url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"),
    3440            "expected HLS master playlist, got: {url}"
    3441        );
    3442        assert!(url.contains("VideoCodec=h264"));
    3443        assert!(url.contains("MediaSourceId=source-1"));
    3444        assert!(url.contains("AudioStreamIndex=1"));
    3445        assert!(!url.contains("stream.mp4"));
    3446    }
    3447
    3448    /// Resuming a transcoded video played nothing at all: every segment came back
    3449    /// `400`, hls.js exhausted its retries and gave up. Starting the same episode
    3450    /// from the beginning was fine.
    3451    ///
    3452    /// Jellyfin builds each segment URI by echoing the *master playlist's* query
    3453    /// string into it (`CreateMainPlaylistRequest(… Request.QueryString …)`), and
    3454    /// its segment handler opens with
    3455    ///
    3456    /// ```csharp
    3457    /// if ((streamingRequest.StartTimeTicks ?? 0) > 0)
    3458    ///     throw new ArgumentException("StartTimeTicks is not allowed.");
    3459    /// ```
    3460    ///
    3461    /// so a resume position put on the playlist is copied onto every
    3462    /// `hls1/main/N.ts` and makes all of them 400. `> 0` is exactly why playing
    3463    /// from the beginning survived.
    3464    ///
    3465    /// HLS does not need the parameter: the playlist spans the whole item, and
    3466    /// asking for segment N *is* the seek — the server transcodes from there. So
    3467    /// the position never belongs in this URL; the player seeks after load. The
    3468    /// sibling progressive `/Audio/universal` builder is a different endpoint with
    3469    /// no segments, and keeps its `StartTimeTicks`.
    3470    ///
    3471    /// TRACES: UR-004, UR-074 | DR-181 | UT-182
    3472    #[tokio::test]
    3473    async fn test_video_stream_url_never_carries_start_time_ticks() {
    3474        let _fixture = QualityFixture::set(StreamingQuality::Original);
    3475        let repo = create_test_repository();
    3476
    3477        let url = repo
    3478            .get_video_stream_url("vid-1", Some("source-1"), Some(1))
    3479            .await
    3480            .unwrap();
    3481
    3482        assert!(
    3483            !url.contains("StartTimeTicks"),
    3484            "an HLS playlist must never carry StartTimeTicks — the server copies it \
    3485             onto every segment URI and then rejects each one with 400: {url}"
    3486        );
    3487    }
    3488
    3489    #[tokio::test]
    3490    async fn test_get_video_stream_url_omits_position_when_absent() {
    3491        let _fixture = QualityFixture::set(StreamingQuality::Original);
    3492        let repo = create_test_repository();
    3493
    3494        let url = repo
    3495            .get_video_stream_url("vid-1", None, None)
    3496            .await
    3497            .unwrap();
    3498
    3499        assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
    3500        assert!(!url.contains("StartTimeTicks"));
    3501        assert!(!url.contains("MediaSourceId"));
    3502        // With no track chosen, the param must be OMITTED so the server picks the
    3503        // source's DefaultAudioStreamIndex. `MediaStream.Index` is global across
    3504        // all streams of a source, so index 0 is the *video* stream on virtually
    3505        // every file — sending it asks for a "audio track" that has no audio.
    3506        assert!(
    3507            !url.contains("AudioStreamIndex"),
    3508            "must not pin an audio index when none was chosen: {url}"
    3509        );
    3510    }
    3511
    3512    /// Jellyfin keys a transcode job by device *and* play session. Every stream
    3513    /// this app opened used the same `DeviceId` and no `PlaySessionId`, so
    3514    /// re-opening the same item — what a mid-playback quality switch, a
    3515    /// transcoded seek and an audio-track switch all do — handed the server a
    3516    /// second job it could not tell apart from the one still running. Observed
    3517    /// on-device: the new playlist is served, then `hls1/main/0.ts` 400s
    3518    /// intermittently while the two jobs fight over the same transcode path, and
    3519    /// playback stalls.
    3520    ///
    3521    /// TRACES: UR-074 | DR-177 | UT-173
    3522    #[tokio::test]
    3523    async fn test_video_stream_url_carries_a_play_session_id() {
    3524        let _fixture = QualityFixture::set(StreamingQuality::Original);
    3525        let repo = create_test_repository();
    3526
    3527        let url = repo
    3528            .get_video_stream_url("vid-1", None, None)
    3529            .await
    3530            .unwrap();
    3531
    3532        assert!(
    3533            url.contains("PlaySessionId="),
    3534            "every transcode must be openable as its own job: {url}"
    3535        );
    3536    }
    3537
    3538    /// Naming no subtitle stream is not the same as asking for none. The server
    3539    /// fills the gap with the source's own default/forced track, and an
    3540    /// image-based one (PGS/DVD/DVB) can only be delivered by painting it into
    3541    /// the picture — the burn-in of DR-176, arriving through the URL rather than
    3542    /// through the negotiation.
    3543    ///
    3544    /// The negotiation already sends the sentinel, but it is not what opens most
    3545    /// streams: a quality switch, a transcoded seek and an audio-track switch all
    3546    /// build this URL again, on their own. Saying it here too makes "no subtitle"
    3547    /// a property of the request instead of something inherited from whatever
    3548    /// session state the server happens to still hold.
    3549    ///
    3550    /// TRACES: UR-020, UR-004 | DR-176 | UT-168
    3551    #[tokio::test]
    3552    async fn test_video_stream_url_asks_for_no_subtitle_stream() {
    3553        let _fixture = QualityFixture::set(StreamingQuality::Original);
    3554        let repo = create_test_repository();
    3555
    3556        let url = repo
    3557            .get_video_stream_url("vid-1", Some("source-1"), Some(1))
    3558            .await
    3559            .unwrap();
    3560
    3561        assert!(
    3562            url.contains("SubtitleStreamIndex=-1"),
    3563            "the stream URL must ask for no subtitle, not leave the choice open: {url}"
    3564        );
    3565    }
    3566
    3567    /// The picker must not offer a subtitle the app cannot draw. Image-based
    3568    /// tracks are bitmaps: the only way to show one is to have the server
    3569    /// composite it, which is exactly what DR-176 stopped asking for. Selecting
    3570    /// one was therefore a control that could not do anything — so the verdict
    3571    /// travels with the stream, decided here where the codec vocabulary lives.
    3572    ///
    3573    /// TRACES: UR-020 | DR-176 | UT-168
    3574    #[test]
    3575    fn test_media_streams_carry_whether_the_app_can_render_them() {
    3576        let item: JellyfinItem = serde_json::from_value(serde_json::json!({
    3577            "Id": "ep-1",
    3578            "Name": "Partings",
    3579            "Type": "Episode",
    3580            "MediaStreams": [
    3581                { "Type": "Video", "Index": 0, "Codec": "hevc", "IsDefault": true },
    3582                { "Type": "Audio", "Index": 1, "Codec": "eac3", "IsDefault": true },
    3583                { "Type": "Subtitle", "Index": 2, "Codec": "PGSSUB", "IsDefault": true },
    3584                { "Type": "Subtitle", "Index": 3, "Codec": "subrip", "IsDefault": false },
    3585                { "Type": "Subtitle", "Index": 4, "Codec": null, "IsDefault": false },
    3586            ],
    3587        }))
    3588        .expect("fixture must deserialize");
    3589
    3590        let streams = item.into_media_item("server-1".to_string()).media_streams;
    3591        let streams = streams.expect("the item carries streams");
    3592        let deliverable = |index: i32| {
    3593            streams
    3594                .iter()
    3595                .find(|s| s.index == index)
    3596                .unwrap_or_else(|| panic!("stream {index} missing"))
    3597                .supports_external_delivery
    3598        };
    3599
    3600        // The bitmap track the server would have had to burn in.
    3601        assert_eq!(deliverable(2), Some(false));
    3602        // Text: fetched as WebVTT and drawn by the app itself.
    3603        assert_eq!(deliverable(3), Some(true));
    3604        // A subtitle whose format the server did not name could be anything;
    3605        // offering it risks a dead control, so it is not offered.
    3606        assert_eq!(deliverable(4), Some(false));
    3607        // Meaningless for anything that is not a subtitle — and said as `None`
    3608        // rather than as a `false` a reader could mistake for a verdict.
    3609        assert_eq!(deliverable(0), None);
    3610        assert_eq!(deliverable(1), None);
    3611    }
    3612
    3613    /// The session id is what makes two opens *distinguishable*, so a fresh one
    3614    /// per open is the whole point — and the open must report the id it replaced
    3615    /// so the caller can stop that job instead of leaving it running.
    3616    ///
    3617    /// TRACES: UR-074 | DR-177 | UT-173
    3618    #[test]
    3619    fn test_each_stream_open_gets_a_fresh_session_and_reports_the_previous() {
    3620        let _lock = QUALITY_LOCK.lock_safe();
    3621
    3622        let (first, _) = begin_video_play_session();
    3623        let (second, replaced) = begin_video_play_session();
    3624
    3625        assert_ne!(first, second, "each open needs its own job identity");
    3626        assert_eq!(
    3627            replaced,
    3628            Some(first),
    3629            "the open must hand back the job it superseded so it can be stopped"
    3630        );
    3631
    3632        // A server-started transcode (PlaybackInfo answered with a TranscodingUrl)
    3633        // has to become the current session too — otherwise the first switch on
    3634        // that stream stops nothing and collides with what is playing.
    3635        let replaced_by_adoption = adopt_video_play_session("server-named-session".to_string());
    3636        assert_eq!(replaced_by_adoption, Some(second));
    3637
    3638        let (_, after_adoption) = begin_video_play_session();
    3639        assert_eq!(
    3640            after_adoption,
    3641            Some("server-named-session".to_string()),
    3642            "the adopted job must be the one the next open stops"
    3643        );
    3644    }
    3645
    3646    #[tokio::test]
    3647    async fn test_get_audio_only_stream_url_for_video_carries_track_and_position() {
    3648        // TRACES: UR-040 | JA-032 | UT-059
    3649        // Background-audio handoff must request an audio-only stream (no video
    3650        // decode) that resumes at the current position and keeps the selected
    3651        // audio track.
    3652        let repo = create_test_repository();
    3653
    3654        let url = repo
    3655            .get_audio_only_stream_url_for_video("vid-1", Some("source-1"), Some(193.0), Some(2))
    3656            .await
    3657            .unwrap();
    3658
    3659        assert!(
    3660            url.starts_with("https://test.server.com/Audio/vid-1/universal?"),
    3661            "expected audio-only universal endpoint, got: {url}"
    3662        );
    3663        // Must NOT be a video stream (no client video decode in background).
    3664        assert!(
    3665            !url.contains("/Videos/"),
    3666            "url must not hit the video endpoint: {url}"
    3667        );
    3668        assert!(
    3669            !url.contains("master.m3u8"),
    3670            "url must not be a video HLS playlist: {url}"
    3671        );
    3672        assert!(url.contains("AudioStreamIndex=2"));
    3673        assert!(url.contains("MediaSourceId=source-1"));
    3674        // 193.0 seconds * 10_000_000 ticks/sec
    3675        assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
    3676        // Progressive mp3 over HTTP — NOT HLS/ts, or ExoPlayer's progressive
    3677        // loader fails with ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED.
    3678        assert!(url.contains("TranscodingProtocol=http"), "url: {url}");
    3679        assert!(url.contains("TranscodingContainer=mp3"), "url: {url}");
    3680        assert!(
    3681            !url.contains("TranscodingProtocol=hls"),
    3682            "url must not be HLS: {url}"
    3683        );
    3684        assert!(!url.contains("Container=ts"), "url must not be ts: {url}");
    3685    }
    3686
    3687    #[tokio::test]
    3688    async fn test_get_audio_only_stream_url_for_video_omits_position_when_absent() {
    3689        // TRACES: UR-040 | JA-032 | UT-059
    3690        let repo = create_test_repository();
    3691
    3692        let url = repo
    3693            .get_audio_only_stream_url_for_video("vid-1", None, None, None)
    3694            .await
    3695            .unwrap();
    3696
    3697        assert!(url.starts_with("https://test.server.com/Audio/vid-1/universal?"));
    3698        assert!(!url.contains("StartTimeTicks"));
    3699        assert!(!url.contains("MediaSourceId"));
    3700        // Same as the video path: omit rather than pin index 0 (the video stream),
    3701        // and let the server fall back to the source's default audio stream.
    3702        assert!(
    3703            !url.contains("AudioStreamIndex"),
    3704            "must not pin an audio index when none was chosen: {url}"
    3705        );
    3706    }
    3707
    3708    #[tokio::test]
    3709    async fn test_get_audio_stream_url_with_special_characters() {
    3710        let repo = create_test_repository();
    3711        let item_id = "track-with-special-chars-!@#";
    3712
    3713        let result = repo.get_audio_stream_url(item_id).await;
    3714
    3715        assert!(result.is_ok());
    3716        let url = result.unwrap();
    3717        assert!(url.contains("track-with-special-chars-!@#"));
    3718        assert!(url.starts_with("https://test.server.com/Audio/"));
    3719    }
    3720
    3721    #[test]
    3722    fn test_image_tags_deserialize_hashmap_format() {
    3723        // Test modern HashMap format with Primary tag
    3724        let json = r#"{"Primary":"abc123","Banner":"def456","Backdrop":"ghi789"}"#;
    3725        let result: Result<ImageTags, _> = serde_json::from_str(json);
    3726
    3727        assert!(result.is_ok());
    3728        let tags = result.unwrap();
    3729        assert_eq!(tags.primary(), Some("abc123".to_string()));
    3730    }
    3731
    3732    #[test]
    3733    fn test_image_tags_deserialize_structured_format() {
    3734        // Test legacy structured format with Primary field
    3735        let json = r#"{"Primary":"xyz789"}"#;
    3736        let result: Result<ImageTags, _> = serde_json::from_str(json);
    3737
    3738        assert!(result.is_ok());
    3739        let tags = result.unwrap();
    3740        assert_eq!(tags.primary(), Some("xyz789".to_string()));
    3741    }
    3742
    3743    #[test]
    3744    fn test_image_tags_deserialize_missing_primary() {
    3745        // Test HashMap without Primary tag
    3746        let json = r#"{"Banner":"def456","Backdrop":"ghi789"}"#;
    3747        let result: Result<ImageTags, _> = serde_json::from_str(json);
    3748
    3749        assert!(result.is_ok());
    3750        let tags = result.unwrap();
    3751        assert_eq!(tags.primary(), None);
    3752    }
    3753
    3754    #[test]
    3755    fn test_image_tags_deserialize_empty_map() {
    3756        // Test empty HashMap
    3757        let json = r#"{}"#;
    3758        let result: Result<ImageTags, _> = serde_json::from_str(json);
    3759
    3760        assert!(result.is_ok());
    3761        let tags = result.unwrap();
    3762        assert_eq!(tags.primary(), None);
    3763    }
    3764
    3765    // ===== Video download URL (real impl) =====
    3766    //
    3767    // These exercise the PRODUCTION `OnlineRepository::get_video_download_url`,
    3768    // not a mock. A prior mock in online_integration_test.rs used the correct
    3769    // `stream.mp4` endpoint while the real impl shipped `/Videos/{id}/download`,
    3770    // which returns 404 on real servers and silently broke every movie/TV
    3771    // download. Assert the real builder targets the resumable stream endpoint.
    3772    //
    3773    // @req-test: DR-013 - Repository pattern for online/offline data access
    3774
    3775    #[test]
    3776    fn test_video_download_url_uses_stream_not_download_endpoint() {
    3777        let repo = create_test_repository();
    3778        let url = repo.get_video_download_url("item123", "original", None, None);
    3779
    3780        // Must NOT use the /download endpoint (404 on real servers).
    3781        assert!(
    3782            !url.contains("/download"),
    3783            "download URL must not use the broken /Videos/{{id}}/download endpoint: {url}"
    3784        );
    3785        // Must use the progressive, range-resumable stream endpoint.
    3786        assert!(
    3787            url.contains("/Videos/item123/stream.mp4"),
    3788            "download URL must target /Videos/{{id}}/stream.mp4: {url}"
    3789        );
    3790        assert!(url.contains("api_key=test-access-token"), "url: {url}");
    3791    }
    3792
    3793    #[test]
    3794    fn test_video_download_url_original_is_static_direct_copy() {
    3795        let repo = create_test_repository();
    3796        let url = repo.get_video_download_url("item123", "original", None, None);
    3797
    3798        // "original" must request a direct static copy (byte-range resumable),
    3799        // with no transcode params.
    3800        assert!(url.contains("Static=true"), "url: {url}");
    3801        assert!(
    3802            !url.contains("videoBitRate"),
    3803            "original must not transcode: {url}"
    3804        );
    3805        assert!(
    3806            !url.contains("maxHeight"),
    3807            "original must not transcode: {url}"
    3808        );
    3809    }
    3810
    3811    #[test]
    3812    fn test_video_download_url_quality_presets_transcode() {
    3813        let repo = create_test_repository();
    3814
    3815        for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
    3816            let url = repo.get_video_download_url("item123", quality, None, None);
    3817            assert!(
    3818                url.contains("/Videos/item123/stream.mp4"),
    3819                "{quality} must use stream.mp4: {url}"
    3820            );
    3821            assert!(
    3822                url.contains("videoBitRate="),
    3823                "{quality} must set bitrate: {url}"
    3824            );
    3825            assert!(
    3826                url.contains(&format!("maxHeight={height}")),
    3827                "{quality} must cap height at {height}: {url}"
    3828            );
    3829            assert!(url.contains("videoCodec=h264"), "{quality}: {url}");
    3830            // Transcoded presets must not also ask for a static copy.
    3831            assert!(
    3832                !url.contains("Static=true"),
    3833                "{quality} must not be Static: {url}"
    3834            );
    3835        }
    3836    }
    3837
    3838    /// The bitrate params are spelled `videoBitRate`/`audioBitRate` — **capital
    3839    /// R**. Jellyfin binds query keys case-insensitively, so this is not a
    3840    /// casing preference: `videoBitrate` is a *different token* that fails to
    3841    /// bind and is silently discarded, taking the user's quality cap with it.
    3842    /// Nothing errors — the download just returns the full-size original, which
    3843    /// is exactly how this bug went unnoticed.
    3844    #[test]
    3845    fn test_video_download_url_bitrate_params_use_capital_r_spelling() {
    3846        let repo = create_test_repository();
    3847
    3848        for quality in ["high", "medium", "low"] {
    3849            let url = repo.get_video_download_url("item123", quality, None, None);
    3850
    3851            assert!(
    3852                url.contains("videoBitRate="),
    3853                "{quality} must spell it videoBitRate (capital R): {url}"
    3854            );
    3855            assert!(
    3856                url.contains("audioBitRate="),
    3857                "{quality} must spell it audioBitRate (capital R): {url}"
    3858            );
    3859
    3860            // The lowercase-r spellings never bind — they must not appear at
    3861            // all, or the cap is silently dropped by the server.
    3862            assert!(
    3863                !url.contains("videoBitrate="),
    3864                "{quality} emits the unbindable lowercase-r spelling: {url}"
    3865            );
    3866            assert!(
    3867                !url.contains("audioBitrate="),
    3868                "{quality} emits the unbindable lowercase-r spelling: {url}"
    3869            );
    3870        }
    3871    }
    3872
    3873    /// A correctly-spelled cap is still only *conditionally* honored: the server
    3874    /// may stream-copy the source when it already satisfies the cap. Video copy
    3875    /// is gated by `allowVideoStreamCopy` (NOT `enableAutoStreamCopy`, which
    3876    /// only governs audio), so the transcode presets must disable it to
    3877    /// guarantee a real re-encode at the requested bitrate.
    3878    #[test]
    3879    fn test_video_download_url_transcode_presets_forbid_video_stream_copy() {
    3880        let repo = create_test_repository();
    3881
    3882        for quality in ["high", "medium", "low"] {
    3883            let url = repo.get_video_download_url("item123", quality, None, None);
    3884            assert!(
    3885                url.contains("allowVideoStreamCopy=false"),
    3886                "{quality} must forbid video stream copy: {url}"
    3887            );
    3888        }
    3889
    3890        // "original" is a deliberate direct copy — it must NOT disable copying.
    3891        let original = repo.get_video_download_url("item123", "original", None, None);
    3892        assert!(
    3893            !original.contains("allowVideoStreamCopy=false"),
    3894            "original must remain a direct copy: {original}"
    3895        );
    3896    }
    3897
    3898    /// A downloaded file is played with no server in reach, so `original`
    3899    /// quality cannot mean "copy whatever the source holds" when the source
    3900    /// holds audio this device cannot decode.
    3901    ///
    3902    /// `Static=true` hands back the source bytes untouched, E-AC-3/AC-3/DTS
    3903    /// track included, and video plays through the webview `<video>` element on
    3904    /// both platforms — which decodes none of them. Streaming already knows this
    3905    /// (DR-149 forces a transcode over the server's own direct-play offer); the
    3906    /// download path did not, so a downloaded film played offline as picture with
    3907    /// no sound while the very same film had sound when streamed.
    3908    ///
    3909    /// TRACES: UR-071, UR-004 | DR-171 | UT-166
    3910    #[test]
    3911    fn test_video_download_url_original_transcodes_undecodable_audio() {
    3912        let repo = create_test_repository();
    3913
    3914        for codec in ["eac3", "ac3", "dts", "truehd", "EAC3"] {
    3915            let url = repo.get_video_download_url("item123", "original", None, Some(codec));
    3916            assert!(
    3917                !url.contains("Static=true"),
    3918                "{codec} cannot be decoded here, so the source must not be copied verbatim: {url}"
    3919            );
    3920            assert!(
    3921                url.contains("audioCodec=aac"),
    3922                "{codec} must be re-encoded to aac on the way down: {url}"
    3923            );
    3924            // "Original" still has to mean original picture: the video stream is
    3925            // copied when it can be, so no bitrate or resolution cap appears.
    3926            assert!(
    3927                url.contains("allowVideoStreamCopy=true"),
    3928                "the video stream must still be copied where possible: {url}"
    3929            );
    3930            assert!(
    3931                !url.contains("videoBitRate") && !url.contains("maxHeight"),
    3932                "original must not degrade the picture to fix the audio: {url}"
    3933            );
    3934        }
    3935    }
    3936
    3937    /// The converse, and the reason the policy is per-item rather than blanket:
    3938    /// audio that plays here keeps the byte-exact, range-resumable copy that the
    3939    /// download worker's resume depends on.
    3940    ///
    3941    /// TRACES: UR-071 | DR-171 | UT-166
    3942    #[test]
    3943    fn test_video_download_url_original_keeps_static_copy_for_playable_audio() {
    3944        let repo = create_test_repository();
    3945
    3946        for codec in ["aac", "mp3", "opus", "vorbis", "flac", "AAC"] {
    3947            let url = repo.get_video_download_url("item123", "original", None, Some(codec));
    3948            assert!(
    3949                url.contains("Static=true"),
    3950                "{codec} plays here — the download must stay a direct copy: {url}"
    3951            );
    3952            assert!(
    3953                !url.contains("audioCodec="),
    3954                "{codec} needs no transcode: {url}"
    3955            );
    3956        }
    3957
    3958        // Unknown codec: the policy only ever *adds* a transcode, so an item we
    3959        // could not look up behaves exactly as it did before.
    3960        let unknown = repo.get_video_download_url("item123", "original", None, None);
    3961        assert!(unknown.contains("Static=true"), "url: {unknown}");
    3962    }
    3963
    3964    /// The explicit quality presets already transcode audio to AAC, so the
    3965    /// policy has nothing to add — and must not start overriding a chosen cap.
    3966    ///
    3967    /// TRACES: UR-071 | DR-171 | UT-166
    3968    #[test]
    3969    fn test_video_download_url_presets_ignore_the_audio_policy() {
    3970        let repo = create_test_repository();
    3971
    3972        for quality in ["high", "medium", "low"] {
    3973            let with = repo.get_video_download_url("item123", quality, None, Some("eac3"));
    3974            let without = repo.get_video_download_url("item123", quality, None, None);
    3975            assert_eq!(with, without, "{quality} must not vary with source audio");
    3976            assert!(with.contains("audioCodec=aac"), "url: {with}");
    3977        }
    3978    }
    3979
    3980    #[test]
    3981    fn test_video_download_url_passes_media_source_id() {
    3982        let repo = create_test_repository();
    3983        let url = repo.get_video_download_url("item123", "original", Some("src-42"), None);
    3984        assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
    3985    }
    3986
    3987    #[test]
    3988    fn test_jellyfin_item_deserialize_with_image_tags() {
    3989        // Test full JellyfinItem deserialization with ImageTags
    3990        let json = r#"{
    3991            "Id": "album123",
    3992            "Name": "Test Album",
    3993            "Type": "MusicAlbum",
    3994            "ImageTags": {"Primary": "tag123"},
    3995            "ArtistItems": [
    3996                {"Id": "artist1", "Name": "Artist One"},
    3997                {"Id": "artist2", "Name": "Artist Two"}
    3998            ]
    3999        }"#;
    4000
    4001        let result: Result<JellyfinItem, _> = serde_json::from_str(json);
    4002        assert!(result.is_ok());
    4003
    4004        let item = result.unwrap();
    4005        assert_eq!(item.id, "album123");
    4006        assert_eq!(item.name, "Test Album");
    4007        assert_eq!(item.item_type, "MusicAlbum");
    4008        assert!(item.image_tags.is_some());
    4009        assert_eq!(
    4010            item.image_tags.unwrap().primary(),
    4011            Some("tag123".to_string())
    4012        );
    4013    }
    4014
    4015    /// UT-100 — the favourites endpoint asks the server for favourites, scoped.
    4016    ///
    4017    /// TRACES: UR-067 | DR-115, JA-033 | UT-100
    4018    #[test]
    4019    fn test_build_favorites_endpoint_scopes_and_filters() {
    4020        let movies = build_favorites_endpoint("u1", SearchScope::Movies, None);
    4021        assert!(movies.starts_with("/Users/u1/Items?Filters=IsFavorite&Recursive=true"));
    4022        assert!(movies.contains("&IncludeItemTypes=Movie"));
    4023        // Jellyfin has no favourite timestamp, so name order is the default.
    4024        assert!(movies.contains("&SortBy=SortName&SortOrder=Ascending"));
    4025        // Hearts must render on the returned cards.
    4026        assert!(movies.contains("UserData"));
    4027
    4028        // Tv covers both the show and any individually favourited episode.
    4029        let tv = build_favorites_endpoint("u1", SearchScope::Tv, None);
    4030        assert!(tv.contains("&IncludeItemTypes=Series,Episode"));
    4031
    4032        let music = build_favorites_endpoint("u1", SearchScope::Music, None);
    4033        assert!(music.contains("&IncludeItemTypes=MusicAlbum,MusicArtist,Audio,Playlist"));
    4034    }
    4035
    4036    /// `All` must omit the type filter entirely rather than send a union, which
    4037    /// would silently drop every type nobody enumerated.
    4038    ///
    4039    /// TRACES: UR-067 | DR-115 | UT-100
    4040    #[test]
    4041    fn test_build_favorites_endpoint_all_scope_omits_type_filter() {
    4042        let all = build_favorites_endpoint("u1", SearchScope::All, None);
    4043        assert!(!all.contains("IncludeItemTypes"));
    4044    }
    4045
    4046    /// Paging and an explicit sort still reach the server.
    4047    ///
    4048    /// TRACES: UR-067 | DR-115 | UT-100
    4049    #[test]
    4050    fn test_build_favorites_endpoint_honours_paging_and_sort() {
    4051        let endpoint = build_favorites_endpoint(
    4052            "u1",
    4053            SearchScope::All,
    4054            Some(&GetItemsOptions {
    4055                limit: Some(20),
    4056                start_index: Some(40),
    4057                sort_by: Some("Random".to_string()),
    4058                sort_order: Some("Descending".to_string()),
    4059                ..Default::default()
    4060            }),
    4061        );
    4062        assert!(endpoint.contains("&Limit=20"));
    4063        assert!(endpoint.contains("&StartIndex=40"));
    4064        assert!(endpoint.contains("&SortBy=Random&SortOrder=Descending"));
    4065    }
    4066
    4067    /// UT-104 — the in-library favourites toggle reaches the server as
    4068    /// `Filters=IsFavorite`, and is absent unless asked for.
    4069    ///
    4070    /// TRACES: UR-067 | DR-116 | UT-104
    4071    #[test]
    4072    fn test_get_items_endpoint_applies_favorites_only() {
    4073        let plain = build_get_items_endpoint("u1", "lib-1", None);
    4074        assert!(!plain.contains("Filters=IsFavorite"));
    4075
    4076        let filtered = build_get_items_endpoint(
    4077            "u1",
    4078            "lib-1",
    4079            Some(&GetItemsOptions {
    4080                favorites_only: Some(true),
    4081                include_item_types: Some(vec!["Movie".to_string()]),
    4082                ..Default::default()
    4083            }),
    4084        );
    4085        assert!(filtered.contains("&Filters=IsFavorite"));
    4086        // Composes with the filters already there rather than replacing them.
    4087        assert!(filtered.contains("&IncludeItemTypes=Movie"));
    4088        assert!(filtered.contains("ParentId=lib-1"));
    4089
    4090        // Explicitly false is not a request to filter.
    4091        let off = build_get_items_endpoint(
    4092            "u1",
    4093            "lib-1",
    4094            Some(&GetItemsOptions {
    4095                favorites_only: Some(false),
    4096                ..Default::default()
    4097            }),
    4098        );
    4099        assert!(!off.contains("Filters=IsFavorite"));
    4100    }
    4101
    4102    /// UT-206 — the values this endpoint builder puts in the query string are
    4103    /// percent-encoded, like `Genres` and `SearchTerm` already are.
    4104    ///
    4105    /// Unencoded, a value carrying `&` or `=` splits into an extra query
    4106    /// parameter (a parent id containing a space produced a malformed URL
    4107    /// outright), so the request the server sees is not the one that was built.
    4108    ///
    4109    /// TRACES: UR-007 | DR-212 | UT-206
    4110    #[test]
    4111    fn test_get_items_endpoint_encodes_query_values() {
    4112        let endpoint = build_get_items_endpoint(
    4113            "u1",
    4114            "lib 1&Filters=IsFavorite",
    4115            Some(&GetItemsOptions {
    4116                include_item_types: Some(vec!["Movie&x=1".to_string()]),
    4117                sort_by: Some("Sort Name".to_string()),
    4118                sort_order: Some("Ascending&y=2".to_string()),
    4119                ..Default::default()
    4120            }),
    4121        );
    4122        assert!(
    4123            endpoint.contains("ParentId=lib%201%26Filters%3DIsFavorite"),
    4124            "{endpoint}"
    4125        );
    4126        assert!(
    4127            endpoint.contains("&IncludeItemTypes=Movie%26x%3D1"),
    4128            "{endpoint}"
    4129        );
    4130        assert!(endpoint.contains("&SortBy=Sort%20Name"), "{endpoint}");
    4131        assert!(
    4132            endpoint.contains("&SortOrder=Ascending%26y%3D2"),
    4133            "{endpoint}"
    4134        );
    4135        // Nothing smuggled in as a parameter of its own.
    4136        assert!(!endpoint.contains("&Filters=IsFavorite"), "{endpoint}");
    4137        assert!(!endpoint.contains("&x=1"), "{endpoint}");
    4138        assert!(!endpoint.contains("&y=2"), "{endpoint}");
    4139    }
    4140
    4141    /// The separators inside a list parameter must survive encoding: Jellyfin
    4142    /// splits `SortBy` and `IncludeItemTypes` on commas, and `hybrid.rs` sends
    4143    /// "ParentIndexNumber,IndexNumber,SortName" to order episodes.
    4144    ///
    4145    /// TRACES: UR-007 | DR-212 | UT-206
    4146    #[test]
    4147    fn test_get_items_endpoint_keeps_list_separators() {
    4148        let endpoint = build_get_items_endpoint(
    4149            "u1",
    4150            "lib-1",
    4151            Some(&GetItemsOptions {
    4152                sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
    4153                include_item_types: Some(vec!["Movie".to_string(), "Series".to_string()]),
    4154                ..Default::default()
    4155            }),
    4156        );
    4157        assert!(
    4158            endpoint.contains("&SortBy=ParentIndexNumber,IndexNumber,SortName"),
    4159            "{endpoint}"
    4160        );
    4161        assert!(
    4162            endpoint.contains("&IncludeItemTypes=Movie,Series"),
    4163            "{endpoint}"
    4164        );
    4165        // A plain GUID parent id is unchanged by encoding.
    4166        assert!(endpoint.contains("ParentId=lib-1"), "{endpoint}");
    4167    }
    4168
    4169    /// The reported bug: a Jellypod podcast listed its episodes alphabetically,
    4170    /// so "[Played] …" titles clumped at the top and a new episode landed
    4171    /// wherever its name happened to fall.
    4172    ///
    4173    /// The cause was the frontend asking for `SortBy=SortName` on *every*
    4174    /// drill-down, which overrides the order the channel plugin itself would
    4175    /// have returned. Which order a container's children take is domain
    4176    /// knowledge, so the caller now names the container and the repository
    4177    /// answers with the sort: a channel folder is release-date-newest-first,
    4178    /// everything else keeps the name order it had.
    4179    ///
    4180    /// TRACES: UR-007 | DR-257 | UT-229
    4181    #[test]
    4182    fn test_get_items_endpoint_orders_channel_folders_by_release_date() {
    4183        let podcast = build_get_items_endpoint(
    4184            "u1",
    4185            "podcast-1",
    4186            Some(&GetItemsOptions {
    4187                parent_kind: Some(MediaKind::ChannelFolder),
    4188                ..Default::default()
    4189            }),
    4190        );
    4191        assert!(
    4192            podcast.contains("&SortBy=PremiereDate&SortOrder=Descending"),
    4193            "{podcast}"
    4194        );
    4195
    4196        // Every other container keeps the name order the app has always used.
    4197        let season = build_get_items_endpoint(
    4198            "u1",
    4199            "season-1",
    4200            Some(&GetItemsOptions {
    4201                parent_kind: Some(MediaKind::Season),
    4202                ..Default::default()
    4203            }),
    4204        );
    4205        assert!(
    4206            season.contains("&SortBy=SortName&SortOrder=Ascending"),
    4207            "{season}"
    4208        );
    4209
    4210        // An explicit sort still wins — the default only fills a gap.
    4211        let explicit = build_get_items_endpoint(
    4212            "u1",
    4213            "podcast-1",
    4214            Some(&GetItemsOptions {
    4215                parent_kind: Some(MediaKind::ChannelFolder),
    4216                sort_by: Some("SortName".to_string()),
    4217                sort_order: Some("Ascending".to_string()),
    4218                ..Default::default()
    4219            }),
    4220        );
    4221        assert!(
    4222            explicit.contains("&SortBy=SortName&SortOrder=Ascending"),
    4223            "{explicit}"
    4224        );
    4225        assert!(!explicit.contains("SortBy=PremiereDate"), "{explicit}");
    4226
    4227        // A caller that names no container is left alone, so the paths that
    4228        // rely on the server's own order (a playlist's stored order) keep it.
    4229        let unspecified = build_get_items_endpoint("u1", "lib-1", None);
    4230        assert!(!unspecified.contains("SortBy="), "{unspecified}");
    4231    }
    4232
    4233    /// A newly-added album must arrive as one entry, not one per track.
    4234    ///
    4235    /// Jellyfin's `/Items/Latest` defaults to `GroupItems=false`, which returns
    4236    /// every new Audio track individually — so ripping a 14-track album filled
    4237    /// the whole "recently added" row with that one album. `GroupItems=true`
    4238    /// makes the server collapse children into their parent container.
    4239    #[test]
    4240    fn test_latest_items_endpoint_groups_children_into_containers() {
    4241        let endpoint = build_latest_items_endpoint("u1", "lib-1", Some(16));
    4242
    4243        assert!(
    4244            endpoint.contains("GroupItems=true"),
    4245            "latest items must be grouped so an album counts once, got: {}",
    4246            endpoint
    4247        );
    4248        assert!(endpoint.contains("ParentId=lib-1"));
    4249        assert!(endpoint.contains("Limit=16"));
    4250    }
    4251
    4252    /// Build a `MediaItem` the way a real listing does — through the Jellyfin
    4253    /// payload — so the fixtures cannot drift from the parsed shape.
    4254    fn item_from_json(json: &str) -> MediaItem {
    4255        let parsed: JellyfinItem = serde_json::from_str(json).expect("fixture must parse");
    4256        parsed.into_media_item("srv".to_string())
    4257    }
    4258
    4259    fn track(id: &str, name: &str, album_id: Option<&str>) -> MediaItem {
    4260        let album = match album_id {
    4261            Some(a) => format!(r#""AlbumId": "{a}", "Album": "Kind of Blue","#),
    4262            None => String::new(),
    4263        };
    4264        item_from_json(&format!(
    4265            r#"{{
    4266                "Id": "{id}",
    4267                "Name": "{name}",
    4268                "Type": "Audio",
    4269                {album}
    4270                "ImageTags": {{"Primary": "art-{id}"}},
    4271                "AlbumArtist": "Miles Davis",
    4272                "Artists": ["Miles Davis"],
    4273                "IndexNumber": 1,
    4274                "RunTimeTicks": 1000
    4275            }}"#
    4276        ))
    4277    }
    4278
    4279    /// A newly-imported album must read as *one* new album, not one new song
    4280    /// per track — even when the server hands back the raw leaves despite
    4281    /// `GroupItems=true` (older servers, and libraries whose tracks resolve no
    4282    /// album parent, ignore it).
    4283    ///
    4284    /// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241
    4285    #[test]
    4286    fn test_collapse_tracks_into_albums_shows_one_card_per_album() {
    4287        let movie = item_from_json(
    4288            r#"{"Id": "mov-1", "Name": "Heat", "Type": "Movie", "ImageTags": {"Primary": "art-mov"}}"#,
    4289        );
    4290        let items = vec![
    4291            track("trk-1", "So What", Some("alb-1")),
    4292            track("trk-2", "Blue in Green", Some("alb-1")),
    4293            movie,
    4294            track("trk-3", "Flamenco Sketches", Some("alb-1")),
    4295        ];
    4296
    4297        let collapsed = collapse_tracks_into_albums(items);
    4298
    4299        assert_eq!(
    4300            collapsed.len(),
    4301            2,
    4302            "three tracks of one album plus a movie must read as two cards, got: {:?}",
    4303            collapsed.iter().map(|i| &i.name).collect::<Vec<_>>()
    4304        );
    4305
    4306        let album = &collapsed[0];
    4307        assert_eq!(album.id, "alb-1", "the card must open the album");
    4308        assert_eq!(album.name, "Kind of Blue");
    4309        assert_eq!(album.item_type, "MusicAlbum");
    4310        assert_eq!(album.kind, crate::domain::MediaKind::Album);
    4311        assert!(album.is_folder);
    4312        assert_eq!(album.album_artist.as_deref(), Some("Miles Davis"));
    4313        assert!(album.image_id.is_some(), "album card needs artwork");
    4314        // Track-only detail must not ride along on a container.
    4315        assert!(album.index_number.is_none());
    4316        assert!(album.album_id.is_none());
    4317        assert!(album.runtime_ticks.is_none());
    4318
    4319        // The movie keeps its place after the album its tracks stood in front of.
    4320        assert_eq!(collapsed[1].id, "mov-1");
    4321    }
    4322
    4323    /// When the server *did* group, its own album row wins — the tracks it also
    4324    /// returned must not add a second card for the same album.
    4325    ///
    4326    /// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-242
    4327    #[test]
    4328    fn test_collapse_prefers_the_album_row_the_server_returned() {
    4329        let album = item_from_json(
    4330            r#"{"Id": "alb-1", "Name": "Kind of Blue", "Type": "MusicAlbum", "IsFolder": true,
    4331                 "Overview": "1959", "ImageTags": {"Primary": "art-alb"}}"#,
    4332        );
    4333        let items = vec![
    4334            album,
    4335            track("trk-1", "So What", Some("alb-1")),
    4336            track("trk-2", "Blue in Green", Some("alb-1")),
    4337        ];
    4338
    4339        let collapsed = collapse_tracks_into_albums(items);
    4340
    4341        assert_eq!(collapsed.len(), 1, "one album, one card");
    4342        assert_eq!(collapsed[0].id, "alb-1");
    4343        assert_eq!(
    4344            collapsed[0].overview.as_deref(),
    4345            Some("1959"),
    4346            "the server's own album row must survive, not a track-built stand-in"
    4347        );
    4348    }
    4349
    4350    /// A track with no album has no container to collapse into, so it stays —
    4351    /// same reasoning that leaves movies alone.
    4352    ///
    4353    /// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-243
    4354    #[test]
    4355    fn test_collapse_leaves_a_standalone_track_alone() {
    4356        let items = vec![track("trk-1", "Field Recording", None)];
    4357
    4358        let collapsed = collapse_tracks_into_albums(items);
    4359
    4360        assert_eq!(collapsed.len(), 1);
    4361        assert_eq!(collapsed[0].id, "trk-1");
    4362        assert_eq!(collapsed[0].item_type, "Audio");
    4363    }
    4364
    4365    /// Collapsing shrinks the listing, so the request has to over-fetch: asking
    4366    /// for exactly 16 rows and then folding one 14-track album into them leaves
    4367    /// an almost empty "Recently Added".
    4368    ///
    4369    /// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-244
    4370    #[test]
    4371    fn test_latest_items_over_fetches_before_collapsing() {
    4372        assert!(
    4373            latest_items_fetch_limit(16) > 16,
    4374            "must ask for more rows than the row shows"
    4375        );
    4376        let endpoint =
    4377            build_latest_items_endpoint("u1", "lib-1", Some(latest_items_fetch_limit(16)));
    4378        assert!(endpoint.contains(&format!("Limit={}", latest_items_fetch_limit(16))));
    4379    }
    4380
    4381    /// UT-190 — Next Up asks the server to leave resumable episodes out.
    4382    ///
    4383    /// Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns
    4384    /// the *in-progress* episode as a series' next up — exactly the episode
    4385    /// `/Items/Resume` already returns, so Continue Watching and Next Up render
    4386    /// the same cards.
    4387    ///
    4388    /// TRACES: UR-059 | DR-197, JA-036 | UT-190
    4389    #[test]
    4390    fn test_build_next_up_endpoint_excludes_resumable() {
    4391        let endpoint = build_next_up_endpoint("u1", None, Some(12));
    4392
    4393        assert!(
    4394            endpoint.contains("EnableResumable=false"),
    4395            "next up must exclude in-progress episodes, got: {}",
    4396            endpoint
    4397        );
    4398        assert!(endpoint.contains("UserId=u1"));
    4399        assert!(endpoint.contains("Limit=12"));
    4400        assert!(
    4401            !endpoint.contains("SeriesId"),
    4402            "no series filter when none was requested, got: {}",
    4403            endpoint
    4404        );
    4405    }
    4406
    4407    /// UT-191 — a per-series Next Up query keeps the series filter.
    4408    ///
    4409    /// TRACES: UR-059 | DR-197 | UT-191
    4410    #[test]
    4411    fn test_build_next_up_endpoint_scopes_to_series() {
    4412        let endpoint = build_next_up_endpoint("u1", Some("series-a"), None);
    4413
    4414        assert!(endpoint.contains("SeriesId=series-a"));
    4415        assert!(endpoint.contains("EnableResumable=false"));
    4416        assert!(
    4417            endpoint.contains("Limit=16"),
    4418            "default limit, got: {}",
    4419            endpoint
    4420        );
    4421    }
    4422
    4423    /// UT-099 — a Jellyfin item's `UserData` reaches `MediaItem.user_data`.
    4424    ///
    4425    /// Before DR-113 this mapping was hardcoded to `None`, so nothing outside
    4426    /// the mini player could know an item was favourited.
    4427    ///
    4428    /// TRACES: UR-069 | DR-113, JA-034 | UT-099
    4429    #[test]
    4430    fn test_jellyfin_item_maps_user_data_favorite() {
    4431        let json = r#"{
    4432            "Id": "movie123",
    4433            "Name": "Test Movie",
    4434            "Type": "Movie",
    4435            "UserData": {
    4436                "PlaybackPositionTicks": 6000000000,
    4437                "Played": false,
    4438                "IsFavorite": true,
    4439                "PlayCount": 2,
    4440                "LastPlayedDate": "2026-08-01T12:00:00Z"
    4441            }
    4442        }"#;
    4443
    4444        let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
    4445        let media = item.into_media_item("server1".to_string());
    4446
    4447        let user_data = media.user_data.expect("user data should be mapped");
    4448        assert_eq!(user_data.is_favorite, Some(true));
    4449        assert_eq!(user_data.is_played, Some(false));
    4450        assert_eq!(user_data.play_count, Some(2));
    4451        assert_eq!(user_data.playback_position_ticks, Some(6_000_000_000));
    4452        // Ticks are converted for the frontend, which never divides them itself.
    4453        assert_eq!(user_data.playback_position_ms, Some(600_000));
    4454    }
    4455
    4456    /// An item without `UserData` still maps — the field is optional, and every
    4457    /// non-user-scoped endpoint omits it.
    4458    ///
    4459    /// TRACES: UR-069 | DR-113 | UT-099
    4460    #[test]
    4461    fn test_jellyfin_item_without_user_data_maps_to_none() {
    4462        let json = r#"{"Id": "x", "Name": "No User Data", "Type": "Movie"}"#;
    4463
    4464        let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
    4465        let media = item.into_media_item("server1".to_string());
    4466
    4467        assert!(media.user_data.is_none());
    4468    }
    4469
    4470    #[test]
    4471    fn test_jellyfin_item_deserialize_with_artist_items() {
    4472        // Test that ArtistItems with PascalCase fields deserialize correctly
    4473        let json = r#"{
    4474            "Id": "track123",
    4475            "Name": "Test Track",
    4476            "Type": "Audio",
    4477            "ArtistItems": [
    4478                {"Id": "artist1", "Name": "Bob Dylan"},
    4479                {"Id": "artist2", "Name": "Johnny Cash"}
    4480            ]
    4481        }"#;
    4482
    4483        let result: Result<JellyfinItem, _> = serde_json::from_str(json);
    4484        assert!(result.is_ok());
    4485
    4486        let item = result.unwrap();
    4487        let artist_items = item.artist_items.expect("Expected artist items");
    4488        assert_eq!(artist_items.len(), 2);
    4489        assert_eq!(artist_items[0].id, "artist1");
    4490        assert_eq!(artist_items[0].name, "Bob Dylan");
    4491        assert_eq!(artist_items[1].id, "artist2");
    4492        assert_eq!(artist_items[1].name, "Johnny Cash");
    4493    }
    4494
    4495    #[test]
    4496    fn test_jellyfin_item_to_media_item_conversion() {
    4497        // Test conversion from JellyfinItem to MediaItem preserves image tags
    4498        let json = r#"{
    4499            "Id": "album456",
    4500            "Name": "Love and Theft",
    4501            "Type": "MusicAlbum",
    4502            "ImageTags": {"Primary": "7ebab4f6a80cd09d"},
    4503            "Artists": ["Bob Dylan"],
    4504            "ArtistItems": [{"Id": "0b2a6e969a27f22aba97f9f0e69fa849", "Name": "Bob Dylan"}],
    4505            "RunTimeTicks": 33900137190
    4506        }"#;
    4507
    4508        let jellyfin_item: JellyfinItem = serde_json::from_str(json).expect("Failed to parse");
    4509        let media_item = jellyfin_item.into_media_item("test-server-id".to_string());
    4510
    4511        assert_eq!(media_item.id, "album456");
    4512        assert_eq!(media_item.name, "Love and Theft");
    4513        assert_eq!(media_item.item_type, "MusicAlbum");
    4514        assert_eq!(
    4515            media_item.primary_image_tag,
    4516            Some("7ebab4f6a80cd09d".to_string())
    4517        );
    4518        assert_eq!(media_item.server_id, "test-server-id");
    4519    }
    4520
    4521    #[test]
    4522    fn test_items_response_deserialize() {
    4523        // Test full ItemsResponse with multiple items
    4524        let json = r#"{
    4525            "Items": [
    4526                {
    4527                    "Id": "item1",
    4528                    "Name": "Item One",
    4529                    "Type": "MusicAlbum",
    4530                    "ImageTags": {"Primary": "tag1"}
    4531                },
    4532                {
    4533                    "Id": "item2",
    4534                    "Name": "Item Two",
    4535                    "Type": "Audio",
    4536                    "ImageTags": {"Primary": "tag2"}
    4537                }
    4538            ],
    4539            "TotalRecordCount": 2
    4540        }"#;
    4541
    4542        let result: Result<ItemsResponse, _> = serde_json::from_str(json);
    4543        assert!(result.is_ok());
    4544
    4545        let response = result.unwrap();
    4546        assert_eq!(response.total_record_count, 2);
    4547        assert_eq!(response.items.len(), 2);
    4548        assert_eq!(response.items[0].id, "item1");
    4549        assert_eq!(response.items[1].id, "item2");
    4550    }
    4551
    4552    #[test]
    4553    fn test_search_term_is_url_encoded() {
    4554        // A multi-word query (and one with a reserved character) must be
    4555        // percent-encoded before being placed in the SearchTerm query param,
    4556        // otherwise the request URL is malformed and search returns nothing.
    4557        assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
    4558        assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
    4559    }
    4560
    4561    #[test]
    4562    fn test_jray_context_deserializes_actors() {
    4563        // The jray?t= envelope as documented in the JRay truth file format.
    4564        let json = r#"{
    4565            "actors": [
    4566                { "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
    4567            ]
    4568        }"#;
    4569        let ctx: JRayContext = serde_json::from_str(json).expect("should parse");
    4570        assert_eq!(ctx.actors.len(), 1);
    4571        assert_eq!(ctx.actors[0].name, "Tom Hanks");
    4572        assert_eq!(ctx.actors[0].jellyfin_id, "abc123-guid");
    4573    }
    4574
    4575    #[test]
    4576    fn test_jray_context_ignores_unknown_keys_and_missing_ids() {
    4577        // Future fields (locations/trivia) must be ignored, and absent id keys
    4578        // must default to "" rather than failing to parse.
    4579        let json = r#"{
    4580            "actors": [ { "name": "Extra" } ],
    4581            "locations": ["Beach"],
    4582            "trivia": "filmed in 1994"
    4583        }"#;
    4584        let ctx: JRayContext = serde_json::from_str(json).expect("should tolerate extra keys");
    4585        assert_eq!(ctx.actors.len(), 1);
    4586        assert_eq!(ctx.actors[0].name, "Extra");
    4587        assert_eq!(ctx.actors[0].imdb_id, "");
    4588        assert_eq!(ctx.actors[0].jellyfin_id, "");
    4589    }
    4590
    4591    // -----------------------------------------------------------------------
    4592    // Direct-play negotiation (DR-228)
    4593    //
    4594    // Fixtures rather than a live server, but the *shapes* are real: every one
    4595    // below was observed in a `PlaybackInfo` response from the development
    4596    // server while this was written. The measured yields those shapes produce —
    4597    // 7% direct play under the Linux h264-only profile, 85% under the Android
    4598    // profile — are recorded in the spec, not asserted here; what is asserted is
    4599    // that each shape maps to the kind it should.
    4600    // -----------------------------------------------------------------------
    4601
    4602    /// A `NegotiatedSource` fixture. Defaults describe the common case — a
    4603    /// source the server is happy to hand over untouched — so each test varies
    4604    /// only the field it is about.
    4605    fn source_fixture() -> NegotiatedSource {
    4606        NegotiatedSource {
    4607            id: "source-1".to_string(),
    4608            supports_direct_play: true,
    4609            supports_direct_stream: true,
    4610            supports_transcoding: true,
    4611            transcoding_url: None,
    4612            bitrate: Some(6_652_961),
    4613            media_streams: Vec::new(),
    4614        }
    4615    }
    4616
    4617    /// The whole point of DR-228: a source the server will serve untouched is
    4618    /// served untouched. Before this, every video play built an HLS transcode
    4619    /// URL regardless.
    4620    ///
    4621    /// TRACES: UR-079 | DR-228 | UT-213
    4622    #[test]
    4623    fn test_a_supported_source_direct_plays() {
    4624        let source = source_fixture();
    4625        assert_eq!(
    4626            decide_playback_kind(&source, false, false),
    4627            PlaybackKind::DirectPlay
    4628        );
    4629    }
    4630
    4631    /// The server can remux without re-encoding. That is not a transcode and
    4632    /// must not be reported as one — the difference is a whole CPU core.
    4633    ///
    4634    /// TRACES: UR-079 | DR-228 | UT-213
    4635    #[test]
    4636    fn test_a_remuxable_source_direct_streams() {
    4637        let source = NegotiatedSource {
    4638            supports_direct_play: false,
    4639            supports_direct_stream: true,
    4640            ..source_fixture()
    4641        };
    4642        let kind = decide_playback_kind(&source, false, false);
    4643        assert_eq!(kind, PlaybackKind::DirectStream);
    4644        assert!(
    4645            !kind.needs_transcoding(),
    4646            "a remux costs no encoder time and must not be reported as transcoding"
    4647        );
    4648    }
    4649
    4650    /// An unsupported codec — the hevc that is ~80% of the sampled library,
    4651    /// under the Linux h264-only profile — transcodes.
    4652    ///
    4653    /// TRACES: UR-079 | DR-228 | UT-213
    4654    #[test]
    4655    fn test_an_unsupported_source_transcodes() {
    4656        let source = NegotiatedSource {
    4657            supports_direct_play: false,
    4658            supports_direct_stream: false,
    4659            ..source_fixture()
    4660        };
    4661        assert_eq!(
    4662            decide_playback_kind(&source, false, false),
    4663            PlaybackKind::Transcode
    4664        );
    4665    }
    4666
    4667    /// The override that exists because Jellyfin 10.11.5 ignores a
    4668    /// DirectPlayProfile's audio codec: it offers direct play for an E-AC-3
    4669    /// track the webview cannot decode, which renders as picture with no sound.
    4670    /// The client's verdict has to win over the server's.
    4671    ///
    4672    /// TRACES: UR-079 | DR-228, DR-148 | UT-213
    4673    #[test]
    4674    fn test_undecodable_audio_overrides_the_servers_direct_play_offer() {
    4675        let source = source_fixture();
    4676        assert!(source.supports_direct_play, "the server said yes");
    4677        assert_eq!(
    4678            decide_playback_kind(&source, true, false),
    4679            PlaybackKind::Transcode,
    4680            "silent direct play is worse than a transcode"
    4681        );
    4682    }
    4683
    4684    /// A pinned audio track cannot be served by a file whose default track is a
    4685    /// different one. Honouring the viewer's choice means asking the server to
    4686    /// build a stream around it.
    4687    ///
    4688    /// TRACES: UR-021, UR-079 | DR-228 | UT-213
    4689    #[test]
    4690    fn test_pinning_an_audio_track_forces_a_transcode() {
    4691        let source = source_fixture();
    4692        assert_eq!(
    4693            decide_playback_kind(&source, false, true),
    4694            PlaybackKind::Transcode
    4695        );
    4696    }
    4697
    4698    /// A ceiling below the source bitrate has to transcode even though the
    4699    /// codec is fine — that is the only way a cap is actually honoured. The
    4700    /// server enforces this via `MaxStaticBitrate` in the profile we send, so it
    4701    /// arrives here as `supports_direct_play: false`; this pins the mapping so a
    4702    /// future refactor cannot quietly direct-play past a cap.
    4703    ///
    4704    /// TRACES: UR-074, UR-079 | DR-226, DR-228 | UT-213
    4705    #[test]
    4706    fn test_a_ceiling_below_the_source_bitrate_transcodes() {
    4707        // 6.65 Mbps source, 2 Mbps ceiling — the server refuses direct play.
    4708        let source = NegotiatedSource {
    4709            supports_direct_play: false,
    4710            supports_direct_stream: false,
    4711            bitrate: Some(6_652_961),
    4712            ..source_fixture()
    4713        };
    4714        assert_eq!(
    4715            decide_playback_kind(&source, false, false),
    4716            PlaybackKind::Transcode
    4717        );
    4718
    4719        // And the ladder marks 2 Mbps as genuinely constraining for it.
    4720        let options =
    4721            crate::repository::stream_selection::quality_options_for_source(Some(6_652_961));
    4722        let two_mbps = options
    4723            .iter()
    4724            .find(|o| o.quality == StreamingQuality::Mbps2)
    4725            .expect("2 Mbps is on the ladder");
    4726        assert!(!two_mbps.exceeds_source);
    4727    }
    4728
    4729    /// Direct play wins over direct stream when both are on offer: copying the
    4730    /// file is strictly cheaper than repackaging it.
    4731    ///
    4732    /// TRACES: UR-079 | DR-228 | UT-213
    4733    #[test]
    4734    fn test_direct_play_is_preferred_over_direct_stream() {
    4735        let source = source_fixture();
    4736        assert!(source.supports_direct_play && source.supports_direct_stream);
    4737        assert_eq!(
    4738            decide_playback_kind(&source, false, false),
    4739            PlaybackKind::DirectPlay
    4740        );
    4741    }
    4742
    4743    // -----------------------------------------------------------------------
    4744    // Per-playback quality ceiling (DR-226)
    4745    // -----------------------------------------------------------------------
    4746
    4747    /// The defect DR-226 exists to fix: the in-player picker documented itself
    4748    /// as a "this film, this connection" control but was implemented by writing
    4749    /// the device default, so one awkward film silently capped everything played
    4750    /// afterwards. The override must not touch the default.
    4751    ///
    4752    /// TRACES: UR-074, UR-079 | DR-226 | UT-213
    4753    #[test]
    4754    fn test_a_playback_override_does_not_disturb_the_device_default() {
    4755        let _guard = QUALITY_LOCK.lock_safe();
    4756        set_streaming_quality(StreamingQuality::Mbps10);
    4757        clear_playback_quality_override();
    4758        assert_eq!(effective_streaming_quality(), StreamingQuality::Mbps10);
    4759
    4760        set_playback_quality_override(StreamingQuality::Kbps720);
    4761        assert_eq!(
    4762            effective_streaming_quality(),
    4763            StreamingQuality::Kbps720,
    4764            "the override governs the stream being opened now"
    4765        );
    4766        assert_eq!(
    4767            streaming_quality(),
    4768            StreamingQuality::Mbps10,
    4769            "but the durable default the Settings screen shows is untouched"
    4770        );
    4771
    4772        clear_playback_quality_override();
    4773        assert_eq!(
    4774            effective_streaming_quality(),
    4775            StreamingQuality::Mbps10,
    4776            "and dropping the override returns to it"
    4777        );
    4778        set_streaming_quality(StreamingQuality::Original);
    4779    }
    4780
    4781    /// A ceiling chosen for one film must not govern the next one — the
    4782    /// autoplayed next episode is the case that matters, since nobody reopens
    4783    /// the picker between episodes.
    4784    ///
    4785    /// TRACES: UR-074, UR-079 | DR-226 | UT-213
    4786    #[test]
    4787    fn test_the_override_is_droppable_so_it_cannot_outlive_its_playback() {
    4788        let _guard = QUALITY_LOCK.lock_safe();
    4789        set_streaming_quality(StreamingQuality::Original);
    4790        set_playback_quality_override(StreamingQuality::Mbps1);
    4791        assert_eq!(playback_quality_override(), Some(StreamingQuality::Mbps1));
    4792
    4793        clear_playback_quality_override();
    4794        assert_eq!(playback_quality_override(), None);
    4795        assert_eq!(effective_streaming_quality(), StreamingQuality::Original);
    4796    }
    4797}