1use async_trait::async_trait;
4use log::{debug, error, info, warn};
5use serde::{Deserialize, Serialize};
6use std::sync::{Arc, RwLock};
7
8use super::stream_selection::{
9 quality_options_for_source, PlaybackKind, Rendition, StreamSelection, Transport,
10};
11use super::{types::*, MediaRepository};
12use crate::connectivity::ConnectivityReporter;
13use crate::jellyfin::HttpClient;
14use crate::settings::StreamingQuality;
15use crate::utils::lock::RwLockSafe;
16
17static STREAMING_QUALITY: RwLock<StreamingQuality> = RwLock::new(StreamingQuality::Original);
32
33static PLAYBACK_QUALITY_OVERRIDE: RwLock<Option<StreamingQuality>> = RwLock::new(None);
48
49pub fn set_streaming_quality(quality: StreamingQuality) {
58 *STREAMING_QUALITY.write_safe() = quality;
59}
60
61pub fn streaming_quality() -> StreamingQuality {
69 *STREAMING_QUALITY.read_safe()
70}
71
72pub fn set_playback_quality_override(quality: StreamingQuality) {
76 *PLAYBACK_QUALITY_OVERRIDE.write_safe() = Some(quality);
77}
78
79pub fn clear_playback_quality_override() {
87 *PLAYBACK_QUALITY_OVERRIDE.write_safe() = None;
88}
89
90pub fn playback_quality_override() -> Option<StreamingQuality> {
94 *PLAYBACK_QUALITY_OVERRIDE.read_safe()
95}
96
97pub fn effective_streaming_quality() -> StreamingQuality {
107 playback_quality_override().unwrap_or_else(streaming_quality)
108}
109
110const DEVICE_ID: &str = "jellytau-tauri";
114
115static VIDEO_PLAY_SESSION: RwLock<Option<String>> = RwLock::new(None);
124
125pub fn begin_video_play_session() -> (String, Option<String>) {
138 let new_session = uuid::Uuid::new_v4().to_string();
139 let mut current = VIDEO_PLAY_SESSION.write_safe();
140 let previous = current.replace(new_session.clone());
141 (new_session, previous)
142}
143
144pub fn adopt_video_play_session(session_id: String) -> Option<String> {
154 VIDEO_PLAY_SESSION.write_safe().replace(session_id)
155}
156
157#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
163pub struct JRayActor {
164 pub name: String,
165 #[serde(default)]
166 pub imdb_id: String,
167 #[serde(default)]
168 pub tmdb_id: String,
169 #[serde(default)]
170 pub jellyfin_id: String,
171}
172
173#[derive(Debug, Clone, Deserialize)]
176struct JRayContext {
177 #[serde(default)]
178 actors: Vec<JRayActor>,
179}
180
181pub struct OnlineRepository {
183 http_client: Arc<HttpClient>,
184 server_url: String,
185 user_id: String,
186 access_token: String,
187 connectivity: Option<ConnectivityReporter>,
191}
192
193impl OnlineRepository {
194 pub fn user_id(&self) -> &str {
197 &self.user_id
198 }
199
200 pub fn new(
201 http_client: Arc<HttpClient>,
202 server_url: String,
203 user_id: String,
204 access_token: String,
205 ) -> Self {
206 Self {
207 http_client,
208 server_url,
209 user_id,
210 access_token,
211 connectivity: None,
212 }
213 }
214
215 pub fn with_connectivity(mut self, reporter: ConnectivityReporter) -> Self {
218 self.connectivity = Some(reporter);
219 self
220 }
221
222 async fn report_outcome<T>(&self, result: &Result<T, RepoError>) {
231 let Some(reporter) = &self.connectivity else {
232 return;
233 };
234
235 match result {
236 Ok(_)
237 | Err(RepoError::Authentication { .. })
238 | Err(RepoError::NotFound { .. })
239 | Err(RepoError::Server { .. }) => {
240 reporter.report_success().await;
241 }
242 Err(RepoError::Network { message }) => {
243 reporter.report_network_failure(Some(message.clone())).await;
244 }
245 Err(RepoError::Database { .. }) | Err(RepoError::Offline) => {
246 }
249 }
250 }
251
252 fn auth_header(&self) -> String {
254 HttpClient::build_auth_header(Some(&self.access_token), "jellytau-device")
255 }
256
257 pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
260 let request = self
261 .http_client
262 .client
263 .get(url)
264 .header("X-Emby-Authorization", self.auth_header())
265 .build()
266 .map_err(|e| format!("Failed to build request: {}", e))?;
267
268 let response = self
269 .http_client
270 .request_with_retry(request)
271 .await
272 .map_err(|e| format!("Download failed: {}", e))?;
273
274 if !response.status().is_success() {
275 let status = response.status();
276 let body = response.text().await.unwrap_or_default();
277 let body_preview = if body.len() > 200 {
278 &body[..200]
279 } else {
280 &body
281 };
282 return Err(format!("HTTP {} ({})", status, body_preview.trim()));
283 }
284
285 response
286 .bytes()
287 .await
288 .map(|b| b.to_vec())
289 .map_err(|e| format!("Failed to read bytes: {}", e))
290 }
291
292 pub async fn get_jray_actors(
297 &self,
298 item_id: &str,
299 t: f64,
300 ) -> Result<Vec<JRayActor>, RepoError> {
301 let endpoint = format!(
302 "/Plugins/JRay/Items/{}/jray?t={}",
303 urlencoding::encode(item_id),
304 t
305 );
306 match self.get_json::<JRayContext>(&endpoint).await {
307 Ok(context) => Ok(context.actors),
308 Err(RepoError::NotFound { .. }) => Ok(Vec::new()),
310 Err(e) => Err(e),
311 }
312 }
313
314 async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
316 if let Some(reporter) = &self.connectivity {
322 if !reporter.is_reachable().await {
323 return Err(RepoError::Offline);
324 }
325 }
326
327 let result = self.get_json_inner(endpoint).await;
328 self.report_outcome(&result).await;
329 result
330 }
331
332 async fn get_json_inner<T: for<'de> Deserialize<'de>>(
333 &self,
334 endpoint: &str,
335 ) -> Result<T, RepoError> {
336 let url = format!("{}{}", self.server_url, endpoint);
337
338 let request = self
339 .http_client
340 .client
341 .get(&url)
342 .header("X-Emby-Authorization", self.auth_header())
343 .build()
344 .map_err(|e| RepoError::Network {
345 message: format!("Failed to build request: {}", e),
346 })?;
347
348 let response = self
349 .http_client
350 .request_with_retry(request)
351 .await
352 .map_err(|e| RepoError::Network {
353 message: e.to_string(),
354 })?;
355
356 if !response.status().is_success() {
357 let status = response.status();
358 if status.as_u16() == 401 || status.as_u16() == 403 {
359 return Err(RepoError::Authentication {
360 message: format!("HTTP {}", status),
361 });
362 } else if status.as_u16() == 404 {
363 return Err(RepoError::NotFound {
364 message: "Resource not found".to_string(),
365 });
366 } else {
367 return Err(RepoError::Server {
368 message: format!("HTTP {}", status),
369 });
370 }
371 }
372
373 let text = response.text().await.map_err(|e| RepoError::Server {
375 message: format!("Failed to read response: {}", e),
376 })?;
377
378 serde_json::from_str(&text).map_err(|e| {
380 error!(
381 "[OnlineRepo] Failed to deserialize {} response: {}",
382 endpoint, e
383 );
384 error!(
385 "[OnlineRepo] Response body (first 1000 chars): {}",
386 if text.len() > 1000 {
387 &text[..1000]
388 } else {
389 &text
390 }
391 );
392 RepoError::Server {
393 message: format!("Failed to parse response: {}", e),
394 }
395 })
396 }
397
398 async fn post_json<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
400 let result = self.post_json_inner(endpoint, body).await;
401 self.report_outcome(&result).await;
402 result
403 }
404
405 async fn post_json_inner<T: Serialize>(
406 &self,
407 endpoint: &str,
408 body: &T,
409 ) -> Result<(), RepoError> {
410 let url = format!("{}{}", self.server_url, endpoint);
411
412 let request = self
413 .http_client
414 .client
415 .post(&url)
416 .header("Content-Type", "application/json")
417 .header("X-Emby-Authorization", self.auth_header())
418 .json(body)
419 .build()
420 .map_err(|e| RepoError::Network {
421 message: format!("Failed to build request: {}", e),
422 })?;
423
424 let response = self
425 .http_client
426 .request_with_retry(request)
427 .await
428 .map_err(|e| RepoError::Network {
429 message: e.to_string(),
430 })?;
431
432 if !response.status().is_success() {
433 let status = response.status();
434 if status.as_u16() == 401 || status.as_u16() == 403 {
435 return Err(RepoError::Authentication {
436 message: format!("HTTP {}", status),
437 });
438 } else {
439 return Err(RepoError::Server {
440 message: format!("HTTP {}", status),
441 });
442 }
443 }
444
445 Ok(())
446 }
447
448 async fn post_json_response<T: Serialize, R: for<'de> Deserialize<'de>>(
450 &self,
451 endpoint: &str,
452 body: &T,
453 ) -> Result<R, RepoError> {
454 let result = self.post_json_response_inner(endpoint, body).await;
455 self.report_outcome(&result).await;
456 result
457 }
458
459 async fn post_json_response_inner<T: Serialize, R: for<'de> Deserialize<'de>>(
460 &self,
461 endpoint: &str,
462 body: &T,
463 ) -> Result<R, RepoError> {
464 let url = format!("{}{}", self.server_url, endpoint);
465
466 if let Ok(json) = serde_json::to_string_pretty(body) {
468 debug!("[HTTP] POST {}", endpoint);
469 debug!("[HTTP] Request body:\n{}", json);
470 }
471
472 let request = self
473 .http_client
474 .client
475 .post(&url)
476 .header("Content-Type", "application/json")
477 .header("X-Emby-Authorization", self.auth_header())
478 .json(body)
479 .build()
480 .map_err(|e| RepoError::Network {
481 message: format!("Failed to build request: {}", e),
482 })?;
483
484 let response = self
485 .http_client
486 .request_with_retry(request)
487 .await
488 .map_err(|e| RepoError::Network {
489 message: e.to_string(),
490 })?;
491
492 if !response.status().is_success() {
493 let status = response.status();
494
495 let error_body = response
497 .text()
498 .await
499 .unwrap_or_else(|_| "Failed to read error body".to_string());
500 error!("[HTTP] Error response ({}): {}", status, error_body);
501
502 if status.as_u16() == 401 || status.as_u16() == 403 {
503 return Err(RepoError::Authentication {
504 message: format!("HTTP {}: {}", status, error_body),
505 });
506 } else if status.as_u16() == 404 {
507 return Err(RepoError::NotFound {
508 message: format!("Resource not found: {}", error_body),
509 });
510 } else {
511 return Err(RepoError::Server {
512 message: format!("HTTP {}: {}", status, error_body),
513 });
514 }
515 }
516
517 response.json().await.map_err(|e| RepoError::Server {
518 message: format!("Failed to parse response: {}", e),
519 })
520 }
521
522 async fn stop_transcode(&self, play_session_id: &str) {
532 let url = format!(
533 "{}/Videos/ActiveEncodings?deviceId={}&playSessionId={}",
534 self.server_url, DEVICE_ID, play_session_id
535 );
536
537 let request = self
538 .http_client
539 .client
540 .delete(&url)
541 .header("X-Emby-Authorization", self.auth_header())
542 .send();
543
544 match request.await {
545 Ok(response) if response.status().is_success() => {
546 debug!("[Transcode] Stopped previous encoding {}", play_session_id);
547 }
548 Ok(response) => {
549 debug!(
550 "[Transcode] Server declined to stop encoding {}: HTTP {}",
551 play_session_id,
552 response.status()
553 );
554 }
555 Err(e) => {
556 debug!(
557 "[Transcode] Could not stop encoding {}: {}",
558 play_session_id, e
559 );
560 }
561 }
562 }
563
564 pub async fn get_video_stream_url(
593 &self,
594 item_id: &str,
595 media_source_id: Option<&str>,
596 audio_stream_index: Option<i32>,
597 ) -> Result<String, RepoError> {
598 let quality = effective_streaming_quality();
599 let max_bitrate = quality.max_bitrate().unwrap_or(20_000_000);
603 let video_bitrate = quality.video_bitrate().unwrap_or(18_000_000);
604
605 let (play_session_id, superseded) = begin_video_play_session();
610 if let Some(previous) = superseded {
611 self.stop_transcode(&previous).await;
612 }
613
614 let (renderer_video_codecs, _) = super::device_profile::renderer_codecs();
632 let mut params = vec![
633 ("api_key", self.access_token.clone()),
634 ("DeviceId", DEVICE_ID.to_string()),
635 ("PlaySessionId", play_session_id),
636 ("VideoCodec", renderer_video_codecs),
637 ("AudioCodec", "aac".to_string()),
638 ("MaxStreamingBitrate", max_bitrate.to_string()),
639 ("VideoBitrate", video_bitrate.to_string()),
640 ("AudioBitrate", quality.audio_bitrate().to_string()),
641 (
642 "TranscodingMaxAudioChannels",
643 super::device_profile::max_audio_channels().to_string(),
644 ),
645 ("SegmentContainer", "ts".to_string()),
646 ("TranscodingContainer", "ts".to_string()),
647 ("TranscodingProtocol", "hls".to_string()),
648 (
657 "SubtitleStreamIndex",
658 super::device_profile::playback_subtitle_stream_index().to_string(),
659 ),
660 ];
661
662 if let Some(height) = quality.max_height() {
665 params.push(("MaxHeight", height.to_string()));
666 }
667
668 if let Some(index) = audio_stream_index {
675 params.push(("AudioStreamIndex", index.to_string()));
676 }
677
678 if let Some(source_id) = media_source_id {
679 params.push(("MediaSourceId", source_id.to_string()));
680 }
681
682 let query = params
684 .iter()
685 .map(|(k, v)| format!("{}={}", k, v))
686 .collect::<Vec<_>>()
687 .join("&");
688
689 let url = format!(
690 "{}/Videos/{}/master.m3u8?{}",
691 self.server_url, item_id, query
692 );
693
694 Ok(url)
695 }
696
697 pub async fn build_audio_only_stream_url_for_video(
719 &self,
720 item_id: &str,
721 media_source_id: Option<&str>,
722 start_time_seconds: Option<f64>,
723 audio_stream_index: Option<i32>,
724 ) -> Result<String, RepoError> {
725 let mut params = vec![
726 ("UserId", self.user_id.clone()),
727 ("api_key", self.access_token.clone()),
728 ("DeviceId", DEVICE_ID.to_string()),
729 ("Container", "mp3".to_string()),
731 ("AudioCodec", "mp3".to_string()),
732 ("TranscodingContainer", "mp3".to_string()),
733 ("TranscodingProtocol", "http".to_string()),
734 (
739 "MaxStreamingBitrate",
740 effective_streaming_quality()
741 .audio_bitrate()
742 .min(384_000)
743 .to_string(),
744 ),
745 ];
746
747 if let Some(index) = audio_stream_index {
750 params.push(("AudioStreamIndex", index.to_string()));
751 }
752
753 if let Some(source_id) = media_source_id {
754 params.push(("MediaSourceId", source_id.to_string()));
755 }
756
757 if let Some(seconds) = start_time_seconds {
758 let ticks = (seconds * 10_000_000.0) as i64;
759 params.push(("StartTimeTicks", ticks.to_string()));
760 }
761
762 let query = params
763 .iter()
764 .map(|(k, v)| format!("{}={}", k, v))
765 .collect::<Vec<_>>()
766 .join("&");
767
768 let url = format!("{}/Audio/{}/universal?{}", self.server_url, item_id, query);
769
770 Ok(url)
771 }
772
773 async fn negotiate_playback(
783 &self,
784 item_id: &str,
785 ) -> Result<(NegotiatedSource, String), RepoError> {
786 let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
787
788 let (video_codecs, audio_codecs) = super::device_profile::renderer_codecs();
793
794 let video_audio_codecs = super::device_profile::video_audio_codecs(&audio_codecs);
800
801 info!("[DeviceProfile] Using video codecs: {}", video_codecs);
802 info!("[DeviceProfile] Using audio codecs: {}", audio_codecs);
803 info!(
804 "[DeviceProfile] Audio codecs for video direct play: {}",
805 video_audio_codecs
806 );
807
808 let max_audio_channels = super::device_profile::max_audio_channels().to_string();
812 info!("[DeviceProfile] Max audio channels: {}", max_audio_channels);
813
814 let quality = effective_streaming_quality();
823 let negotiated_bitrate = quality.max_bitrate().unwrap_or(999_999_999) as i64;
824 if let Some(cap) = quality.max_bitrate() {
825 info!(
826 "[DeviceProfile] Streaming quality cap active: {} ({} bps)",
827 quality.label(),
828 cap
829 );
830 }
831
832 let device_profile = DeviceProfile {
834 name: "JellyTau Native Player".to_string(),
835 max_streaming_bitrate: negotiated_bitrate,
836 max_static_bitrate: negotiated_bitrate,
837 max_audio_channels: max_audio_channels.clone(),
838 direct_play_profiles: vec![
839 DirectPlayProfile {
840 profile_type: "Video".to_string(),
841 container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp".to_string(),
842 video_codec: Some(video_codecs.clone()),
843 audio_codec: video_audio_codecs.clone(),
845 },
846 DirectPlayProfile {
847 profile_type: "Audio".to_string(),
848 container: "mp3,aac,flac,alac,wav,ogg,wma,opus".to_string(),
849 video_codec: None,
850 audio_codec: audio_codecs.clone(),
854 },
855 ],
856 transcoding_profiles: vec![
857 TranscodingProfile {
858 profile_type: "Video".to_string(),
859 context: "Streaming".to_string(),
860 protocol: "hls".to_string(),
861 container: "ts".to_string(),
862 video_codec: Some(
875 if video_codecs.contains("hevc") {
876 "h264,hevc"
877 } else {
878 "h264"
879 }
880 .to_string(),
881 ),
882 audio_codec: "aac,mp3".to_string(),
883 max_audio_channels: max_audio_channels.clone(),
884 },
885 TranscodingProfile {
886 profile_type: "Audio".to_string(),
887 context: "Streaming".to_string(),
888 protocol: "http".to_string(),
889 container: "mp3".to_string(),
890 video_codec: None,
891 audio_codec: "mp3".to_string(),
892 max_audio_channels: max_audio_channels.clone(),
893 },
894 ],
895 subtitle_profiles: super::device_profile::subtitle_profiles()
896 .into_iter()
897 .map(|(format, method)| SubtitleProfile {
898 format: format.to_string(),
899 method: method.to_string(),
900 })
901 .collect(),
902 };
903
904 let request_body = PlaybackInfoRequest {
906 user_id: self.user_id.clone(),
907 audio_stream_index: None, subtitle_stream_index: Some(super::device_profile::playback_subtitle_stream_index()),
916 start_time_ticks: 0,
917 is_playback: true,
918 auto_open_live_stream: true,
919 max_streaming_bitrate: quality.max_bitrate().unwrap_or(20_000_000) as i64,
921 device_profile: Some(device_profile), };
923
924 let response: PlaybackInfoResponse =
925 self.post_json_response(&endpoint, &request_body).await?;
926 let source = response
927 .media_sources
928 .into_iter()
929 .next()
930 .ok_or(RepoError::NotFound {
931 message: "No media sources available".to_string(),
932 })?;
933
934 Ok((source, response.play_session_id))
935 }
936
937 pub async fn get_stream_selection(
953 &self,
954 item_id: &str,
955 media_source_id: Option<&str>,
956 audio_stream_index: Option<i32>,
957 ) -> Result<StreamSelection, RepoError> {
958 let (source, play_session_id) = self.negotiate_playback(item_id).await?;
959
960 let quality = effective_streaming_quality();
961 let source_bitrate = source.bitrate.and_then(|b| u64::try_from(b).ok());
962 let available = quality_options_for_source(source_bitrate);
963
964 let audio_streams: Vec<(Option<&str>, bool)> = source
970 .media_streams
971 .iter()
972 .filter(|stream| stream.stream_type == "Audio")
973 .map(|stream| (stream.codec.as_deref(), stream.is_default))
974 .collect();
975 let audio_forces_transcode = super::device_profile::audio_forces_transcode(&audio_streams);
976
977 let track_pinned = audio_stream_index.is_some();
981
982 let effective_source_id = media_source_id.unwrap_or(&source.id).to_string();
983
984 let decided = decide_playback_kind(&source, audio_forces_transcode, track_pinned);
985
986 let selection = match decided {
987 PlaybackKind::Transcode => {
988 let url = if let Some(transcoding_url) = source
989 .transcoding_url
990 .as_deref()
991 .filter(|_| !track_pinned && audio_stream_index.is_none())
992 {
993 if let Some(previous) = adopt_video_play_session(play_session_id.clone()) {
997 self.stop_transcode(&previous).await;
998 }
999 format!(
1003 "{}{}",
1004 self.server_url,
1005 super::device_profile::without_server_chosen_subtitle(transcoding_url)
1006 )
1007 } else {
1008 self.get_video_stream_url(
1012 item_id,
1013 Some(&effective_source_id),
1014 audio_stream_index,
1015 )
1016 .await?
1017 };
1018
1019 StreamSelection {
1020 url,
1021 transport: Transport::Hls,
1026 playback_kind: PlaybackKind::Transcode,
1027 rendition: Some(Rendition {
1028 quality,
1029 max_bitrate: quality.max_bitrate(),
1030 max_height: quality.max_height(),
1031 video_codec: Some("h264".to_string()),
1032 audio_codec: Some("aac".to_string()),
1033 }),
1034 available,
1035 media_source_id: Some(effective_source_id),
1036 play_session_id: Some(play_session_id),
1037 needs_transcoding: PlaybackKind::Transcode.needs_transcoding(),
1038 }
1039 }
1040 kind @ (PlaybackKind::DirectPlay | PlaybackKind::DirectStream) => {
1041 let url = format!(
1046 "{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId={}&api_key={}&userId={}",
1047 self.server_url,
1048 item_id,
1049 effective_source_id,
1050 DEVICE_ID,
1051 self.access_token,
1052 self.user_id
1053 );
1054
1055 StreamSelection {
1056 url,
1057 transport: Transport::Progressive,
1061 playback_kind: kind,
1062 rendition: None,
1066 available,
1067 media_source_id: Some(effective_source_id),
1068 play_session_id: Some(play_session_id),
1069 needs_transcoding: kind.needs_transcoding(),
1070 }
1071 }
1072 };
1073
1074 info!(
1075 "[StreamSelection] {} → {:?} over {:?} (source bitrate {:?}, ceiling {})",
1076 item_id,
1077 selection.playback_kind,
1078 selection.transport,
1079 source_bitrate,
1080 quality.label(),
1081 );
1082
1083 Ok(selection)
1084 }
1085}
1086
1087#[derive(Debug, Deserialize)]
1089#[serde(rename_all = "PascalCase")]
1090struct ItemsResponse {
1091 items: Vec<JellyfinItem>,
1092 total_record_count: usize,
1093}
1094
1095#[derive(Debug, Deserialize)]
1097#[serde(rename_all = "PascalCase")]
1098struct CreatePlaylistResponse {
1099 id: String,
1100}
1101
1102#[derive(Debug, Deserialize)]
1104#[serde(rename_all = "PascalCase")]
1105#[allow(dead_code)]
1106struct PlaylistItemsResponse {
1107 items: Vec<JellyfinPlaylistItem>,
1108 total_record_count: usize,
1109}
1110
1111#[derive(Debug, Deserialize)]
1113#[serde(rename_all = "PascalCase")]
1114struct JellyfinPlaylistItem {
1115 playlist_item_id: String,
1116 #[serde(flatten)]
1117 item: JellyfinItem,
1118}
1119
1120#[derive(Debug, Deserialize)]
1121#[serde(rename_all = "PascalCase")]
1122struct JellyfinItem {
1123 id: String,
1124 name: String,
1125 #[serde(rename = "Type")]
1126 item_type: String,
1127 #[serde(default)]
1128 is_folder: bool,
1129 parent_id: Option<String>,
1130 overview: Option<String>,
1131 genres: Option<Vec<String>>,
1132 production_year: Option<i32>,
1133 premiere_date: Option<String>,
1134 community_rating: Option<f64>,
1135 official_rating: Option<String>,
1136 run_time_ticks: Option<i64>,
1137 image_tags: Option<ImageTags>,
1138 backdrop_image_tags: Option<Vec<String>>,
1139 parent_backdrop_image_tags: Option<Vec<String>>,
1140 album_id: Option<String>,
1141 album: Option<String>,
1142 album_artist: Option<String>,
1143 artists: Option<Vec<String>>,
1144 artist_items: Option<Vec<crate::repository::types::ArtistItem>>,
1145 index_number: Option<i32>,
1146 parent_index_number: Option<i32>,
1147 series_id: Option<String>,
1148 series_name: Option<String>,
1149 season_id: Option<String>,
1150 season_name: Option<String>,
1151 media_streams: Option<Vec<JellyfinMediaStream>>,
1152 media_sources: Option<Vec<JellyfinMediaSource>>,
1153 people: Option<Vec<crate::repository::types::Person>>,
1154 user_data: Option<JellyfinUserData>,
1155}
1156
1157#[derive(Debug, Deserialize, Clone)]
1169#[serde(rename_all = "PascalCase")]
1170struct JellyfinUserData {
1171 playback_position_ticks: Option<i64>,
1172 #[serde(rename = "Played")]
1173 is_played: Option<bool>,
1174 is_favorite: Option<bool>,
1175 play_count: Option<i32>,
1176 last_played_date: Option<String>,
1177}
1178
1179impl From<JellyfinUserData> for UserData {
1180 fn from(jf: JellyfinUserData) -> Self {
1181 UserData {
1182 playback_position_ticks: jf.playback_position_ticks,
1183 playback_position_ms: jf.playback_position_ticks.map(crate::domain::ticks_to_ms),
1184 is_played: jf.is_played,
1185 is_favorite: jf.is_favorite,
1186 play_count: jf.play_count,
1187 last_played_date: jf.last_played_date,
1188 playback_context_type: None,
1189 playback_context_id: None,
1190 }
1191 }
1192}
1193
1194fn build_get_items_endpoint(
1201 user_id: &str,
1202 parent_id: &str,
1203 options: Option<&GetItemsOptions>,
1204) -> String {
1205 let mut endpoint = format!(
1212 "/Users/{}/Items?ParentId={}",
1213 user_id,
1214 urlencoding::encode(parent_id)
1215 );
1216
1217 if let Some(opts) = options {
1218 if let Some(limit) = opts.limit {
1219 endpoint.push_str(&format!("&Limit={}", limit));
1220 }
1221 if let Some(start_index) = opts.start_index {
1222 endpoint.push_str(&format!("&StartIndex={}", start_index));
1223 }
1224 if let Some(types) = &opts.include_item_types {
1225 let encoded: Vec<String> = types
1228 .iter()
1229 .map(|t| urlencoding::encode(t).into_owned())
1230 .collect();
1231 endpoint.push_str(&format!("&IncludeItemTypes={}", encoded.join(",")));
1232 }
1233 let default_sort = default_listing_sort(opts.parent_kind);
1239 let sort_by = opts
1240 .sort_by
1241 .as_deref()
1242 .or(default_sort.map(|(field, _)| field));
1243 let sort_order = opts
1244 .sort_order
1245 .as_deref()
1246 .or(default_sort.map(|(_, order)| order));
1247
1248 if let Some(sort_by) = sort_by {
1249 let encoded: Vec<String> = sort_by
1252 .split(',')
1253 .map(|field| urlencoding::encode(field).into_owned())
1254 .collect();
1255 endpoint.push_str(&format!("&SortBy={}", encoded.join(",")));
1256 }
1257 if let Some(sort_order) = sort_order {
1258 endpoint.push_str(&format!("&SortOrder={}", urlencoding::encode(sort_order)));
1259 }
1260 if let Some(recursive) = opts.recursive {
1261 endpoint.push_str(&format!("&Recursive={}", recursive));
1262 }
1263 if let Some(genres) = &opts.genres {
1264 if !genres.is_empty() {
1265 let encoded: Vec<String> = genres
1267 .iter()
1268 .map(|g| urlencoding::encode(g).into_owned())
1269 .collect();
1270 endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
1271 }
1272 }
1273 if opts.favorites_only == Some(true) {
1275 endpoint.push_str("&Filters=IsFavorite");
1276 }
1277 }
1278
1279 endpoint
1283 .push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
1284 endpoint
1285}
1286
1287fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
1301 format!(
1302 "/Users/{}/Items/Latest?ParentId={}&Limit={}&GroupItems=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1303 user_id,
1304 parent_id,
1305 limit.unwrap_or(16)
1306 )
1307}
1308
1309fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
1323 let mut endpoint = format!(
1324 "/Shows/NextUp?UserId={}&Limit={}&EnableResumable=false&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1325 user_id,
1326 limit.unwrap_or(16)
1327 );
1328
1329 if let Some(sid) = series_id {
1330 endpoint.push_str(&format!("&SeriesId={}", sid));
1331 }
1332
1333 endpoint
1334}
1335
1336fn build_favorites_endpoint(
1346 user_id: &str,
1347 scope: SearchScope,
1348 options: Option<&GetItemsOptions>,
1349) -> String {
1350 let mut endpoint = format!("/Users/{}/Items?Filters=IsFavorite&Recursive=true", user_id);
1351
1352 if let Some(types) = scope.item_types() {
1353 endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
1354 }
1355
1356 let sort_by = options
1359 .and_then(|o| o.sort_by.as_deref())
1360 .unwrap_or("SortName");
1361 let sort_order = options
1362 .and_then(|o| o.sort_order.as_deref())
1363 .unwrap_or("Ascending");
1364 endpoint.push_str(&format!("&SortBy={}&SortOrder={}", sort_by, sort_order));
1365
1366 if let Some(limit) = options.and_then(|o| o.limit) {
1367 endpoint.push_str(&format!("&Limit={}", limit));
1368 }
1369 if let Some(start_index) = options.and_then(|o| o.start_index) {
1370 endpoint.push_str(&format!("&StartIndex={}", start_index));
1371 }
1372
1373 endpoint
1374 .push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
1375 endpoint
1376}
1377
1378#[derive(Debug, Deserialize)]
1381#[serde(untagged)]
1382enum ImageTags {
1383 Map(std::collections::HashMap<String, String>),
1385 Structured {
1387 #[serde(rename = "Primary")]
1388 primary: Option<String>,
1389 },
1390}
1391
1392impl ImageTags {
1393 fn primary(&self) -> Option<String> {
1394 match self {
1395 ImageTags::Map(map) => map.get("Primary").cloned(),
1396 ImageTags::Structured { primary } => primary.clone(),
1397 }
1398 }
1399}
1400
1401#[derive(Debug, Deserialize, Clone)]
1402#[serde(rename_all = "PascalCase")]
1403struct JellyfinMediaStream {
1404 #[serde(rename = "Type")]
1405 stream_type: String,
1406 codec: Option<String>,
1407 language: Option<String>,
1408 display_title: Option<String>,
1409 index: i32,
1410 is_default: bool,
1411 #[serde(default)]
1412 is_forced: bool,
1413}
1414
1415#[derive(Debug, Deserialize, Clone)]
1416#[serde(rename_all = "PascalCase")]
1417struct JellyfinMediaSource {
1418 id: String,
1419 name: String,
1420 container: Option<String>,
1421 size: Option<i64>,
1422 bitrate: Option<i32>,
1423 supports_direct_play: bool,
1424 supports_direct_stream: bool,
1425 supports_transcoding: bool,
1426 direct_stream_url: Option<String>,
1427}
1428
1429impl JellyfinItem {
1430 fn into_media_item(self, server_id: String) -> MediaItem {
1431 let primary_tag = self.image_tags.as_ref().and_then(|tags| tags.primary());
1433 let backdrop_tags = self.backdrop_image_tags;
1434
1435 let kind = crate::domain::kind_from_jellyfin(&self.item_type, self.is_folder);
1436
1437 MediaItem {
1438 id: self.id,
1439 name: self.name,
1440 item_type: self.item_type,
1441 kind,
1442 is_folder: self.is_folder,
1443 server_id,
1444 parent_id: self.parent_id,
1445 library_id: None, overview: self.overview,
1447 genres: self.genres,
1448 production_year: self.production_year,
1449 premiere_date: self.premiere_date,
1450 community_rating: self.community_rating,
1451 official_rating: self.official_rating,
1452 runtime_ticks: self.run_time_ticks,
1453 duration_ms: self.run_time_ticks.map(crate::domain::ticks_to_ms),
1454 primary_image_tag: primary_tag.clone(),
1455 image_id: primary_tag,
1456 backdrop_image_tags: backdrop_tags,
1457 parent_backdrop_image_tags: self.parent_backdrop_image_tags,
1458 album_id: self.album_id,
1459 album_name: self.album,
1460 album_artist: self.album_artist,
1461 artists: self.artists,
1462 artist_items: self.artist_items,
1463 index_number: self.index_number,
1464 parent_index_number: self.parent_index_number,
1465 series_id: self.series_id,
1466 series_name: self.series_name,
1467 season_id: self.season_id,
1468 season_name: self.season_name,
1469 user_data: self.user_data.map(UserData::from),
1472 media_streams: self.media_streams.map(|streams| {
1473 streams
1474 .into_iter()
1475 .map(|s| {
1476 let kind = crate::domain::stream_kind_from_jellyfin(&s.stream_type);
1477 let supports_external_delivery =
1481 (kind == crate::domain::StreamKind::Subtitle).then(|| {
1482 super::device_profile::subtitle_supports_external_delivery(
1483 s.codec.as_deref(),
1484 )
1485 });
1486 crate::repository::types::MediaStream {
1487 kind,
1488 stream_type: s.stream_type,
1489 codec: s.codec,
1490 language: s.language,
1491 display_title: s.display_title,
1492 index: s.index,
1493 is_default: s.is_default,
1494 is_forced: s.is_forced,
1495 supports_external_delivery,
1496 }
1497 })
1498 .collect()
1499 }),
1500 media_sources: self.media_sources.map(|sources| {
1501 sources
1502 .into_iter()
1503 .map(|s| crate::repository::types::MediaSource {
1504 id: s.id,
1505 name: s.name,
1506 container: s.container,
1507 size: s.size,
1508 bitrate: s.bitrate,
1509 supports_direct_play: s.supports_direct_play,
1510 supports_direct_stream: s.supports_direct_stream,
1511 supports_transcoding: s.supports_transcoding,
1512 direct_stream_url: s.direct_stream_url,
1513 })
1514 .collect()
1515 }),
1516 people: self.people,
1517 }
1518 }
1519}
1520
1521#[derive(Debug, Serialize)]
1534#[serde(rename_all = "PascalCase")]
1535struct PlaybackInfoRequest {
1536 user_id: String,
1537 #[serde(skip_serializing_if = "Option::is_none")]
1541 audio_stream_index: Option<i32>,
1542 #[serde(skip_serializing_if = "Option::is_none")]
1543 subtitle_stream_index: Option<i32>,
1544 start_time_ticks: i64,
1545 is_playback: bool,
1546 auto_open_live_stream: bool,
1547 max_streaming_bitrate: i64,
1548 #[serde(skip_serializing_if = "Option::is_none")]
1549 device_profile: Option<DeviceProfile>,
1550}
1551
1552#[derive(Debug, Serialize)]
1553#[serde(rename_all = "PascalCase")]
1554struct DeviceProfile {
1555 name: String,
1556 max_streaming_bitrate: i64,
1557 max_static_bitrate: i64,
1558 max_audio_channels: String,
1562 direct_play_profiles: Vec<DirectPlayProfile>,
1563 transcoding_profiles: Vec<TranscodingProfile>,
1564 subtitle_profiles: Vec<SubtitleProfile>,
1565}
1566
1567#[derive(Debug, Serialize)]
1568#[serde(rename_all = "PascalCase")]
1569struct DirectPlayProfile {
1570 #[serde(rename = "Type")]
1571 profile_type: String,
1572 container: String,
1573 #[serde(skip_serializing_if = "Option::is_none")]
1574 video_codec: Option<String>,
1575 audio_codec: String,
1576}
1577
1578#[derive(Debug, Serialize)]
1579#[serde(rename_all = "PascalCase")]
1580struct TranscodingProfile {
1581 #[serde(rename = "Type")]
1582 profile_type: String,
1583 context: String,
1584 protocol: String,
1585 container: String,
1586 #[serde(skip_serializing_if = "Option::is_none")]
1587 video_codec: Option<String>,
1588 audio_codec: String,
1589 max_audio_channels: String,
1590}
1591
1592#[derive(Debug, Serialize)]
1593#[serde(rename_all = "PascalCase")]
1594struct SubtitleProfile {
1595 format: String,
1596 method: String,
1597}
1598
1599#[derive(Debug, Deserialize)]
1600#[serde(rename_all = "PascalCase")]
1601struct PlaybackInfoResponse {
1602 media_sources: Vec<NegotiatedSource>,
1603 play_session_id: String,
1604}
1605
1606#[derive(Debug, Deserialize)]
1607#[serde(rename_all = "PascalCase")]
1608pub struct NegotiatedSource {
1609 pub id: String,
1610 pub supports_direct_play: bool,
1611 #[serde(default)]
1617 pub supports_direct_stream: bool,
1618 pub supports_transcoding: bool,
1619 pub transcoding_url: Option<String>,
1620 #[serde(default)]
1627 pub bitrate: Option<i64>,
1628 #[serde(default)]
1629 pub media_streams: Vec<NegotiatedStream>,
1630}
1631
1632#[derive(Debug, Deserialize)]
1633#[serde(rename_all = "PascalCase")]
1634pub struct NegotiatedStream {
1635 #[serde(rename = "Type")]
1636 stream_type: String,
1637 #[serde(default)]
1638 index: i32,
1639 #[serde(default)]
1640 codec: Option<String>,
1641 #[serde(default)]
1643 is_default: bool,
1644}
1645
1646pub fn decide_playback_kind(
1660 source: &NegotiatedSource,
1661 audio_forces_transcode: bool,
1662 audio_track_pinned: bool,
1663) -> PlaybackKind {
1664 if audio_forces_transcode {
1665 warn!(
1666 "[StreamSelection] Server offered direct play for audio this renderer cannot decode — forcing a transcode"
1667 );
1668 return PlaybackKind::Transcode;
1669 }
1670 if audio_track_pinned {
1671 return PlaybackKind::Transcode;
1674 }
1675 if source.supports_direct_play {
1676 PlaybackKind::DirectPlay
1677 } else if source.supports_direct_stream {
1678 PlaybackKind::DirectStream
1679 } else {
1680 PlaybackKind::Transcode
1681 }
1682}
1683
1684#[async_trait]
1685impl MediaRepository for OnlineRepository {
1686 async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
1687 #[derive(Debug, Deserialize)]
1688 #[serde(rename_all = "PascalCase")]
1689 struct LibrariesResponse {
1690 items: Vec<JellyfinLibrary>,
1691 }
1692
1693 #[derive(Debug, Deserialize)]
1694 #[serde(rename_all = "PascalCase")]
1695 struct JellyfinLibrary {
1696 id: String,
1697 name: String,
1698 collection_type: Option<String>,
1699 image_tags: Option<ImageTags>,
1700 }
1701
1702 let endpoint = format!("/Users/{}/Views", self.user_id);
1703 let response: LibrariesResponse = self.get_json(&endpoint).await?;
1704
1705 Ok(response
1706 .items
1707 .into_iter()
1708 .map(|lib| {
1709 Library::new(
1710 lib.id,
1711 lib.name,
1712 lib.collection_type.unwrap_or_else(|| "unknown".to_string()),
1713 lib.image_tags.and_then(|tags| tags.primary()),
1714 )
1715 })
1716 .collect())
1717 }
1718
1719 async fn get_items(
1720 &self,
1721 parent_id: &str,
1722 options: Option<GetItemsOptions>,
1723 ) -> Result<SearchResult, RepoError> {
1724 let endpoint = build_get_items_endpoint(&self.user_id, parent_id, options.as_ref());
1725
1726 let response: ItemsResponse = self.get_json(&endpoint).await?;
1727
1728 Ok(SearchResult {
1729 items: response
1730 .items
1731 .into_iter()
1732 .map(|item| item.into_media_item(self.user_id.clone()))
1733 .collect(),
1734 total_record_count: response.total_record_count,
1735 })
1736 }
1737
1738 async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
1750 let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, urlencoding::encode(item_id));
1751
1752 let item: JellyfinItem = self.get_json(&endpoint).await?;
1753 let media_item = item.into_media_item(self.user_id.clone());
1754
1755 Ok(media_item)
1756 }
1757
1758 async fn get_latest_items(
1759 &self,
1760 parent_id: &str,
1761 limit: Option<usize>,
1762 ) -> Result<Vec<MediaItem>, RepoError> {
1763 let endpoint = build_latest_items_endpoint(&self.user_id, parent_id, limit);
1764
1765 let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
1766 Ok(items
1767 .into_iter()
1768 .map(|item| item.into_media_item(self.user_id.clone()))
1769 .collect())
1770 }
1771
1772 async fn get_resume_items(
1781 &self,
1782 parent_id: Option<&str>,
1783 limit: Option<usize>,
1784 ) -> Result<Vec<MediaItem>, RepoError> {
1785 let limit_str = limit.unwrap_or(16);
1786 let mut endpoint = format!(
1787 "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1788 self.user_id, limit_str
1789 );
1790
1791 if let Some(pid) = parent_id {
1792 endpoint.push_str(&format!("&ParentId={}", pid));
1793 }
1794
1795 let response: ItemsResponse = self.get_json(&endpoint).await?;
1796 Ok(response
1797 .items
1798 .into_iter()
1799 .map(|item| item.into_media_item(self.user_id.clone()))
1800 .collect())
1801 }
1802
1803 async fn get_next_up_episodes(
1808 &self,
1809 series_id: Option<&str>,
1810 limit: Option<usize>,
1811 ) -> Result<Vec<MediaItem>, RepoError> {
1812 let endpoint = build_next_up_endpoint(&self.user_id, series_id, limit);
1813
1814 let response: ItemsResponse = self.get_json(&endpoint).await?;
1815 Ok(response
1816 .items
1817 .into_iter()
1818 .map(|item| item.into_media_item(self.user_id.clone()))
1819 .collect())
1820 }
1821
1822 async fn get_recently_played_audio(
1823 &self,
1824 limit: Option<usize>,
1825 ) -> Result<Vec<MediaItem>, RepoError> {
1826 let limit_val = limit.unwrap_or(12);
1827 let fetch_limit = limit_val * 3;
1829 let endpoint = format!(
1830 "/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1831 self.user_id, fetch_limit
1832 );
1833
1834 let response: ItemsResponse = self.get_json(&endpoint).await?;
1835 let items: Vec<MediaItem> = response
1836 .items
1837 .into_iter()
1838 .map(|item| item.into_media_item(self.user_id.clone()))
1839 .collect();
1840
1841 debug!("[get_recently_played_audio] Fetched {} items", items.len());
1842 for item in &items {
1843 debug!("[get_recently_played_audio] Item: name={}, type={}, album_id={:?}, album_name={:?}",
1844 item.name, item.item_type, item.album_id, item.album_name);
1845 }
1846
1847 use std::collections::BTreeMap;
1849 let mut album_map: BTreeMap<String, Vec<MediaItem>> = BTreeMap::new();
1850 let mut ungrouped = Vec::new();
1851
1852 for item in items {
1853 let group_key = item.album_id.clone().or_else(|| item.album_name.clone());
1855
1856 if let Some(key) = group_key {
1857 debug!(
1858 "[get_recently_played_audio] Grouping item '{}' into album '{}'",
1859 item.name, key
1860 );
1861 album_map.entry(key).or_default().push(item);
1862 } else {
1863 debug!(
1864 "[get_recently_played_audio] No album_id or album_name for item: '{}'",
1865 item.name
1866 );
1867 ungrouped.push(item);
1868 }
1869 }
1870
1871 let mut result: Vec<MediaItem> = album_map
1873 .into_iter()
1874 .map(|(album_id, tracks)| {
1875 let first_track = &tracks[0];
1876 let most_recent = tracks
1877 .iter()
1878 .max_by(|a, b| {
1879 let date_a = a
1880 .user_data
1881 .as_ref()
1882 .and_then(|ud| ud.last_played_date.as_deref())
1883 .unwrap_or("");
1884 let date_b = b
1885 .user_data
1886 .as_ref()
1887 .and_then(|ud| ud.last_played_date.as_deref())
1888 .unwrap_or("");
1889 date_b.cmp(date_a)
1890 })
1891 .unwrap_or(first_track);
1892
1893 MediaItem {
1894 id: album_id,
1895 name: first_track
1896 .album_name
1897 .clone()
1898 .unwrap_or_else(|| "Unknown Album".to_string()),
1899 item_type: "MusicAlbum".to_string(),
1900 kind: crate::domain::MediaKind::Album,
1901 is_folder: true,
1902 server_id: first_track.server_id.clone(),
1903 parent_id: None,
1904 library_id: None,
1905 overview: None,
1906 genres: None,
1907 production_year: None,
1908 premiere_date: None,
1909 community_rating: None,
1910 official_rating: None,
1911 runtime_ticks: None,
1912 duration_ms: None,
1913 primary_image_tag: first_track.primary_image_tag.clone(),
1914 image_id: first_track.primary_image_tag.clone(),
1915 backdrop_image_tags: None,
1916 parent_backdrop_image_tags: None,
1917 album_id: None,
1918 album_name: None,
1919 album_artist: None,
1920 artists: first_track.artists.clone(),
1921 artist_items: first_track.artist_items.clone(),
1922 index_number: None,
1923 parent_index_number: None,
1924 series_id: None,
1925 series_name: None,
1926 season_id: None,
1927 season_name: None,
1928 user_data: most_recent.user_data.clone(),
1929 media_streams: None,
1930 media_sources: None,
1931 people: None,
1932 }
1933 })
1934 .collect();
1935
1936 result.extend(ungrouped);
1938
1939 let final_result: Vec<MediaItem> = result.into_iter().take(limit_val).collect();
1941 debug!(
1942 "[get_recently_played_audio] Returning {} items after grouping",
1943 final_result.len()
1944 );
1945 for item in &final_result {
1946 debug!(
1947 "[get_recently_played_audio] Return: name={}, type={}",
1948 item.name, item.item_type
1949 );
1950 }
1951 Ok(final_result)
1952 }
1953
1954 async fn get_rediscover_albums(
1955 &self,
1956 parent_id: Option<&str>,
1957 limit: Option<usize>,
1958 ) -> Result<Vec<MediaItem>, RepoError> {
1959 let limit_val = limit.unwrap_or(12);
1960 let mut endpoint = format!(
1964 "/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1965 self.user_id, limit_val
1966 );
1967
1968 if let Some(pid) = parent_id {
1969 endpoint.push_str(&format!("&ParentId={}", pid));
1970 }
1971
1972 let response: ItemsResponse = self.get_json(&endpoint).await?;
1973 Ok(response
1974 .items
1975 .into_iter()
1976 .map(|item| item.into_media_item(self.user_id.clone()))
1977 .collect())
1978 }
1979
1980 async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
1986 let limit_str = limit.unwrap_or(16);
1987 let endpoint = format!(
1988 "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1989 self.user_id, limit_str
1990 );
1991
1992 let response: ItemsResponse = self.get_json(&endpoint).await?;
1993 Ok(response
1994 .items
1995 .into_iter()
1996 .map(|item| item.into_media_item(self.user_id.clone()))
1997 .collect())
1998 }
1999
2000 async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
2001 let mut endpoint = format!(
2004 "/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
2005 self.user_id
2006 );
2007
2008 if let Some(pid) = parent_id {
2009 endpoint.push_str(&format!("&ParentId={}", pid));
2010 }
2011
2012 #[derive(Debug, Deserialize)]
2013 #[serde(rename_all = "PascalCase")]
2014 struct GenresResponse {
2015 items: Vec<JellyfinGenre>,
2016 }
2017
2018 #[derive(Debug, Deserialize)]
2019 #[serde(rename_all = "PascalCase")]
2020 struct JellyfinGenre {
2021 id: String,
2022 name: String,
2023 album_count: Option<u32>,
2029 child_count: Option<u32>,
2030 }
2031
2032 let response: GenresResponse = self.get_json(&endpoint).await?;
2033 let genres: Vec<Genre> = response
2034 .items
2035 .into_iter()
2036 .map(|g| Genre {
2037 id: g.id,
2038 name: g.name,
2039 album_count: g.album_count.or(g.child_count),
2040 })
2041 .collect();
2042
2043 let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
2044 log::warn!(
2047 "get_genres: {} genres, {} carry counts. sample: {:?}",
2048 genres.len(),
2049 with_counts,
2050 genres
2051 .iter()
2052 .take(8)
2053 .map(|g| (g.name.as_str(), g.album_count))
2054 .collect::<Vec<_>>()
2055 );
2056
2057 Ok(genres)
2058 }
2059
2060 async fn search(
2069 &self,
2070 query: &str,
2071 options: Option<SearchOptions>,
2072 ) -> Result<SearchResult, RepoError> {
2073 let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50);
2074 let mut endpoint = format!(
2078 "/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
2079 self.user_id,
2080 urlencoding::encode(query),
2081 limit
2082 );
2083
2084 if let Some(opts) = options {
2085 if let Some(types) = opts.include_item_types {
2086 let encoded_types = types
2087 .iter()
2088 .map(|t| urlencoding::encode(t).into_owned())
2089 .collect::<Vec<_>>()
2090 .join(",");
2091 endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
2092 }
2093 }
2094
2095 endpoint.push_str(
2098 "&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData",
2099 );
2100
2101 let response: ItemsResponse = self.get_json(&endpoint).await?;
2102 Ok(SearchResult {
2103 items: response
2104 .items
2105 .into_iter()
2106 .map(|item| item.into_media_item(self.user_id.clone()))
2107 .collect(),
2108 total_record_count: response.total_record_count,
2109 })
2110 }
2111
2112 async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
2113 let (source, play_session_id) = self.negotiate_playback(item_id).await?;
2114
2115 info!(
2117 "PlaybackInfo MediaSource has {} streams",
2118 source.media_streams.len()
2119 );
2120 for stream in &source.media_streams {
2121 info!(
2122 " Stream type={}, index={}, codec={:?}",
2123 stream.stream_type, stream.index, stream.codec
2124 );
2125 }
2126
2127 for stream in &source.media_streams {
2132 if stream.stream_type == "Subtitle" {
2133 if let Some(codec) = stream.codec.as_deref() {
2134 if super::device_profile::subtitle_forces_burn_in(codec) {
2135 info!(
2136 " Subtitle index={} ({}) is image-based — not requested; the app renders text tracks itself rather than have the server burn it in (which would force a video re-encode)",
2137 stream.index, codec
2138 );
2139 }
2140 }
2141 }
2142 }
2143
2144 let audio_streams: Vec<(Option<&str>, bool)> = source
2150 .media_streams
2151 .iter()
2152 .filter(|stream| stream.stream_type == "Audio")
2153 .map(|stream| (stream.codec.as_deref(), stream.is_default))
2154 .collect();
2155 let audio_forces_transcode = super::device_profile::audio_forces_transcode(&audio_streams);
2156
2157 let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
2159 if let Some(previous) = adopt_video_play_session(play_session_id.clone()) {
2163 self.stop_transcode(&previous).await;
2164 }
2165 format!(
2171 "{}{}",
2172 self.server_url,
2173 super::device_profile::without_server_chosen_subtitle(transcoding_url)
2174 )
2175 } else if audio_forces_transcode {
2176 warn!(
2177 "[PlaybackInfo] Server offered direct play for audio the webview cannot decode ({:?}) — forcing an HLS transcode",
2178 audio_streams.first().and_then(|(codec, _)| *codec)
2179 );
2180 self.get_video_stream_url(item_id, Some(&source.id), None)
2181 .await?
2182 } else {
2183 format!(
2187 "{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&userId={}",
2188 self.server_url,
2189 item_id,
2190 source.id,
2191 self.access_token,
2192 self.user_id
2193 )
2194 };
2195
2196 info!("Final stream URL: {}", stream_url);
2197
2198 Ok(PlaybackInfo {
2199 media_source_id: source.id.clone(),
2200 play_session_id,
2201 stream_url,
2202 direct_play: source.supports_direct_play && !audio_forces_transcode,
2203 needs_transcoding: audio_forces_transcode
2204 || (!source.supports_direct_play && source.supports_transcoding),
2205 })
2206 }
2207
2208 async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
2209 let url = format!(
2211 "{}/Audio/{}/stream?UserId={}&api_key={}&Static=true",
2212 self.server_url, item_id, self.user_id, self.access_token
2213 );
2214 Ok(url)
2215 }
2216
2217 async fn get_audio_only_stream_url_for_video(
2218 &self,
2219 item_id: &str,
2220 media_source_id: Option<&str>,
2221 start_time_seconds: Option<f64>,
2222 audio_stream_index: Option<i32>,
2223 ) -> Result<String, RepoError> {
2224 self.build_audio_only_stream_url_for_video(
2225 item_id,
2226 media_source_id,
2227 start_time_seconds,
2228 audio_stream_index,
2229 )
2230 .await
2231 }
2232
2233 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
2234 let endpoint = format!(
2237 "/LiveTv/Channels?UserId={}&Fields=PrimaryImageAspectRatio,Overview&EnableImageTypes=Primary",
2238 self.user_id
2239 );
2240 let response: ItemsResponse = self.get_json(&endpoint).await?;
2241 Ok(response
2242 .items
2243 .into_iter()
2244 .map(|item| item.into_media_item(self.server_url.clone()))
2245 .collect())
2246 }
2247
2248 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
2249 let endpoint = format!("/Channels?UserId={}", self.user_id);
2252 let response: ItemsResponse = self.get_json(&endpoint).await?;
2253 let total = response.total_record_count;
2254 let items = response
2255 .items
2256 .into_iter()
2257 .map(|item| item.into_media_item(self.server_url.clone()))
2258 .collect();
2259 Ok(SearchResult {
2260 items,
2261 total_record_count: total,
2262 })
2263 }
2264
2265 async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
2266 #[derive(Debug, Serialize)]
2270 #[serde(rename_all = "PascalCase")]
2271 struct OpenLiveStreamRequest {
2272 user_id: String,
2273 #[serde(rename = "AutoOpenLiveStream")]
2274 auto_open_live_stream: bool,
2275 is_playback: bool,
2276 max_streaming_bitrate: u64,
2277 subtitle_stream_index: i32,
2284 }
2285
2286 #[derive(Debug, Deserialize)]
2287 #[serde(rename_all = "PascalCase")]
2288 struct OpenLiveStreamResponse {
2289 #[serde(default)]
2290 media_sources: Vec<LiveMediaSource>,
2291 play_session_id: Option<String>,
2292 }
2293
2294 #[derive(Debug, Deserialize)]
2295 #[serde(rename_all = "PascalCase")]
2296 struct LiveMediaSource {
2297 id: String,
2298 transcoding_url: Option<String>,
2299 live_stream_id: Option<String>,
2300 }
2301
2302 let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
2303 let request = OpenLiveStreamRequest {
2304 user_id: self.user_id.clone(),
2305 auto_open_live_stream: true,
2306 is_playback: true,
2307 max_streaming_bitrate: effective_streaming_quality()
2311 .max_bitrate()
2312 .unwrap_or(20_000_000),
2313 subtitle_stream_index: super::device_profile::playback_subtitle_stream_index(),
2314 };
2315
2316 let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?;
2317
2318 let source = response
2319 .media_sources
2320 .into_iter()
2321 .next()
2322 .ok_or(RepoError::NotFound {
2323 message: "No live media source returned".to_string(),
2324 })?;
2325
2326 let stream_url = match source.transcoding_url {
2329 Some(url) => format!(
2332 "{}{}",
2333 self.server_url,
2334 super::device_profile::without_server_chosen_subtitle(&url)
2335 ),
2336 None => format!(
2337 "{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts&SubtitleStreamIndex={}",
2338 self.server_url,
2339 item_id,
2340 self.access_token,
2341 source.id,
2342 source.live_stream_id.clone().unwrap_or_default(),
2343 super::device_profile::playback_subtitle_stream_index(),
2344 ),
2345 };
2346
2347 Ok(LiveStreamInfo {
2348 stream_url,
2349 play_session_id: response.play_session_id,
2350 live_stream_id: source.live_stream_id,
2351 media_source_id: Some(source.id),
2352 transport: Transport::Hls,
2355 })
2356 }
2357
2358 async fn report_playback_start(
2359 &self,
2360 item_id: &str,
2361 position_ticks: i64,
2362 ) -> Result<(), RepoError> {
2363 #[derive(Serialize)]
2364 #[serde(rename_all = "PascalCase")]
2365 struct PlaybackStartRequest {
2366 item_id: String,
2367 position_ticks: i64,
2368 play_command: String,
2369 is_paused: bool,
2370 }
2371
2372 let request = PlaybackStartRequest {
2373 item_id: item_id.to_string(),
2374 position_ticks,
2375 play_command: "PlayNow".to_string(),
2376 is_paused: false,
2377 };
2378
2379 self.post_json("/Sessions/Playing", &request).await
2380 }
2381
2382 async fn report_playback_progress(
2383 &self,
2384 item_id: &str,
2385 position_ticks: i64,
2386 ) -> Result<(), RepoError> {
2387 #[derive(Serialize)]
2388 #[serde(rename_all = "PascalCase")]
2389 struct PlaybackProgressRequest {
2390 item_id: String,
2391 position_ticks: i64,
2392 is_paused: bool,
2393 }
2394
2395 let request = PlaybackProgressRequest {
2396 item_id: item_id.to_string(),
2397 position_ticks,
2398 is_paused: false,
2399 };
2400
2401 self.post_json("/Sessions/Playing/Progress", &request).await
2402 }
2403
2404 async fn report_playback_stopped(
2405 &self,
2406 item_id: &str,
2407 position_ticks: i64,
2408 ) -> Result<(), RepoError> {
2409 #[derive(Serialize)]
2410 #[serde(rename_all = "PascalCase")]
2411 struct PlaybackStoppedRequest {
2412 item_id: String,
2413 position_ticks: i64,
2414 }
2415
2416 let request = PlaybackStoppedRequest {
2417 item_id: item_id.to_string(),
2418 position_ticks,
2419 };
2420
2421 self.post_json("/Sessions/Playing/Stopped", &request).await
2422 }
2423
2424 fn get_image_url(
2425 &self,
2426 item_id: &str,
2427 image_type: ImageType,
2428 options: Option<ImageOptions>,
2429 ) -> String {
2430 let mut url = format!(
2431 "{}/Items/{}/Images/{}",
2432 self.server_url,
2433 item_id,
2434 image_type.as_str()
2435 );
2436
2437 let mut params: Vec<String> = Vec::new();
2441
2442 if let Some(opts) = options {
2443 if let Some(width) = opts.max_width {
2444 params.push(format!("maxWidth={}", width));
2445 }
2446 if let Some(height) = opts.max_height {
2447 params.push(format!("maxHeight={}", height));
2448 }
2449 if let Some(quality) = opts.quality {
2450 params.push(format!("quality={}", quality));
2451 }
2452 if let Some(tag) = opts.tag {
2453 params.push(format!("tag={}", tag));
2454 }
2455 }
2456
2457 if !params.is_empty() {
2458 url.push('?');
2459 url.push_str(¶ms.join("&"));
2460 }
2461
2462 url
2463 }
2464
2465 fn get_subtitle_url(
2466 &self,
2467 item_id: &str,
2468 media_source_id: &str,
2469 stream_index: i32,
2470 format: &str,
2471 ) -> String {
2472 format!(
2480 "{}/Videos/{}/{}/Subtitles/{}/Stream.{}",
2481 self.server_url, item_id, media_source_id, stream_index, format
2482 )
2483 }
2484
2485 fn get_video_download_url(
2487 &self,
2488 item_id: &str,
2489 quality: &str,
2490 media_source_id: Option<&str>,
2491 source_audio_codec: Option<&str>,
2492 ) -> String {
2493 let mut url = format!("{}/Videos/{}/stream.mp4", self.server_url, item_id);
2499 let mut params = vec![format!("api_key={}", self.access_token)];
2500
2501 match quality {
2518 "high" => {
2519 params.push("videoBitRate=8000000".to_string());
2520 params.push("maxHeight=1080".to_string());
2521 params.push("audioBitRate=384000".to_string());
2522 params.push("videoCodec=h264".to_string());
2523 params.push("audioCodec=aac".to_string());
2524 params.push("allowVideoStreamCopy=false".to_string());
2525 }
2526 "medium" => {
2527 params.push("videoBitRate=4000000".to_string());
2528 params.push("maxHeight=720".to_string());
2529 params.push("audioBitRate=256000".to_string());
2530 params.push("videoCodec=h264".to_string());
2531 params.push("audioCodec=aac".to_string());
2532 params.push("allowVideoStreamCopy=false".to_string());
2533 }
2534 "low" => {
2535 params.push("videoBitRate=1500000".to_string());
2536 params.push("maxHeight=480".to_string());
2537 params.push("audioBitRate=128000".to_string());
2538 params.push("videoCodec=h264".to_string());
2539 params.push("audioCodec=aac".to_string());
2540 params.push("allowVideoStreamCopy=false".to_string());
2541 }
2542 _ => match source_audio_codec {
2564 Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
2565 params.push("videoCodec=h264".to_string());
2566 params.push("allowVideoStreamCopy=true".to_string());
2567 params.push("audioCodec=aac".to_string());
2568 params.push("audioBitRate=384000".to_string());
2569 }
2570 _ => params.push("Static=true".to_string()),
2574 },
2575 }
2576
2577 if let Some(source_id) = media_source_id {
2579 params.push(format!("mediaSourceId={}", source_id));
2580 }
2581
2582 url.push('?');
2583 url.push_str(¶ms.join("&"));
2584
2585 url
2586 }
2587
2588 async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
2589 let endpoint = format!(
2590 "/Users/{}/FavoriteItems/{}",
2591 self.user_id,
2592 urlencoding::encode(item_id)
2593 );
2594 self.post_json(&endpoint, &serde_json::json!({})).await
2595 }
2596
2597 async fn get_favorites(
2599 &self,
2600 scope: SearchScope,
2601 options: Option<GetItemsOptions>,
2602 ) -> Result<SearchResult, RepoError> {
2603 let endpoint = build_favorites_endpoint(&self.user_id, scope, options.as_ref());
2604 let response: ItemsResponse = self.get_json(&endpoint).await?;
2605
2606 Ok(SearchResult {
2607 items: response
2608 .items
2609 .into_iter()
2610 .map(|item| item.into_media_item(self.user_id.clone()))
2611 .collect(),
2612 total_record_count: response.total_record_count,
2613 })
2614 }
2615
2616 async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
2623 let endpoint = format!(
2624 "/Users/{}/FavoriteItems/{}",
2625 self.user_id,
2626 urlencoding::encode(item_id)
2627 );
2628 let url = format!("{}{}", self.server_url, endpoint);
2629
2630 let result = async {
2631 let request = self
2632 .http_client
2633 .client
2634 .delete(&url)
2635 .header("X-Emby-Authorization", self.auth_header())
2636 .build()
2637 .map_err(|e| RepoError::Network {
2638 message: format!("Failed to build request: {}", e),
2639 })?;
2640
2641 let response = self
2642 .http_client
2643 .request_with_retry(request)
2644 .await
2645 .map_err(|e| RepoError::Network {
2646 message: e.to_string(),
2647 })?;
2648
2649 if !response.status().is_success() {
2650 return Err(RepoError::Server {
2651 message: format!("HTTP {}", response.status()),
2652 });
2653 }
2654
2655 Ok(())
2656 }
2657 .await;
2658
2659 self.report_outcome(&result).await;
2660 result
2661 }
2662
2663 async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
2669 let endpoint = format!(
2670 "/Users/{}/PlayedItems/{}",
2671 self.user_id,
2672 urlencoding::encode(item_id)
2673 );
2674 let url = format!("{}{}", self.server_url, endpoint);
2675
2676 let result = async {
2677 let request = self
2678 .http_client
2679 .client
2680 .delete(&url)
2681 .header("X-Emby-Authorization", self.auth_header())
2682 .build()
2683 .map_err(|e| RepoError::Network {
2684 message: format!("Failed to build request: {}", e),
2685 })?;
2686
2687 let response = self
2688 .http_client
2689 .request_with_retry(request)
2690 .await
2691 .map_err(|e| RepoError::Network {
2692 message: e.to_string(),
2693 })?;
2694
2695 if !response.status().is_success() {
2696 return Err(RepoError::Server {
2697 message: format!("HTTP {}", response.status()),
2698 });
2699 }
2700
2701 Ok(())
2702 }
2703 .await;
2704
2705 self.report_outcome(&result).await;
2706 result
2707 }
2708
2709 async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
2714 let endpoint = format!(
2715 "/Users/{}/PlayedItems/{}",
2716 self.user_id,
2717 urlencoding::encode(item_id)
2718 );
2719 let url = format!("{}{}", self.server_url, endpoint);
2720
2721 let result = async {
2722 let request = self
2723 .http_client
2724 .client
2725 .post(&url)
2726 .header("X-Emby-Authorization", self.auth_header())
2727 .header("Content-Length", "0")
2728 .build()
2729 .map_err(|e| RepoError::Network {
2730 message: format!("Failed to build request: {}", e),
2731 })?;
2732
2733 let response = self
2734 .http_client
2735 .request_with_retry(request)
2736 .await
2737 .map_err(|e| RepoError::Network {
2738 message: e.to_string(),
2739 })?;
2740
2741 if !response.status().is_success() {
2742 return Err(RepoError::Server {
2743 message: format!("HTTP {}", response.status()),
2744 });
2745 }
2746
2747 Ok(())
2748 }
2749 .await;
2750
2751 self.report_outcome(&result).await;
2752 result
2753 }
2754
2755 async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
2763 let endpoint = format!(
2764 "/Users/{}/Items/{}",
2765 self.user_id,
2766 urlencoding::encode(person_id)
2767 );
2768 let item: JellyfinItem = self.get_json(&endpoint).await?;
2769 Ok(item.into_media_item(self.user_id.clone()))
2770 }
2771
2772 async fn get_items_by_person(
2776 &self,
2777 person_id: &str,
2778 options: Option<GetItemsOptions>,
2779 ) -> Result<SearchResult, RepoError> {
2780 let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
2781
2782 let mut endpoint = format!(
2783 "/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
2784 self.user_id, person_id, limit
2785 );
2786
2787 if let Some(ref opts) = options {
2789 if let Some(ref include_types) = opts.include_item_types {
2790 if !include_types.is_empty() {
2791 let types_param = include_types.join(",");
2792 endpoint.push_str(&format!("&IncludeItemTypes={}", types_param));
2793 }
2794 }
2795 }
2796
2797 let response: ItemsResponse = self.get_json(&endpoint).await?;
2798 Ok(SearchResult {
2799 items: response
2800 .items
2801 .into_iter()
2802 .map(|item| item.into_media_item(self.user_id.clone()))
2803 .collect(),
2804 total_record_count: response.total_record_count,
2805 })
2806 }
2807
2808 async fn get_similar_items(
2809 &self,
2810 item_id: &str,
2811 limit: Option<usize>,
2812 ) -> Result<SearchResult, RepoError> {
2813 let limit_str = limit.unwrap_or(20);
2814
2815 let endpoint = format!(
2817 "/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
2818 item_id, self.user_id, limit_str
2819 );
2820
2821 let response: ItemsResponse = self.get_json(&endpoint).await?;
2822 Ok(SearchResult {
2823 items: response
2824 .items
2825 .into_iter()
2826 .map(|item| item.into_media_item(self.user_id.clone()))
2827 .collect(),
2828 total_record_count: response.total_record_count,
2829 })
2830 }
2831
2832 async fn create_playlist(
2835 &self,
2836 name: &str,
2837 item_ids: &[String],
2838 ) -> Result<PlaylistCreatedResult, RepoError> {
2839 info!(
2840 "[OnlineRepo] Creating playlist '{}' with {} items",
2841 name,
2842 item_ids.len()
2843 );
2844 let body = serde_json::json!({
2845 "Name": name,
2846 "Ids": item_ids,
2847 "MediaType": "Audio",
2848 "UserId": self.user_id,
2849 });
2850 let response: CreatePlaylistResponse = self.post_json_response("/Playlists", &body).await?;
2851 Ok(PlaylistCreatedResult { id: response.id })
2852 }
2853
2854 async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
2855 info!("[OnlineRepo] Deleting playlist {}", playlist_id);
2856 let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
2857 let url = format!("{}{}", self.server_url, endpoint);
2858
2859 let request = self
2860 .http_client
2861 .client
2862 .delete(&url)
2863 .header("X-Emby-Authorization", self.auth_header())
2864 .build()
2865 .map_err(|e| RepoError::Network {
2866 message: format!("Failed to build request: {}", e),
2867 })?;
2868
2869 let response = self
2870 .http_client
2871 .request_with_retry(request)
2872 .await
2873 .map_err(|e| RepoError::Network {
2874 message: e.to_string(),
2875 })?;
2876
2877 if !response.status().is_success() {
2878 return Err(RepoError::Server {
2879 message: format!("HTTP {}", response.status()),
2880 });
2881 }
2882
2883 Ok(())
2884 }
2885
2886 async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
2887 info!(
2888 "[OnlineRepo] Renaming playlist {} to '{}'",
2889 playlist_id, name
2890 );
2891 let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
2892 self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
2893 .await
2894 }
2895
2896 async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
2897 let endpoint = format!(
2898 "/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000",
2899 playlist_id, self.user_id
2900 );
2901
2902 let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
2903 debug!(
2904 "[OnlineRepo] Got {} playlist items for {}",
2905 response.items.len(),
2906 playlist_id
2907 );
2908
2909 Ok(response
2910 .items
2911 .into_iter()
2912 .map(|pi| PlaylistEntry {
2913 playlist_item_id: pi.playlist_item_id,
2914 item: pi.item.into_media_item(self.user_id.clone()),
2915 })
2916 .collect())
2917 }
2918
2919 async fn add_to_playlist(
2920 &self,
2921 playlist_id: &str,
2922 item_ids: &[String],
2923 ) -> Result<(), RepoError> {
2924 info!(
2925 "[OnlineRepo] Adding {} items to playlist {}",
2926 item_ids.len(),
2927 playlist_id
2928 );
2929 let ids_param = item_ids
2931 .iter()
2932 .map(|id| urlencoding::encode(id).into_owned())
2933 .collect::<Vec<_>>()
2934 .join(",");
2935 let endpoint = format!(
2936 "/Playlists/{}/Items?Ids={}",
2937 urlencoding::encode(playlist_id),
2938 ids_param
2939 );
2940 self.post_json(&endpoint, &serde_json::json!({})).await
2941 }
2942
2943 async fn remove_from_playlist(
2944 &self,
2945 playlist_id: &str,
2946 entry_ids: &[String],
2947 ) -> Result<(), RepoError> {
2948 info!(
2949 "[OnlineRepo] Removing {} entries from playlist {}",
2950 entry_ids.len(),
2951 playlist_id
2952 );
2953 let ids_param = entry_ids
2954 .iter()
2955 .map(|id| urlencoding::encode(id).into_owned())
2956 .collect::<Vec<_>>()
2957 .join(",");
2958 let endpoint = format!(
2959 "/Playlists/{}/Items?EntryIds={}",
2960 urlencoding::encode(playlist_id),
2961 ids_param
2962 );
2963 let url = format!("{}{}", self.server_url, endpoint);
2964
2965 let request = self
2966 .http_client
2967 .client
2968 .delete(&url)
2969 .header("X-Emby-Authorization", self.auth_header())
2970 .build()
2971 .map_err(|e| RepoError::Network {
2972 message: format!("Failed to build request: {}", e),
2973 })?;
2974
2975 let response = self
2976 .http_client
2977 .request_with_retry(request)
2978 .await
2979 .map_err(|e| RepoError::Network {
2980 message: e.to_string(),
2981 })?;
2982
2983 if !response.status().is_success() {
2984 return Err(RepoError::Server {
2985 message: format!("HTTP {}", response.status()),
2986 });
2987 }
2988
2989 Ok(())
2990 }
2991
2992 async fn move_playlist_item(
2993 &self,
2994 playlist_id: &str,
2995 item_id: &str,
2996 new_index: u32,
2997 ) -> Result<(), RepoError> {
2998 info!(
2999 "[OnlineRepo] Moving item {} in playlist {} to index {}",
3000 item_id, playlist_id, new_index
3001 );
3002 let endpoint = format!(
3003 "/Playlists/{}/Items/{}/Move/{}",
3004 playlist_id, item_id, new_index
3005 );
3006 self.post_json(&endpoint, &serde_json::json!({})).await
3007 }
3008}
3009
3010#[cfg(test)]
3011mod tests {
3012 use super::*;
3013 use crate::domain::MediaKind;
3014 use crate::utils::lock::MutexSafe;
3015 use std::sync::Arc;
3016
3017 fn create_test_repository() -> OnlineRepository {
3018 let http_config = crate::jellyfin::HttpConfig::default();
3019 let http_client =
3020 Arc::new(HttpClient::new(http_config).expect("Failed to create HTTP client for test"));
3021 OnlineRepository::new(
3022 http_client,
3023 "https://test.server.com".to_string(),
3024 "test-user-id".to_string(),
3025 "test-access-token".to_string(),
3026 )
3027 }
3028
3029 #[test]
3049 fn subtitle_url_uses_jellyfins_stream_route() {
3050 let repo = create_test_repository();
3051
3052 assert_eq!(
3053 repo.get_subtitle_url("item123", "source456", 2, "vtt"),
3054 "https://test.server.com/Videos/item123/source456/Subtitles/2/Stream.vtt"
3055 );
3056 }
3057
3058 fn create_test_repository_with_connectivity(
3062 ) -> (OnlineRepository, crate::connectivity::ConnectivityReporter) {
3063 let monitor_http = HttpClient::new(crate::jellyfin::HttpConfig::default())
3064 .expect("Failed to create HTTP client for monitor");
3065 let monitor = crate::connectivity::ConnectivityMonitor::new(monitor_http);
3066 let reporter = monitor.reporter();
3067 let repo = create_test_repository().with_connectivity(reporter.clone());
3068 (repo, reporter)
3069 }
3070
3071 #[tokio::test]
3080 async fn test_report_outcome_classifies_server_answered_as_reachable() {
3081 let (repo, reporter) = create_test_repository_with_connectivity();
3082
3083 for err in [
3085 RepoError::Authentication {
3086 message: "401".into(),
3087 },
3088 RepoError::NotFound {
3089 message: "404".into(),
3090 },
3091 RepoError::Server {
3092 message: "500".into(),
3093 },
3094 ] {
3095 reporter.mark_unreachable_for_test().await;
3096 assert!(!reporter.is_reachable().await, "precondition: offline");
3097
3098 let result: Result<(), RepoError> = Err(err);
3099 repo.report_outcome(&result).await;
3100
3101 assert!(
3102 reporter.is_reachable().await,
3103 "a server that answers should be reported reachable"
3104 );
3105 }
3106
3107 reporter.mark_unreachable_for_test().await;
3109 let ok: Result<(), RepoError> = Ok(());
3110 repo.report_outcome(&ok).await;
3111 assert!(reporter.is_reachable().await, "Ok ⇒ reachable");
3112 }
3113
3114 #[tokio::test]
3117 async fn test_report_outcome_ignores_local_errors() {
3118 let (repo, reporter) = create_test_repository_with_connectivity();
3119
3120 reporter.mark_unreachable_for_test().await;
3123 for err in [
3124 RepoError::Database {
3125 message: "cache".into(),
3126 },
3127 RepoError::Offline,
3128 ] {
3129 let result: Result<(), RepoError> = Err(err);
3130 repo.report_outcome(&result).await;
3131 assert!(
3132 !reporter.is_reachable().await,
3133 "local-side error must not change reachability"
3134 );
3135 }
3136 }
3137
3138 #[tokio::test]
3144 async fn test_get_json_fast_fails_when_offline() {
3145 let (repo, reporter) = create_test_repository_with_connectivity();
3146 reporter.mark_unreachable_for_test().await;
3147 assert!(!reporter.is_reachable().await, "precondition: offline");
3148
3149 let result: Result<serde_json::Value, RepoError> = repo.get_json("/System/Info").await;
3150 assert!(
3151 matches!(result, Err(RepoError::Offline)),
3152 "known-offline get_json should return Offline immediately, got {:?}",
3153 result
3154 );
3155 }
3156
3157 #[tokio::test]
3160 async fn test_report_outcome_network_error_is_debounced() {
3161 let (repo, reporter) = create_test_repository_with_connectivity();
3162 assert!(reporter.is_reachable().await, "starts online");
3163
3164 let result: Result<(), RepoError> = Err(RepoError::Network {
3165 message: "timeout".into(),
3166 });
3167 repo.report_outcome(&result).await;
3168
3169 assert!(
3170 reporter.is_reachable().await,
3171 "a single network failure stays online (debounced)"
3172 );
3173 }
3174
3175 #[tokio::test]
3176 async fn test_get_audio_stream_url_formats_correctly() {
3177 let repo = create_test_repository();
3178 let item_id = "test-track-123";
3179
3180 let result = repo.get_audio_stream_url(item_id).await;
3181
3182 assert!(result.is_ok());
3183 let url = result.unwrap();
3184 assert_eq!(
3185 url,
3186 "https://test.server.com/Audio/test-track-123/stream?UserId=test-user-id&api_key=test-access-token&Static=true"
3187 );
3188 }
3189
3190 static QUALITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3196
3197 struct QualityFixture(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
3198
3199 impl QualityFixture {
3200 fn set(quality: StreamingQuality) -> Self {
3201 let guard = QUALITY_LOCK.lock_safe();
3202 set_streaming_quality(quality);
3203 Self(guard)
3204 }
3205 }
3206
3207 impl Drop for QualityFixture {
3208 fn drop(&mut self) {
3209 set_streaming_quality(StreamingQuality::Original);
3210 clear_playback_quality_override();
3214 }
3215 }
3216
3217 #[tokio::test]
3224 async fn test_video_stream_url_applies_bitrate_cap() {
3225 let _fixture = QualityFixture::set(StreamingQuality::Mbps2);
3226 let repo = create_test_repository();
3227
3228 let url = repo
3229 .get_video_stream_url("vid-1", None, None)
3230 .await
3231 .unwrap();
3232
3233 assert!(url.contains("MaxStreamingBitrate=2000000"), "url: {url}");
3234 assert!(url.contains("VideoBitrate=1808000"), "url: {url}");
3237 assert!(url.contains("AudioBitrate=192000"), "url: {url}");
3238 assert!(url.contains("MaxHeight=720"), "url: {url}");
3239 }
3240
3241 #[tokio::test]
3246 async fn test_video_stream_url_uncapped_keeps_legacy_allowance() {
3247 let _fixture = QualityFixture::set(StreamingQuality::Original);
3248 let repo = create_test_repository();
3249
3250 let url = repo
3251 .get_video_stream_url("vid-1", None, None)
3252 .await
3253 .unwrap();
3254
3255 assert!(url.contains("MaxStreamingBitrate=20000000"), "url: {url}");
3256 assert!(url.contains("VideoBitrate=18000000"), "url: {url}");
3257 assert!(url.contains("AudioBitrate=384000"), "url: {url}");
3258 assert!(
3259 !url.contains("MaxHeight"),
3260 "uncapped must not scale the picture down: {url}"
3261 );
3262 }
3263
3264 #[tokio::test]
3269 async fn test_audio_only_stream_url_takes_the_lower_of_cap_and_default() {
3270 {
3271 let _fixture = QualityFixture::set(StreamingQuality::Kbps720);
3272 let repo = create_test_repository();
3273 let url = repo
3274 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
3275 .await
3276 .unwrap();
3277 assert!(url.contains("MaxStreamingBitrate=96000"), "url: {url}");
3278 }
3279
3280 let _fixture = QualityFixture::set(StreamingQuality::Original);
3281 let repo = create_test_repository();
3282 let url = repo
3283 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
3284 .await
3285 .unwrap();
3286 assert!(url.contains("MaxStreamingBitrate=384000"), "url: {url}");
3287 }
3288
3289 #[tokio::test]
3302 async fn test_get_video_stream_url_returns_an_hls_master_playlist() {
3303 let _fixture = QualityFixture::set(StreamingQuality::Original);
3304 let repo = create_test_repository();
3305
3306 let url = repo
3307 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
3308 .await
3309 .unwrap();
3310
3311 assert!(
3312 url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"),
3313 "expected HLS master playlist, got: {url}"
3314 );
3315 assert!(url.contains("VideoCodec=h264"));
3316 assert!(url.contains("MediaSourceId=source-1"));
3317 assert!(url.contains("AudioStreamIndex=1"));
3318 assert!(!url.contains("stream.mp4"));
3319 }
3320
3321 #[tokio::test]
3346 async fn test_video_stream_url_never_carries_start_time_ticks() {
3347 let _fixture = QualityFixture::set(StreamingQuality::Original);
3348 let repo = create_test_repository();
3349
3350 let url = repo
3351 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
3352 .await
3353 .unwrap();
3354
3355 assert!(
3356 !url.contains("StartTimeTicks"),
3357 "an HLS playlist must never carry StartTimeTicks — the server copies it \
3358 onto every segment URI and then rejects each one with 400: {url}"
3359 );
3360 }
3361
3362 #[tokio::test]
3363 async fn test_get_video_stream_url_omits_position_when_absent() {
3364 let _fixture = QualityFixture::set(StreamingQuality::Original);
3365 let repo = create_test_repository();
3366
3367 let url = repo
3368 .get_video_stream_url("vid-1", None, None)
3369 .await
3370 .unwrap();
3371
3372 assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
3373 assert!(!url.contains("StartTimeTicks"));
3374 assert!(!url.contains("MediaSourceId"));
3375 assert!(
3380 !url.contains("AudioStreamIndex"),
3381 "must not pin an audio index when none was chosen: {url}"
3382 );
3383 }
3384
3385 #[tokio::test]
3396 async fn test_video_stream_url_carries_a_play_session_id() {
3397 let _fixture = QualityFixture::set(StreamingQuality::Original);
3398 let repo = create_test_repository();
3399
3400 let url = repo
3401 .get_video_stream_url("vid-1", None, None)
3402 .await
3403 .unwrap();
3404
3405 assert!(
3406 url.contains("PlaySessionId="),
3407 "every transcode must be openable as its own job: {url}"
3408 );
3409 }
3410
3411 #[tokio::test]
3425 async fn test_video_stream_url_asks_for_no_subtitle_stream() {
3426 let _fixture = QualityFixture::set(StreamingQuality::Original);
3427 let repo = create_test_repository();
3428
3429 let url = repo
3430 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
3431 .await
3432 .unwrap();
3433
3434 assert!(
3435 url.contains("SubtitleStreamIndex=-1"),
3436 "the stream URL must ask for no subtitle, not leave the choice open: {url}"
3437 );
3438 }
3439
3440 #[test]
3448 fn test_media_streams_carry_whether_the_app_can_render_them() {
3449 let item: JellyfinItem = serde_json::from_value(serde_json::json!({
3450 "Id": "ep-1",
3451 "Name": "Partings",
3452 "Type": "Episode",
3453 "MediaStreams": [
3454 { "Type": "Video", "Index": 0, "Codec": "hevc", "IsDefault": true },
3455 { "Type": "Audio", "Index": 1, "Codec": "eac3", "IsDefault": true },
3456 { "Type": "Subtitle", "Index": 2, "Codec": "PGSSUB", "IsDefault": true },
3457 { "Type": "Subtitle", "Index": 3, "Codec": "subrip", "IsDefault": false },
3458 { "Type": "Subtitle", "Index": 4, "Codec": null, "IsDefault": false },
3459 ],
3460 }))
3461 .expect("fixture must deserialize");
3462
3463 let streams = item.into_media_item("server-1".to_string()).media_streams;
3464 let streams = streams.expect("the item carries streams");
3465 let deliverable = |index: i32| {
3466 streams
3467 .iter()
3468 .find(|s| s.index == index)
3469 .unwrap_or_else(|| panic!("stream {index} missing"))
3470 .supports_external_delivery
3471 };
3472
3473 assert_eq!(deliverable(2), Some(false));
3475 assert_eq!(deliverable(3), Some(true));
3477 assert_eq!(deliverable(4), Some(false));
3480 assert_eq!(deliverable(0), None);
3483 assert_eq!(deliverable(1), None);
3484 }
3485
3486 #[test]
3492 fn test_each_stream_open_gets_a_fresh_session_and_reports_the_previous() {
3493 let _lock = QUALITY_LOCK.lock_safe();
3494
3495 let (first, _) = begin_video_play_session();
3496 let (second, replaced) = begin_video_play_session();
3497
3498 assert_ne!(first, second, "each open needs its own job identity");
3499 assert_eq!(
3500 replaced,
3501 Some(first),
3502 "the open must hand back the job it superseded so it can be stopped"
3503 );
3504
3505 let replaced_by_adoption = adopt_video_play_session("server-named-session".to_string());
3509 assert_eq!(replaced_by_adoption, Some(second));
3510
3511 let (_, after_adoption) = begin_video_play_session();
3512 assert_eq!(
3513 after_adoption,
3514 Some("server-named-session".to_string()),
3515 "the adopted job must be the one the next open stops"
3516 );
3517 }
3518
3519 #[tokio::test]
3520 async fn test_get_audio_only_stream_url_for_video_carries_track_and_position() {
3521 let repo = create_test_repository();
3526
3527 let url = repo
3528 .get_audio_only_stream_url_for_video("vid-1", Some("source-1"), Some(193.0), Some(2))
3529 .await
3530 .unwrap();
3531
3532 assert!(
3533 url.starts_with("https://test.server.com/Audio/vid-1/universal?"),
3534 "expected audio-only universal endpoint, got: {url}"
3535 );
3536 assert!(
3538 !url.contains("/Videos/"),
3539 "url must not hit the video endpoint: {url}"
3540 );
3541 assert!(
3542 !url.contains("master.m3u8"),
3543 "url must not be a video HLS playlist: {url}"
3544 );
3545 assert!(url.contains("AudioStreamIndex=2"));
3546 assert!(url.contains("MediaSourceId=source-1"));
3547 assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
3549 assert!(url.contains("TranscodingProtocol=http"), "url: {url}");
3552 assert!(url.contains("TranscodingContainer=mp3"), "url: {url}");
3553 assert!(
3554 !url.contains("TranscodingProtocol=hls"),
3555 "url must not be HLS: {url}"
3556 );
3557 assert!(!url.contains("Container=ts"), "url must not be ts: {url}");
3558 }
3559
3560 #[tokio::test]
3561 async fn test_get_audio_only_stream_url_for_video_omits_position_when_absent() {
3562 let repo = create_test_repository();
3564
3565 let url = repo
3566 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
3567 .await
3568 .unwrap();
3569
3570 assert!(url.starts_with("https://test.server.com/Audio/vid-1/universal?"));
3571 assert!(!url.contains("StartTimeTicks"));
3572 assert!(!url.contains("MediaSourceId"));
3573 assert!(
3576 !url.contains("AudioStreamIndex"),
3577 "must not pin an audio index when none was chosen: {url}"
3578 );
3579 }
3580
3581 #[tokio::test]
3582 async fn test_get_audio_stream_url_with_special_characters() {
3583 let repo = create_test_repository();
3584 let item_id = "track-with-special-chars-!@#";
3585
3586 let result = repo.get_audio_stream_url(item_id).await;
3587
3588 assert!(result.is_ok());
3589 let url = result.unwrap();
3590 assert!(url.contains("track-with-special-chars-!@#"));
3591 assert!(url.starts_with("https://test.server.com/Audio/"));
3592 }
3593
3594 #[test]
3595 fn test_image_tags_deserialize_hashmap_format() {
3596 let json = r#"{"Primary":"abc123","Banner":"def456","Backdrop":"ghi789"}"#;
3598 let result: Result<ImageTags, _> = serde_json::from_str(json);
3599
3600 assert!(result.is_ok());
3601 let tags = result.unwrap();
3602 assert_eq!(tags.primary(), Some("abc123".to_string()));
3603 }
3604
3605 #[test]
3606 fn test_image_tags_deserialize_structured_format() {
3607 let json = r#"{"Primary":"xyz789"}"#;
3609 let result: Result<ImageTags, _> = serde_json::from_str(json);
3610
3611 assert!(result.is_ok());
3612 let tags = result.unwrap();
3613 assert_eq!(tags.primary(), Some("xyz789".to_string()));
3614 }
3615
3616 #[test]
3617 fn test_image_tags_deserialize_missing_primary() {
3618 let json = r#"{"Banner":"def456","Backdrop":"ghi789"}"#;
3620 let result: Result<ImageTags, _> = serde_json::from_str(json);
3621
3622 assert!(result.is_ok());
3623 let tags = result.unwrap();
3624 assert_eq!(tags.primary(), None);
3625 }
3626
3627 #[test]
3628 fn test_image_tags_deserialize_empty_map() {
3629 let json = r#"{}"#;
3631 let result: Result<ImageTags, _> = serde_json::from_str(json);
3632
3633 assert!(result.is_ok());
3634 let tags = result.unwrap();
3635 assert_eq!(tags.primary(), None);
3636 }
3637
3638 #[test]
3649 fn test_video_download_url_uses_stream_not_download_endpoint() {
3650 let repo = create_test_repository();
3651 let url = repo.get_video_download_url("item123", "original", None, None);
3652
3653 assert!(
3655 !url.contains("/download"),
3656 "download URL must not use the broken /Videos/{{id}}/download endpoint: {url}"
3657 );
3658 assert!(
3660 url.contains("/Videos/item123/stream.mp4"),
3661 "download URL must target /Videos/{{id}}/stream.mp4: {url}"
3662 );
3663 assert!(url.contains("api_key=test-access-token"), "url: {url}");
3664 }
3665
3666 #[test]
3667 fn test_video_download_url_original_is_static_direct_copy() {
3668 let repo = create_test_repository();
3669 let url = repo.get_video_download_url("item123", "original", None, None);
3670
3671 assert!(url.contains("Static=true"), "url: {url}");
3674 assert!(
3675 !url.contains("videoBitRate"),
3676 "original must not transcode: {url}"
3677 );
3678 assert!(
3679 !url.contains("maxHeight"),
3680 "original must not transcode: {url}"
3681 );
3682 }
3683
3684 #[test]
3685 fn test_video_download_url_quality_presets_transcode() {
3686 let repo = create_test_repository();
3687
3688 for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
3689 let url = repo.get_video_download_url("item123", quality, None, None);
3690 assert!(
3691 url.contains("/Videos/item123/stream.mp4"),
3692 "{quality} must use stream.mp4: {url}"
3693 );
3694 assert!(
3695 url.contains("videoBitRate="),
3696 "{quality} must set bitrate: {url}"
3697 );
3698 assert!(
3699 url.contains(&format!("maxHeight={height}")),
3700 "{quality} must cap height at {height}: {url}"
3701 );
3702 assert!(url.contains("videoCodec=h264"), "{quality}: {url}");
3703 assert!(
3705 !url.contains("Static=true"),
3706 "{quality} must not be Static: {url}"
3707 );
3708 }
3709 }
3710
3711 #[test]
3718 fn test_video_download_url_bitrate_params_use_capital_r_spelling() {
3719 let repo = create_test_repository();
3720
3721 for quality in ["high", "medium", "low"] {
3722 let url = repo.get_video_download_url("item123", quality, None, None);
3723
3724 assert!(
3725 url.contains("videoBitRate="),
3726 "{quality} must spell it videoBitRate (capital R): {url}"
3727 );
3728 assert!(
3729 url.contains("audioBitRate="),
3730 "{quality} must spell it audioBitRate (capital R): {url}"
3731 );
3732
3733 assert!(
3736 !url.contains("videoBitrate="),
3737 "{quality} emits the unbindable lowercase-r spelling: {url}"
3738 );
3739 assert!(
3740 !url.contains("audioBitrate="),
3741 "{quality} emits the unbindable lowercase-r spelling: {url}"
3742 );
3743 }
3744 }
3745
3746 #[test]
3752 fn test_video_download_url_transcode_presets_forbid_video_stream_copy() {
3753 let repo = create_test_repository();
3754
3755 for quality in ["high", "medium", "low"] {
3756 let url = repo.get_video_download_url("item123", quality, None, None);
3757 assert!(
3758 url.contains("allowVideoStreamCopy=false"),
3759 "{quality} must forbid video stream copy: {url}"
3760 );
3761 }
3762
3763 let original = repo.get_video_download_url("item123", "original", None, None);
3765 assert!(
3766 !original.contains("allowVideoStreamCopy=false"),
3767 "original must remain a direct copy: {original}"
3768 );
3769 }
3770
3771 #[test]
3784 fn test_video_download_url_original_transcodes_undecodable_audio() {
3785 let repo = create_test_repository();
3786
3787 for codec in ["eac3", "ac3", "dts", "truehd", "EAC3"] {
3788 let url = repo.get_video_download_url("item123", "original", None, Some(codec));
3789 assert!(
3790 !url.contains("Static=true"),
3791 "{codec} cannot be decoded here, so the source must not be copied verbatim: {url}"
3792 );
3793 assert!(
3794 url.contains("audioCodec=aac"),
3795 "{codec} must be re-encoded to aac on the way down: {url}"
3796 );
3797 assert!(
3800 url.contains("allowVideoStreamCopy=true"),
3801 "the video stream must still be copied where possible: {url}"
3802 );
3803 assert!(
3804 !url.contains("videoBitRate") && !url.contains("maxHeight"),
3805 "original must not degrade the picture to fix the audio: {url}"
3806 );
3807 }
3808 }
3809
3810 #[test]
3816 fn test_video_download_url_original_keeps_static_copy_for_playable_audio() {
3817 let repo = create_test_repository();
3818
3819 for codec in ["aac", "mp3", "opus", "vorbis", "flac", "AAC"] {
3820 let url = repo.get_video_download_url("item123", "original", None, Some(codec));
3821 assert!(
3822 url.contains("Static=true"),
3823 "{codec} plays here — the download must stay a direct copy: {url}"
3824 );
3825 assert!(
3826 !url.contains("audioCodec="),
3827 "{codec} needs no transcode: {url}"
3828 );
3829 }
3830
3831 let unknown = repo.get_video_download_url("item123", "original", None, None);
3834 assert!(unknown.contains("Static=true"), "url: {unknown}");
3835 }
3836
3837 #[test]
3842 fn test_video_download_url_presets_ignore_the_audio_policy() {
3843 let repo = create_test_repository();
3844
3845 for quality in ["high", "medium", "low"] {
3846 let with = repo.get_video_download_url("item123", quality, None, Some("eac3"));
3847 let without = repo.get_video_download_url("item123", quality, None, None);
3848 assert_eq!(with, without, "{quality} must not vary with source audio");
3849 assert!(with.contains("audioCodec=aac"), "url: {with}");
3850 }
3851 }
3852
3853 #[test]
3854 fn test_video_download_url_passes_media_source_id() {
3855 let repo = create_test_repository();
3856 let url = repo.get_video_download_url("item123", "original", Some("src-42"), None);
3857 assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
3858 }
3859
3860 #[test]
3861 fn test_jellyfin_item_deserialize_with_image_tags() {
3862 let json = r#"{
3864 "Id": "album123",
3865 "Name": "Test Album",
3866 "Type": "MusicAlbum",
3867 "ImageTags": {"Primary": "tag123"},
3868 "ArtistItems": [
3869 {"Id": "artist1", "Name": "Artist One"},
3870 {"Id": "artist2", "Name": "Artist Two"}
3871 ]
3872 }"#;
3873
3874 let result: Result<JellyfinItem, _> = serde_json::from_str(json);
3875 assert!(result.is_ok());
3876
3877 let item = result.unwrap();
3878 assert_eq!(item.id, "album123");
3879 assert_eq!(item.name, "Test Album");
3880 assert_eq!(item.item_type, "MusicAlbum");
3881 assert!(item.image_tags.is_some());
3882 assert_eq!(
3883 item.image_tags.unwrap().primary(),
3884 Some("tag123".to_string())
3885 );
3886 }
3887
3888 #[test]
3892 fn test_build_favorites_endpoint_scopes_and_filters() {
3893 let movies = build_favorites_endpoint("u1", SearchScope::Movies, None);
3894 assert!(movies.starts_with("/Users/u1/Items?Filters=IsFavorite&Recursive=true"));
3895 assert!(movies.contains("&IncludeItemTypes=Movie"));
3896 assert!(movies.contains("&SortBy=SortName&SortOrder=Ascending"));
3898 assert!(movies.contains("UserData"));
3900
3901 let tv = build_favorites_endpoint("u1", SearchScope::Tv, None);
3903 assert!(tv.contains("&IncludeItemTypes=Series,Episode"));
3904
3905 let music = build_favorites_endpoint("u1", SearchScope::Music, None);
3906 assert!(music.contains("&IncludeItemTypes=MusicAlbum,MusicArtist,Audio,Playlist"));
3907 }
3908
3909 #[test]
3914 fn test_build_favorites_endpoint_all_scope_omits_type_filter() {
3915 let all = build_favorites_endpoint("u1", SearchScope::All, None);
3916 assert!(!all.contains("IncludeItemTypes"));
3917 }
3918
3919 #[test]
3923 fn test_build_favorites_endpoint_honours_paging_and_sort() {
3924 let endpoint = build_favorites_endpoint(
3925 "u1",
3926 SearchScope::All,
3927 Some(&GetItemsOptions {
3928 limit: Some(20),
3929 start_index: Some(40),
3930 sort_by: Some("Random".to_string()),
3931 sort_order: Some("Descending".to_string()),
3932 ..Default::default()
3933 }),
3934 );
3935 assert!(endpoint.contains("&Limit=20"));
3936 assert!(endpoint.contains("&StartIndex=40"));
3937 assert!(endpoint.contains("&SortBy=Random&SortOrder=Descending"));
3938 }
3939
3940 #[test]
3945 fn test_get_items_endpoint_applies_favorites_only() {
3946 let plain = build_get_items_endpoint("u1", "lib-1", None);
3947 assert!(!plain.contains("Filters=IsFavorite"));
3948
3949 let filtered = build_get_items_endpoint(
3950 "u1",
3951 "lib-1",
3952 Some(&GetItemsOptions {
3953 favorites_only: Some(true),
3954 include_item_types: Some(vec!["Movie".to_string()]),
3955 ..Default::default()
3956 }),
3957 );
3958 assert!(filtered.contains("&Filters=IsFavorite"));
3959 assert!(filtered.contains("&IncludeItemTypes=Movie"));
3961 assert!(filtered.contains("ParentId=lib-1"));
3962
3963 let off = build_get_items_endpoint(
3965 "u1",
3966 "lib-1",
3967 Some(&GetItemsOptions {
3968 favorites_only: Some(false),
3969 ..Default::default()
3970 }),
3971 );
3972 assert!(!off.contains("Filters=IsFavorite"));
3973 }
3974
3975 #[test]
3984 fn test_get_items_endpoint_encodes_query_values() {
3985 let endpoint = build_get_items_endpoint(
3986 "u1",
3987 "lib 1&Filters=IsFavorite",
3988 Some(&GetItemsOptions {
3989 include_item_types: Some(vec!["Movie&x=1".to_string()]),
3990 sort_by: Some("Sort Name".to_string()),
3991 sort_order: Some("Ascending&y=2".to_string()),
3992 ..Default::default()
3993 }),
3994 );
3995 assert!(
3996 endpoint.contains("ParentId=lib%201%26Filters%3DIsFavorite"),
3997 "{endpoint}"
3998 );
3999 assert!(
4000 endpoint.contains("&IncludeItemTypes=Movie%26x%3D1"),
4001 "{endpoint}"
4002 );
4003 assert!(endpoint.contains("&SortBy=Sort%20Name"), "{endpoint}");
4004 assert!(
4005 endpoint.contains("&SortOrder=Ascending%26y%3D2"),
4006 "{endpoint}"
4007 );
4008 assert!(!endpoint.contains("&Filters=IsFavorite"), "{endpoint}");
4010 assert!(!endpoint.contains("&x=1"), "{endpoint}");
4011 assert!(!endpoint.contains("&y=2"), "{endpoint}");
4012 }
4013
4014 #[test]
4020 fn test_get_items_endpoint_keeps_list_separators() {
4021 let endpoint = build_get_items_endpoint(
4022 "u1",
4023 "lib-1",
4024 Some(&GetItemsOptions {
4025 sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
4026 include_item_types: Some(vec!["Movie".to_string(), "Series".to_string()]),
4027 ..Default::default()
4028 }),
4029 );
4030 assert!(
4031 endpoint.contains("&SortBy=ParentIndexNumber,IndexNumber,SortName"),
4032 "{endpoint}"
4033 );
4034 assert!(
4035 endpoint.contains("&IncludeItemTypes=Movie,Series"),
4036 "{endpoint}"
4037 );
4038 assert!(endpoint.contains("ParentId=lib-1"), "{endpoint}");
4040 }
4041
4042 #[test]
4055 fn test_get_items_endpoint_orders_channel_folders_by_release_date() {
4056 let podcast = build_get_items_endpoint(
4057 "u1",
4058 "podcast-1",
4059 Some(&GetItemsOptions {
4060 parent_kind: Some(MediaKind::ChannelFolder),
4061 ..Default::default()
4062 }),
4063 );
4064 assert!(
4065 podcast.contains("&SortBy=PremiereDate&SortOrder=Descending"),
4066 "{podcast}"
4067 );
4068
4069 let season = build_get_items_endpoint(
4071 "u1",
4072 "season-1",
4073 Some(&GetItemsOptions {
4074 parent_kind: Some(MediaKind::Season),
4075 ..Default::default()
4076 }),
4077 );
4078 assert!(
4079 season.contains("&SortBy=SortName&SortOrder=Ascending"),
4080 "{season}"
4081 );
4082
4083 let explicit = build_get_items_endpoint(
4085 "u1",
4086 "podcast-1",
4087 Some(&GetItemsOptions {
4088 parent_kind: Some(MediaKind::ChannelFolder),
4089 sort_by: Some("SortName".to_string()),
4090 sort_order: Some("Ascending".to_string()),
4091 ..Default::default()
4092 }),
4093 );
4094 assert!(
4095 explicit.contains("&SortBy=SortName&SortOrder=Ascending"),
4096 "{explicit}"
4097 );
4098 assert!(!explicit.contains("SortBy=PremiereDate"), "{explicit}");
4099
4100 let unspecified = build_get_items_endpoint("u1", "lib-1", None);
4103 assert!(!unspecified.contains("SortBy="), "{unspecified}");
4104 }
4105
4106 #[test]
4113 fn test_latest_items_endpoint_groups_children_into_containers() {
4114 let endpoint = build_latest_items_endpoint("u1", "lib-1", Some(16));
4115
4116 assert!(
4117 endpoint.contains("GroupItems=true"),
4118 "latest items must be grouped so an album counts once, got: {}",
4119 endpoint
4120 );
4121 assert!(endpoint.contains("ParentId=lib-1"));
4122 assert!(endpoint.contains("Limit=16"));
4123 }
4124
4125 #[test]
4134 fn test_build_next_up_endpoint_excludes_resumable() {
4135 let endpoint = build_next_up_endpoint("u1", None, Some(12));
4136
4137 assert!(
4138 endpoint.contains("EnableResumable=false"),
4139 "next up must exclude in-progress episodes, got: {}",
4140 endpoint
4141 );
4142 assert!(endpoint.contains("UserId=u1"));
4143 assert!(endpoint.contains("Limit=12"));
4144 assert!(
4145 !endpoint.contains("SeriesId"),
4146 "no series filter when none was requested, got: {}",
4147 endpoint
4148 );
4149 }
4150
4151 #[test]
4155 fn test_build_next_up_endpoint_scopes_to_series() {
4156 let endpoint = build_next_up_endpoint("u1", Some("series-a"), None);
4157
4158 assert!(endpoint.contains("SeriesId=series-a"));
4159 assert!(endpoint.contains("EnableResumable=false"));
4160 assert!(
4161 endpoint.contains("Limit=16"),
4162 "default limit, got: {}",
4163 endpoint
4164 );
4165 }
4166
4167 #[test]
4174 fn test_jellyfin_item_maps_user_data_favorite() {
4175 let json = r#"{
4176 "Id": "movie123",
4177 "Name": "Test Movie",
4178 "Type": "Movie",
4179 "UserData": {
4180 "PlaybackPositionTicks": 6000000000,
4181 "Played": false,
4182 "IsFavorite": true,
4183 "PlayCount": 2,
4184 "LastPlayedDate": "2026-08-01T12:00:00Z"
4185 }
4186 }"#;
4187
4188 let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
4189 let media = item.into_media_item("server1".to_string());
4190
4191 let user_data = media.user_data.expect("user data should be mapped");
4192 assert_eq!(user_data.is_favorite, Some(true));
4193 assert_eq!(user_data.is_played, Some(false));
4194 assert_eq!(user_data.play_count, Some(2));
4195 assert_eq!(user_data.playback_position_ticks, Some(6_000_000_000));
4196 assert_eq!(user_data.playback_position_ms, Some(600_000));
4198 }
4199
4200 #[test]
4205 fn test_jellyfin_item_without_user_data_maps_to_none() {
4206 let json = r#"{"Id": "x", "Name": "No User Data", "Type": "Movie"}"#;
4207
4208 let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
4209 let media = item.into_media_item("server1".to_string());
4210
4211 assert!(media.user_data.is_none());
4212 }
4213
4214 #[test]
4215 fn test_jellyfin_item_deserialize_with_artist_items() {
4216 let json = r#"{
4218 "Id": "track123",
4219 "Name": "Test Track",
4220 "Type": "Audio",
4221 "ArtistItems": [
4222 {"Id": "artist1", "Name": "Bob Dylan"},
4223 {"Id": "artist2", "Name": "Johnny Cash"}
4224 ]
4225 }"#;
4226
4227 let result: Result<JellyfinItem, _> = serde_json::from_str(json);
4228 assert!(result.is_ok());
4229
4230 let item = result.unwrap();
4231 let artist_items = item.artist_items.expect("Expected artist items");
4232 assert_eq!(artist_items.len(), 2);
4233 assert_eq!(artist_items[0].id, "artist1");
4234 assert_eq!(artist_items[0].name, "Bob Dylan");
4235 assert_eq!(artist_items[1].id, "artist2");
4236 assert_eq!(artist_items[1].name, "Johnny Cash");
4237 }
4238
4239 #[test]
4240 fn test_jellyfin_item_to_media_item_conversion() {
4241 let json = r#"{
4243 "Id": "album456",
4244 "Name": "Love and Theft",
4245 "Type": "MusicAlbum",
4246 "ImageTags": {"Primary": "7ebab4f6a80cd09d"},
4247 "Artists": ["Bob Dylan"],
4248 "ArtistItems": [{"Id": "0b2a6e969a27f22aba97f9f0e69fa849", "Name": "Bob Dylan"}],
4249 "RunTimeTicks": 33900137190
4250 }"#;
4251
4252 let jellyfin_item: JellyfinItem = serde_json::from_str(json).expect("Failed to parse");
4253 let media_item = jellyfin_item.into_media_item("test-server-id".to_string());
4254
4255 assert_eq!(media_item.id, "album456");
4256 assert_eq!(media_item.name, "Love and Theft");
4257 assert_eq!(media_item.item_type, "MusicAlbum");
4258 assert_eq!(
4259 media_item.primary_image_tag,
4260 Some("7ebab4f6a80cd09d".to_string())
4261 );
4262 assert_eq!(media_item.server_id, "test-server-id");
4263 }
4264
4265 #[test]
4266 fn test_items_response_deserialize() {
4267 let json = r#"{
4269 "Items": [
4270 {
4271 "Id": "item1",
4272 "Name": "Item One",
4273 "Type": "MusicAlbum",
4274 "ImageTags": {"Primary": "tag1"}
4275 },
4276 {
4277 "Id": "item2",
4278 "Name": "Item Two",
4279 "Type": "Audio",
4280 "ImageTags": {"Primary": "tag2"}
4281 }
4282 ],
4283 "TotalRecordCount": 2
4284 }"#;
4285
4286 let result: Result<ItemsResponse, _> = serde_json::from_str(json);
4287 assert!(result.is_ok());
4288
4289 let response = result.unwrap();
4290 assert_eq!(response.total_record_count, 2);
4291 assert_eq!(response.items.len(), 2);
4292 assert_eq!(response.items[0].id, "item1");
4293 assert_eq!(response.items[1].id, "item2");
4294 }
4295
4296 #[test]
4297 fn test_search_term_is_url_encoded() {
4298 assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
4302 assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
4303 }
4304
4305 #[test]
4306 fn test_jray_context_deserializes_actors() {
4307 let json = r#"{
4309 "actors": [
4310 { "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
4311 ]
4312 }"#;
4313 let ctx: JRayContext = serde_json::from_str(json).expect("should parse");
4314 assert_eq!(ctx.actors.len(), 1);
4315 assert_eq!(ctx.actors[0].name, "Tom Hanks");
4316 assert_eq!(ctx.actors[0].jellyfin_id, "abc123-guid");
4317 }
4318
4319 #[test]
4320 fn test_jray_context_ignores_unknown_keys_and_missing_ids() {
4321 let json = r#"{
4324 "actors": [ { "name": "Extra" } ],
4325 "locations": ["Beach"],
4326 "trivia": "filmed in 1994"
4327 }"#;
4328 let ctx: JRayContext = serde_json::from_str(json).expect("should tolerate extra keys");
4329 assert_eq!(ctx.actors.len(), 1);
4330 assert_eq!(ctx.actors[0].name, "Extra");
4331 assert_eq!(ctx.actors[0].imdb_id, "");
4332 assert_eq!(ctx.actors[0].jellyfin_id, "");
4333 }
4334
4335 fn source_fixture() -> NegotiatedSource {
4350 NegotiatedSource {
4351 id: "source-1".to_string(),
4352 supports_direct_play: true,
4353 supports_direct_stream: true,
4354 supports_transcoding: true,
4355 transcoding_url: None,
4356 bitrate: Some(6_652_961),
4357 media_streams: Vec::new(),
4358 }
4359 }
4360
4361 #[test]
4367 fn test_a_supported_source_direct_plays() {
4368 let source = source_fixture();
4369 assert_eq!(
4370 decide_playback_kind(&source, false, false),
4371 PlaybackKind::DirectPlay
4372 );
4373 }
4374
4375 #[test]
4380 fn test_a_remuxable_source_direct_streams() {
4381 let source = NegotiatedSource {
4382 supports_direct_play: false,
4383 supports_direct_stream: true,
4384 ..source_fixture()
4385 };
4386 let kind = decide_playback_kind(&source, false, false);
4387 assert_eq!(kind, PlaybackKind::DirectStream);
4388 assert!(
4389 !kind.needs_transcoding(),
4390 "a remux costs no encoder time and must not be reported as transcoding"
4391 );
4392 }
4393
4394 #[test]
4399 fn test_an_unsupported_source_transcodes() {
4400 let source = NegotiatedSource {
4401 supports_direct_play: false,
4402 supports_direct_stream: false,
4403 ..source_fixture()
4404 };
4405 assert_eq!(
4406 decide_playback_kind(&source, false, false),
4407 PlaybackKind::Transcode
4408 );
4409 }
4410
4411 #[test]
4418 fn test_undecodable_audio_overrides_the_servers_direct_play_offer() {
4419 let source = source_fixture();
4420 assert!(source.supports_direct_play, "the server said yes");
4421 assert_eq!(
4422 decide_playback_kind(&source, true, false),
4423 PlaybackKind::Transcode,
4424 "silent direct play is worse than a transcode"
4425 );
4426 }
4427
4428 #[test]
4434 fn test_pinning_an_audio_track_forces_a_transcode() {
4435 let source = source_fixture();
4436 assert_eq!(
4437 decide_playback_kind(&source, false, true),
4438 PlaybackKind::Transcode
4439 );
4440 }
4441
4442 #[test]
4450 fn test_a_ceiling_below_the_source_bitrate_transcodes() {
4451 let source = NegotiatedSource {
4453 supports_direct_play: false,
4454 supports_direct_stream: false,
4455 bitrate: Some(6_652_961),
4456 ..source_fixture()
4457 };
4458 assert_eq!(
4459 decide_playback_kind(&source, false, false),
4460 PlaybackKind::Transcode
4461 );
4462
4463 let options =
4465 crate::repository::stream_selection::quality_options_for_source(Some(6_652_961));
4466 let two_mbps = options
4467 .iter()
4468 .find(|o| o.quality == StreamingQuality::Mbps2)
4469 .expect("2 Mbps is on the ladder");
4470 assert!(!two_mbps.exceeds_source);
4471 }
4472
4473 #[test]
4478 fn test_direct_play_is_preferred_over_direct_stream() {
4479 let source = source_fixture();
4480 assert!(source.supports_direct_play && source.supports_direct_stream);
4481 assert_eq!(
4482 decide_playback_kind(&source, false, false),
4483 PlaybackKind::DirectPlay
4484 );
4485 }
4486
4487 #[test]
4498 fn test_a_playback_override_does_not_disturb_the_device_default() {
4499 let _guard = QUALITY_LOCK.lock_safe();
4500 set_streaming_quality(StreamingQuality::Mbps10);
4501 clear_playback_quality_override();
4502 assert_eq!(effective_streaming_quality(), StreamingQuality::Mbps10);
4503
4504 set_playback_quality_override(StreamingQuality::Kbps720);
4505 assert_eq!(
4506 effective_streaming_quality(),
4507 StreamingQuality::Kbps720,
4508 "the override governs the stream being opened now"
4509 );
4510 assert_eq!(
4511 streaming_quality(),
4512 StreamingQuality::Mbps10,
4513 "but the durable default the Settings screen shows is untouched"
4514 );
4515
4516 clear_playback_quality_override();
4517 assert_eq!(
4518 effective_streaming_quality(),
4519 StreamingQuality::Mbps10,
4520 "and dropping the override returns to it"
4521 );
4522 set_streaming_quality(StreamingQuality::Original);
4523 }
4524
4525 #[test]
4531 fn test_the_override_is_droppable_so_it_cannot_outlive_its_playback() {
4532 let _guard = QUALITY_LOCK.lock_safe();
4533 set_streaming_quality(StreamingQuality::Original);
4534 set_playback_quality_override(StreamingQuality::Mbps1);
4535 assert_eq!(playback_quality_override(), Some(StreamingQuality::Mbps1));
4536
4537 clear_playback_quality_override();
4538 assert_eq!(playback_quality_override(), None);
4539 assert_eq!(effective_streaming_quality(), StreamingQuality::Original);
4540 }
4541}