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