Skip to main content

jellytau_lib/repository/
online.rs

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