Skip to main content

jellytau_lib/repository/
online.rs

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