Skip to main content

jellytau_lib/repository/
online.rs

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