Skip to main content

jellytau_lib/repository/
online.rs

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