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