1use async_trait::async_trait;
4use log::{debug, error, info, warn};
5use serde::{Deserialize, Serialize};
6use std::sync::{Arc, RwLock};
7
8use super::capabilities::ServerCapabilities;
9use super::endpoints;
10use super::stream_selection::{
11 quality_options_for_source, PlaybackKind, Rendition, StreamSelection, Transport,
12};
13use super::{types::*, MediaRepository};
14use crate::connectivity::ConnectivityReporter;
15use crate::jellyfin::HttpClient;
16use crate::settings::StreamingQuality;
17use crate::utils::lock::RwLockSafe;
18
19static STREAMING_QUALITY: RwLock<StreamingQuality> = RwLock::new(StreamingQuality::Original);
34
35static PLAYBACK_QUALITY_OVERRIDE: RwLock<Option<StreamingQuality>> = RwLock::new(None);
50
51pub fn set_streaming_quality(quality: StreamingQuality) {
60 *STREAMING_QUALITY.write_safe() = quality;
61}
62
63pub fn streaming_quality() -> StreamingQuality {
71 *STREAMING_QUALITY.read_safe()
72}
73
74pub fn set_playback_quality_override(quality: StreamingQuality) {
78 *PLAYBACK_QUALITY_OVERRIDE.write_safe() = Some(quality);
79}
80
81pub fn clear_playback_quality_override() {
89 *PLAYBACK_QUALITY_OVERRIDE.write_safe() = None;
90}
91
92pub fn playback_quality_override() -> Option<StreamingQuality> {
96 *PLAYBACK_QUALITY_OVERRIDE.read_safe()
97}
98
99pub fn effective_streaming_quality() -> StreamingQuality {
109 playback_quality_override().unwrap_or_else(streaming_quality)
110}
111
112const DEVICE_ID: &str = "jellytau-tauri";
116
117static VIDEO_PLAY_SESSION: RwLock<Option<String>> = RwLock::new(None);
126
127pub fn begin_video_play_session() -> (String, Option<String>) {
140 let new_session = uuid::Uuid::new_v4().to_string();
141 let mut current = VIDEO_PLAY_SESSION.write_safe();
142 let previous = current.replace(new_session.clone());
143 (new_session, previous)
144}
145
146pub fn adopt_video_play_session(session_id: String) -> Option<String> {
156 VIDEO_PLAY_SESSION.write_safe().replace(session_id)
157}
158
159#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
165pub struct JRayActor {
166 pub name: String,
167 #[serde(default)]
168 pub imdb_id: String,
169 #[serde(default)]
170 pub tmdb_id: String,
171 #[serde(default)]
172 pub jellyfin_id: String,
173}
174
175#[derive(Debug, Clone, Deserialize)]
178struct JRayContext {
179 #[serde(default)]
180 actors: Vec<JRayActor>,
181}
182
183pub struct OnlineRepository {
185 http_client: Arc<HttpClient>,
186 server_url: String,
187 user_id: String,
188 access_token: String,
189 connectivity: Option<ConnectivityReporter>,
193 capabilities: ServerCapabilities,
199}
200
201impl OnlineRepository {
202 pub fn user_id(&self) -> &str {
205 &self.user_id
206 }
207
208 pub fn new(
209 http_client: Arc<HttpClient>,
210 server_url: String,
211 user_id: String,
212 access_token: String,
213 ) -> Self {
214 Self {
215 http_client,
216 server_url,
217 user_id,
218 access_token,
219 connectivity: None,
220 capabilities: ServerCapabilities::assumed(),
224 }
225 }
226
227 pub fn with_capabilities(mut self, capabilities: ServerCapabilities) -> Self {
232 self.capabilities = capabilities;
233 self
234 }
235
236 #[cfg(test)]
244 pub fn capabilities(&self) -> &ServerCapabilities {
245 &self.capabilities
246 }
247
248 pub fn with_connectivity(mut self, reporter: ConnectivityReporter) -> Self {
251 self.connectivity = Some(reporter);
252 self
253 }
254
255 async fn report_outcome<T>(&self, result: &Result<T, RepoError>) {
264 let Some(reporter) = &self.connectivity else {
265 return;
266 };
267
268 match result {
269 Ok(_)
270 | Err(RepoError::Authentication { .. })
271 | Err(RepoError::NotFound { .. })
272 | Err(RepoError::Server { .. }) => {
273 reporter.report_success().await;
274 }
275 Err(RepoError::Network { message }) => {
276 reporter.report_network_failure(Some(message.clone())).await;
277 }
278 Err(RepoError::Database { .. }) | Err(RepoError::Offline) => {
279 }
282 }
283 }
284
285 fn auth_header(&self) -> String {
287 HttpClient::build_auth_header(Some(&self.access_token), "jellytau-device")
288 }
289
290 pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
293 let request = self
294 .http_client
295 .client
296 .get(url)
297 .header("Authorization", self.auth_header())
298 .build()
299 .map_err(|e| format!("Failed to build request: {}", e))?;
300
301 let response = self
302 .http_client
303 .request_with_retry(request)
304 .await
305 .map_err(|e| format!("Download failed: {}", e))?;
306
307 if !response.status().is_success() {
308 let status = response.status();
309 let body = response.text().await.unwrap_or_default();
310 let body_preview = if body.len() > 200 {
311 &body[..200]
312 } else {
313 &body
314 };
315 return Err(format!("HTTP {} ({})", status, body_preview.trim()));
316 }
317
318 response
319 .bytes()
320 .await
321 .map(|b| b.to_vec())
322 .map_err(|e| format!("Failed to read bytes: {}", e))
323 }
324
325 pub async fn get_jray_actors(
330 &self,
331 item_id: &str,
332 t: f64,
333 ) -> Result<Vec<JRayActor>, RepoError> {
334 let endpoint = endpoints::jray_context(&self.capabilities, item_id, t);
335 match self.get_json::<JRayContext>(&endpoint).await {
336 Ok(context) => Ok(context.actors),
337 Err(RepoError::NotFound { .. }) => Ok(Vec::new()),
339 Err(e) => Err(e),
340 }
341 }
342
343 async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
345 if let Some(reporter) = &self.connectivity {
351 if !reporter.is_reachable().await {
352 return Err(RepoError::Offline);
353 }
354 }
355
356 let result = self.get_json_inner(endpoint).await;
357 self.report_outcome(&result).await;
358 result
359 }
360
361 async fn get_json_inner<T: for<'de> Deserialize<'de>>(
362 &self,
363 endpoint: &str,
364 ) -> Result<T, RepoError> {
365 let url = format!("{}{}", self.server_url, endpoint);
366
367 let request = self
368 .http_client
369 .client
370 .get(&url)
371 .header("Authorization", self.auth_header())
372 .build()
373 .map_err(|e| RepoError::Network {
374 message: format!("Failed to build request: {}", e),
375 })?;
376
377 let response = self
378 .http_client
379 .request_with_retry(request)
380 .await
381 .map_err(|e| RepoError::Network {
382 message: e.to_string(),
383 })?;
384
385 if !response.status().is_success() {
386 let status = response.status();
387 if status.as_u16() == 401 || status.as_u16() == 403 {
388 return Err(RepoError::Authentication {
389 message: format!("HTTP {}", status),
390 });
391 } else if status.as_u16() == 404 {
392 return Err(RepoError::NotFound {
393 message: "Resource not found".to_string(),
394 });
395 } else {
396 return Err(RepoError::Server {
397 message: format!("HTTP {}", status),
398 });
399 }
400 }
401
402 let text = response.text().await.map_err(|e| RepoError::Server {
404 message: format!("Failed to read response: {}", e),
405 })?;
406
407 serde_json::from_str(&text).map_err(|e| {
409 error!(
410 "[OnlineRepo] Failed to deserialize {} response: {}",
411 endpoint, e
412 );
413 error!(
414 "[OnlineRepo] Response body (first 1000 chars): {}",
415 if text.len() > 1000 {
416 &text[..1000]
417 } else {
418 &text
419 }
420 );
421 RepoError::Server {
422 message: format!("Failed to parse response: {}", e),
423 }
424 })
425 }
426
427 async fn post_json<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
429 let result = self.post_json_inner(endpoint, body).await;
430 self.report_outcome(&result).await;
431 result
432 }
433
434 async fn post_json_inner<T: Serialize>(
435 &self,
436 endpoint: &str,
437 body: &T,
438 ) -> Result<(), RepoError> {
439 let url = format!("{}{}", self.server_url, endpoint);
440
441 let request = self
442 .http_client
443 .client
444 .post(&url)
445 .header("Content-Type", "application/json")
446 .header("Authorization", self.auth_header())
447 .json(body)
448 .build()
449 .map_err(|e| RepoError::Network {
450 message: format!("Failed to build request: {}", e),
451 })?;
452
453 let response = self
454 .http_client
455 .request_with_retry(request)
456 .await
457 .map_err(|e| RepoError::Network {
458 message: e.to_string(),
459 })?;
460
461 if !response.status().is_success() {
462 let status = response.status();
463 if status.as_u16() == 401 || status.as_u16() == 403 {
464 return Err(RepoError::Authentication {
465 message: format!("HTTP {}", status),
466 });
467 } else {
468 return Err(RepoError::Server {
469 message: format!("HTTP {}", status),
470 });
471 }
472 }
473
474 Ok(())
475 }
476
477 async fn post_json_response<T: Serialize, R: for<'de> Deserialize<'de>>(
479 &self,
480 endpoint: &str,
481 body: &T,
482 ) -> Result<R, RepoError> {
483 let result = self.post_json_response_inner(endpoint, body).await;
484 self.report_outcome(&result).await;
485 result
486 }
487
488 async fn post_json_response_inner<T: Serialize, R: for<'de> Deserialize<'de>>(
489 &self,
490 endpoint: &str,
491 body: &T,
492 ) -> Result<R, RepoError> {
493 let url = format!("{}{}", self.server_url, endpoint);
494
495 if let Ok(json) = serde_json::to_string_pretty(body) {
497 debug!("[HTTP] POST {}", endpoint);
498 debug!("[HTTP] Request body:\n{}", json);
499 }
500
501 let request = self
502 .http_client
503 .client
504 .post(&url)
505 .header("Content-Type", "application/json")
506 .header("Authorization", self.auth_header())
507 .json(body)
508 .build()
509 .map_err(|e| RepoError::Network {
510 message: format!("Failed to build request: {}", e),
511 })?;
512
513 let response = self
514 .http_client
515 .request_with_retry(request)
516 .await
517 .map_err(|e| RepoError::Network {
518 message: e.to_string(),
519 })?;
520
521 if !response.status().is_success() {
522 let status = response.status();
523
524 let error_body = response
526 .text()
527 .await
528 .unwrap_or_else(|_| "Failed to read error body".to_string());
529 error!("[HTTP] Error response ({}): {}", status, error_body);
530
531 if status.as_u16() == 401 || status.as_u16() == 403 {
532 return Err(RepoError::Authentication {
533 message: format!("HTTP {}: {}", status, error_body),
534 });
535 } else if status.as_u16() == 404 {
536 return Err(RepoError::NotFound {
537 message: format!("Resource not found: {}", error_body),
538 });
539 } else {
540 return Err(RepoError::Server {
541 message: format!("HTTP {}: {}", status, error_body),
542 });
543 }
544 }
545
546 response.json().await.map_err(|e| RepoError::Server {
547 message: format!("Failed to parse response: {}", e),
548 })
549 }
550
551 async fn stop_transcode(&self, play_session_id: &str) {
561 let url = format!(
562 "{}/Videos/ActiveEncodings?deviceId={}&playSessionId={}",
563 self.server_url, DEVICE_ID, play_session_id
564 );
565
566 let request = self
567 .http_client
568 .client
569 .delete(&url)
570 .header("Authorization", self.auth_header())
571 .send();
572
573 match request.await {
574 Ok(response) if response.status().is_success() => {
575 debug!("[Transcode] Stopped previous encoding {}", play_session_id);
576 }
577 Ok(response) => {
578 debug!(
579 "[Transcode] Server declined to stop encoding {}: HTTP {}",
580 play_session_id,
581 response.status()
582 );
583 }
584 Err(e) => {
585 debug!(
586 "[Transcode] Could not stop encoding {}: {}",
587 play_session_id, e
588 );
589 }
590 }
591 }
592
593 pub async fn get_video_stream_url(
622 &self,
623 item_id: &str,
624 media_source_id: Option<&str>,
625 audio_stream_index: Option<i32>,
626 ) -> Result<String, RepoError> {
627 let quality = effective_streaming_quality();
628 let max_bitrate = quality.max_bitrate().unwrap_or(20_000_000);
632 let video_bitrate = quality.video_bitrate().unwrap_or(18_000_000);
633
634 let (play_session_id, superseded) = begin_video_play_session();
639 if let Some(previous) = superseded {
640 self.stop_transcode(&previous).await;
641 }
642
643 let (renderer_video_codecs, _) = super::device_profile::renderer_codecs();
661 let mut params = vec![
662 ("ApiKey", self.access_token.clone()),
663 ("DeviceId", DEVICE_ID.to_string()),
664 ("PlaySessionId", play_session_id),
665 ("VideoCodec", renderer_video_codecs),
666 ("AudioCodec", "aac".to_string()),
667 ("MaxStreamingBitrate", max_bitrate.to_string()),
668 ("VideoBitrate", video_bitrate.to_string()),
669 ("AudioBitrate", quality.audio_bitrate().to_string()),
670 (
671 "TranscodingMaxAudioChannels",
672 super::device_profile::max_audio_channels().to_string(),
673 ),
674 ("SegmentContainer", "ts".to_string()),
675 ("TranscodingContainer", "ts".to_string()),
676 ("TranscodingProtocol", "hls".to_string()),
677 (
686 "SubtitleStreamIndex",
687 super::device_profile::playback_subtitle_stream_index().to_string(),
688 ),
689 ];
690
691 if let Some(height) = quality.max_height() {
694 params.push(("MaxHeight", height.to_string()));
695 }
696
697 if let Some(index) = audio_stream_index {
704 params.push(("AudioStreamIndex", index.to_string()));
705 }
706
707 if let Some(source_id) = media_source_id {
708 params.push(("MediaSourceId", source_id.to_string()));
709 }
710
711 let query = params
713 .iter()
714 .map(|(k, v)| format!("{}={}", k, v))
715 .collect::<Vec<_>>()
716 .join("&");
717
718 let url = format!(
719 "{}/Videos/{}/master.m3u8?{}",
720 self.server_url, item_id, query
721 );
722
723 Ok(url)
724 }
725
726 pub async fn build_audio_only_stream_url_for_video(
748 &self,
749 item_id: &str,
750 media_source_id: Option<&str>,
751 start_time_seconds: Option<f64>,
752 audio_stream_index: Option<i32>,
753 ) -> Result<String, RepoError> {
754 let mut params = vec![
755 ("UserId", self.user_id.clone()),
756 ("ApiKey", self.access_token.clone()),
757 ("DeviceId", DEVICE_ID.to_string()),
758 ("Container", "mp3".to_string()),
760 ("AudioCodec", "mp3".to_string()),
761 ("TranscodingContainer", "mp3".to_string()),
762 ("TranscodingProtocol", "http".to_string()),
763 (
768 "MaxStreamingBitrate",
769 effective_streaming_quality()
770 .audio_bitrate()
771 .min(384_000)
772 .to_string(),
773 ),
774 ];
775
776 if let Some(index) = audio_stream_index {
779 params.push(("AudioStreamIndex", index.to_string()));
780 }
781
782 if let Some(source_id) = media_source_id {
783 params.push(("MediaSourceId", source_id.to_string()));
784 }
785
786 if let Some(seconds) = start_time_seconds {
787 let ticks = (seconds * 10_000_000.0) as i64;
788 params.push(("StartTimeTicks", ticks.to_string()));
789 }
790
791 let query = params
792 .iter()
793 .map(|(k, v)| format!("{}={}", k, v))
794 .collect::<Vec<_>>()
795 .join("&");
796
797 let url = format!("{}/Audio/{}/universal?{}", self.server_url, item_id, query);
798
799 Ok(url)
800 }
801
802 async fn negotiate_playback(
812 &self,
813 item_id: &str,
814 ) -> Result<(NegotiatedSource, String), RepoError> {
815 let endpoint = endpoints::playback_info(&self.capabilities, item_id);
816
817 let (video_codecs, audio_codecs) = super::device_profile::renderer_codecs();
822
823 let video_audio_codecs = super::device_profile::video_audio_codecs(&audio_codecs);
829
830 info!("[DeviceProfile] Using video codecs: {}", video_codecs);
831 info!("[DeviceProfile] Using audio codecs: {}", audio_codecs);
832 info!(
833 "[DeviceProfile] Audio codecs for video direct play: {}",
834 video_audio_codecs
835 );
836
837 let max_audio_channels = super::device_profile::max_audio_channels().to_string();
841 info!("[DeviceProfile] Max audio channels: {}", max_audio_channels);
842
843 let quality = effective_streaming_quality();
852 let negotiated_bitrate = quality.max_bitrate().unwrap_or(999_999_999) as i64;
853 if let Some(cap) = quality.max_bitrate() {
854 info!(
855 "[DeviceProfile] Streaming quality cap active: {} ({} bps)",
856 quality.label(),
857 cap
858 );
859 }
860
861 let device_profile = DeviceProfile {
863 name: "JellyTau Native Player".to_string(),
864 max_streaming_bitrate: negotiated_bitrate,
865 max_static_bitrate: negotiated_bitrate,
866 max_audio_channels: max_audio_channels.clone(),
867 direct_play_profiles: vec![
868 DirectPlayProfile {
869 profile_type: "Video".to_string(),
870 container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp".to_string(),
871 video_codec: Some(video_codecs.clone()),
872 audio_codec: video_audio_codecs.clone(),
874 },
875 DirectPlayProfile {
876 profile_type: "Audio".to_string(),
877 container: "mp3,aac,flac,alac,wav,ogg,wma,opus".to_string(),
878 video_codec: None,
879 audio_codec: audio_codecs.clone(),
883 },
884 ],
885 transcoding_profiles: vec![
886 TranscodingProfile {
887 profile_type: "Video".to_string(),
888 context: "Streaming".to_string(),
889 protocol: "hls".to_string(),
890 container: "ts".to_string(),
891 video_codec: Some(
904 if video_codecs.contains("hevc") {
905 "h264,hevc"
906 } else {
907 "h264"
908 }
909 .to_string(),
910 ),
911 audio_codec: "aac,mp3".to_string(),
912 max_audio_channels: max_audio_channels.clone(),
913 },
914 TranscodingProfile {
915 profile_type: "Audio".to_string(),
916 context: "Streaming".to_string(),
917 protocol: "http".to_string(),
918 container: "mp3".to_string(),
919 video_codec: None,
920 audio_codec: "mp3".to_string(),
921 max_audio_channels: max_audio_channels.clone(),
922 },
923 ],
924 subtitle_profiles: super::device_profile::subtitle_profiles()
925 .into_iter()
926 .map(|(format, method)| SubtitleProfile {
927 format: format.to_string(),
928 method: method.to_string(),
929 })
930 .collect(),
931 };
932
933 let request_body = PlaybackInfoRequest {
935 user_id: self.user_id.clone(),
936 audio_stream_index: None, subtitle_stream_index: Some(super::device_profile::playback_subtitle_stream_index()),
945 start_time_ticks: 0,
946 is_playback: true,
947 auto_open_live_stream: true,
948 max_streaming_bitrate: quality.max_bitrate().unwrap_or(20_000_000) as i64,
950 device_profile: Some(device_profile), };
952
953 let response: PlaybackInfoResponse =
954 self.post_json_response(&endpoint, &request_body).await?;
955 let source = response
956 .media_sources
957 .into_iter()
958 .next()
959 .ok_or(RepoError::NotFound {
960 message: "No media sources available".to_string(),
961 })?;
962
963 Ok((source, response.play_session_id))
964 }
965
966 pub async fn get_stream_selection(
982 &self,
983 item_id: &str,
984 media_source_id: Option<&str>,
985 audio_stream_index: Option<i32>,
986 ) -> Result<StreamSelection, RepoError> {
987 let (source, play_session_id) = self.negotiate_playback(item_id).await?;
988
989 let quality = effective_streaming_quality();
990 let source_bitrate = source.bitrate.and_then(|b| u64::try_from(b).ok());
991 let available = quality_options_for_source(source_bitrate);
992
993 let audio_streams: Vec<(Option<&str>, bool)> = source
999 .media_streams
1000 .iter()
1001 .filter(|stream| stream.stream_type == "Audio")
1002 .map(|stream| (stream.codec.as_deref(), stream.is_default))
1003 .collect();
1004 let audio_forces_transcode = super::device_profile::audio_forces_transcode(&audio_streams);
1005
1006 let track_pinned = audio_stream_index.is_some();
1010
1011 let effective_source_id = media_source_id.unwrap_or(&source.id).to_string();
1012
1013 let decided = decide_playback_kind(&source, audio_forces_transcode, track_pinned);
1014
1015 let selection = match decided {
1016 PlaybackKind::Transcode => {
1017 let url = if let Some(transcoding_url) = source
1018 .transcoding_url
1019 .as_deref()
1020 .filter(|_| !track_pinned && audio_stream_index.is_none())
1021 {
1022 if let Some(previous) = adopt_video_play_session(play_session_id.clone()) {
1026 self.stop_transcode(&previous).await;
1027 }
1028 format!(
1032 "{}{}",
1033 self.server_url,
1034 super::device_profile::without_server_chosen_subtitle(transcoding_url)
1035 )
1036 } else {
1037 self.get_video_stream_url(
1041 item_id,
1042 Some(&effective_source_id),
1043 audio_stream_index,
1044 )
1045 .await?
1046 };
1047
1048 StreamSelection {
1049 url,
1050 transport: Transport::Hls,
1055 playback_kind: PlaybackKind::Transcode,
1056 rendition: Some(Rendition {
1057 quality,
1058 max_bitrate: quality.max_bitrate(),
1059 max_height: quality.max_height(),
1060 video_codec: Some("h264".to_string()),
1061 audio_codec: Some("aac".to_string()),
1062 }),
1063 available,
1064 media_source_id: Some(effective_source_id),
1065 play_session_id: Some(play_session_id),
1066 needs_transcoding: PlaybackKind::Transcode.needs_transcoding(),
1067 }
1068 }
1069 kind @ (PlaybackKind::DirectPlay | PlaybackKind::DirectStream) => {
1070 let url = format!(
1075 "{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId={}&ApiKey={}&userId={}",
1076 self.server_url,
1077 item_id,
1078 effective_source_id,
1079 DEVICE_ID,
1080 self.access_token,
1081 self.user_id
1082 );
1083
1084 StreamSelection {
1085 url,
1086 transport: Transport::Progressive,
1090 playback_kind: kind,
1091 rendition: None,
1095 available,
1096 media_source_id: Some(effective_source_id),
1097 play_session_id: Some(play_session_id),
1098 needs_transcoding: kind.needs_transcoding(),
1099 }
1100 }
1101 };
1102
1103 info!(
1104 "[StreamSelection] {} → {:?} over {:?} (source bitrate {:?}, ceiling {})",
1105 item_id,
1106 selection.playback_kind,
1107 selection.transport,
1108 source_bitrate,
1109 quality.label(),
1110 );
1111
1112 Ok(selection)
1113 }
1114}
1115
1116#[derive(Debug, Deserialize)]
1118#[serde(rename_all = "PascalCase")]
1119struct ItemsResponse {
1120 items: Vec<JellyfinItem>,
1121 total_record_count: usize,
1122}
1123
1124#[derive(Debug, Deserialize)]
1126#[serde(rename_all = "PascalCase")]
1127struct CreatePlaylistResponse {
1128 id: String,
1129}
1130
1131#[derive(Debug, Deserialize)]
1133#[serde(rename_all = "PascalCase")]
1134#[allow(dead_code)]
1135struct PlaylistItemsResponse {
1136 items: Vec<JellyfinPlaylistItem>,
1137 total_record_count: usize,
1138}
1139
1140#[derive(Debug, Deserialize)]
1142#[serde(rename_all = "PascalCase")]
1143struct JellyfinPlaylistItem {
1144 playlist_item_id: String,
1145 #[serde(flatten)]
1146 item: JellyfinItem,
1147}
1148
1149#[derive(Debug, Deserialize)]
1150#[serde(rename_all = "PascalCase")]
1151struct JellyfinItem {
1152 id: String,
1153 name: String,
1154 #[serde(rename = "Type")]
1155 item_type: String,
1156 #[serde(default)]
1157 is_folder: bool,
1158 parent_id: Option<String>,
1159 overview: Option<String>,
1160 genres: Option<Vec<String>>,
1161 production_year: Option<i32>,
1162 premiere_date: Option<String>,
1163 community_rating: Option<f64>,
1164 official_rating: Option<String>,
1165 run_time_ticks: Option<i64>,
1166 image_tags: Option<ImageTags>,
1167 backdrop_image_tags: Option<Vec<String>>,
1168 parent_backdrop_image_tags: Option<Vec<String>>,
1169 album_id: Option<String>,
1170 album: Option<String>,
1171 album_artist: Option<String>,
1172 artists: Option<Vec<String>>,
1173 artist_items: Option<Vec<crate::repository::types::ArtistItem>>,
1174 index_number: Option<i32>,
1175 parent_index_number: Option<i32>,
1176 series_id: Option<String>,
1177 series_name: Option<String>,
1178 season_id: Option<String>,
1179 season_name: Option<String>,
1180 media_streams: Option<Vec<JellyfinMediaStream>>,
1181 media_sources: Option<Vec<JellyfinMediaSource>>,
1182 people: Option<Vec<crate::repository::types::Person>>,
1183 user_data: Option<JellyfinUserData>,
1184}
1185
1186#[derive(Debug, Deserialize, Clone)]
1198#[serde(rename_all = "PascalCase")]
1199struct JellyfinUserData {
1200 playback_position_ticks: Option<i64>,
1201 #[serde(rename = "Played")]
1202 is_played: Option<bool>,
1203 is_favorite: Option<bool>,
1204 play_count: Option<i32>,
1205 last_played_date: Option<String>,
1206}
1207
1208impl From<JellyfinUserData> for UserData {
1209 fn from(jf: JellyfinUserData) -> Self {
1210 UserData {
1211 playback_position_ticks: jf.playback_position_ticks,
1212 playback_position_ms: jf.playback_position_ticks.map(crate::domain::ticks_to_ms),
1213 is_played: jf.is_played,
1214 is_favorite: jf.is_favorite,
1215 play_count: jf.play_count,
1216 last_played_date: jf.last_played_date,
1217 playback_context_type: None,
1218 playback_context_id: None,
1219 }
1220 }
1221}
1222
1223#[cfg(test)]
1230fn build_get_items_endpoint(
1231 user_id: &str,
1232 parent_id: &str,
1233 options: Option<&GetItemsOptions>,
1234) -> String {
1235 endpoints::get_items(&ServerCapabilities::assumed(), user_id, parent_id, options)
1236}
1237
1238#[cfg(test)]
1241fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
1242 endpoints::latest_items(&ServerCapabilities::assumed(), user_id, parent_id, limit)
1243}
1244
1245fn latest_items_fetch_limit(limit: usize) -> usize {
1253 limit.saturating_mul(3)
1254}
1255
1256fn collapse_tracks_into_albums(items: Vec<MediaItem>) -> Vec<MediaItem> {
1273 use std::collections::HashSet;
1274
1275 let server_albums: HashSet<String> = items
1278 .iter()
1279 .filter(|i| i.kind == crate::domain::MediaKind::Album)
1280 .map(|i| i.id.clone())
1281 .collect();
1282
1283 let mut seen_albums: HashSet<String> = HashSet::new();
1284 let mut collapsed = Vec::with_capacity(items.len());
1285
1286 for item in items {
1287 let album_id = match (&item.kind, &item.album_id) {
1288 (crate::domain::MediaKind::Track, Some(id)) => id.clone(),
1289 _ => {
1290 collapsed.push(item);
1291 continue;
1292 }
1293 };
1294
1295 if server_albums.contains(&album_id) || !seen_albums.insert(album_id.clone()) {
1296 continue;
1297 }
1298 collapsed.push(album_from_track(&item, album_id));
1299 }
1300
1301 collapsed
1302}
1303
1304fn album_from_track(track: &MediaItem, album_id: String) -> MediaItem {
1312 MediaItem {
1313 id: album_id,
1314 name: track
1315 .album_name
1316 .clone()
1317 .unwrap_or_else(|| "Unknown Album".to_string()),
1318 item_type: "MusicAlbum".to_string(),
1319 kind: crate::domain::MediaKind::Album,
1320 is_folder: true,
1321 server_id: track.server_id.clone(),
1322 parent_id: None,
1323 library_id: track.library_id.clone(),
1324 overview: None,
1325 genres: track.genres.clone(),
1326 production_year: track.production_year,
1327 premiere_date: track.premiere_date.clone(),
1328 community_rating: None,
1329 official_rating: None,
1330 runtime_ticks: None,
1333 duration_ms: None,
1334 primary_image_tag: track.primary_image_tag.clone(),
1335 image_id: track.image_id.clone(),
1336 backdrop_image_tags: track.backdrop_image_tags.clone(),
1337 parent_backdrop_image_tags: track.parent_backdrop_image_tags.clone(),
1338 album_id: None,
1339 album_name: None,
1340 album_artist: track.album_artist.clone(),
1341 artists: track.artists.clone(),
1342 artist_items: track.artist_items.clone(),
1343 index_number: None,
1344 parent_index_number: None,
1345 series_id: None,
1346 series_name: None,
1347 season_id: None,
1348 season_name: None,
1349 user_data: None,
1350 media_streams: None,
1351 media_sources: None,
1352 people: None,
1353 }
1354}
1355
1356#[cfg(test)]
1358fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
1359 endpoints::next_up(&ServerCapabilities::assumed(), user_id, series_id, limit)
1360}
1361
1362#[cfg(test)]
1365fn build_favorites_endpoint(
1366 user_id: &str,
1367 scope: SearchScope,
1368 options: Option<&GetItemsOptions>,
1369) -> String {
1370 endpoints::favorites(&ServerCapabilities::assumed(), user_id, scope, options)
1371}
1372
1373#[derive(Debug, Deserialize)]
1376#[serde(untagged)]
1377enum ImageTags {
1378 Map(std::collections::HashMap<String, String>),
1380 Structured {
1382 #[serde(rename = "Primary")]
1383 primary: Option<String>,
1384 },
1385}
1386
1387impl ImageTags {
1388 fn primary(&self) -> Option<String> {
1389 match self {
1390 ImageTags::Map(map) => map.get("Primary").cloned(),
1391 ImageTags::Structured { primary } => primary.clone(),
1392 }
1393 }
1394}
1395
1396#[derive(Debug, Deserialize, Clone)]
1397#[serde(rename_all = "PascalCase")]
1398struct JellyfinMediaStream {
1399 #[serde(rename = "Type")]
1400 stream_type: String,
1401 codec: Option<String>,
1402 language: Option<String>,
1403 display_title: Option<String>,
1404 index: i32,
1405 is_default: bool,
1406 #[serde(default)]
1407 is_forced: bool,
1408}
1409
1410#[derive(Debug, Deserialize, Clone)]
1411#[serde(rename_all = "PascalCase")]
1412struct JellyfinMediaSource {
1413 id: String,
1414 name: String,
1415 container: Option<String>,
1416 size: Option<i64>,
1417 bitrate: Option<i32>,
1418 supports_direct_play: bool,
1419 supports_direct_stream: bool,
1420 supports_transcoding: bool,
1421 direct_stream_url: Option<String>,
1422}
1423
1424impl JellyfinItem {
1425 fn into_media_item(self, server_id: String) -> MediaItem {
1426 let primary_tag = self.image_tags.as_ref().and_then(|tags| tags.primary());
1428 let backdrop_tags = self.backdrop_image_tags;
1429
1430 let kind = crate::domain::kind_from_jellyfin(&self.item_type, self.is_folder);
1431
1432 MediaItem {
1433 id: self.id,
1434 name: self.name,
1435 item_type: self.item_type,
1436 kind,
1437 is_folder: self.is_folder,
1438 server_id,
1439 parent_id: self.parent_id,
1440 library_id: None, overview: self.overview,
1442 genres: self.genres,
1443 production_year: self.production_year,
1444 premiere_date: self.premiere_date,
1445 community_rating: self.community_rating,
1446 official_rating: self.official_rating,
1447 runtime_ticks: self.run_time_ticks,
1448 duration_ms: self.run_time_ticks.map(crate::domain::ticks_to_ms),
1449 primary_image_tag: primary_tag.clone(),
1450 image_id: primary_tag,
1451 backdrop_image_tags: backdrop_tags,
1452 parent_backdrop_image_tags: self.parent_backdrop_image_tags,
1453 album_id: self.album_id,
1454 album_name: self.album,
1455 album_artist: self.album_artist,
1456 artists: self.artists,
1457 artist_items: self.artist_items,
1458 index_number: self.index_number,
1459 parent_index_number: self.parent_index_number,
1460 series_id: self.series_id,
1461 series_name: self.series_name,
1462 season_id: self.season_id,
1463 season_name: self.season_name,
1464 user_data: self.user_data.map(UserData::from),
1467 media_streams: self.media_streams.map(|streams| {
1468 streams
1469 .into_iter()
1470 .map(|s| {
1471 let kind = crate::domain::stream_kind_from_jellyfin(&s.stream_type);
1472 let supports_external_delivery =
1476 (kind == crate::domain::StreamKind::Subtitle).then(|| {
1477 super::device_profile::subtitle_supports_external_delivery(
1478 s.codec.as_deref(),
1479 )
1480 });
1481 crate::repository::types::MediaStream {
1482 kind,
1483 stream_type: s.stream_type,
1484 codec: s.codec,
1485 language: s.language,
1486 display_title: s.display_title,
1487 index: s.index,
1488 is_default: s.is_default,
1489 is_forced: s.is_forced,
1490 supports_external_delivery,
1491 }
1492 })
1493 .collect()
1494 }),
1495 media_sources: self.media_sources.map(|sources| {
1496 sources
1497 .into_iter()
1498 .map(|s| crate::repository::types::MediaSource {
1499 id: s.id,
1500 name: s.name,
1501 container: s.container,
1502 size: s.size,
1503 bitrate: s.bitrate,
1504 supports_direct_play: s.supports_direct_play,
1505 supports_direct_stream: s.supports_direct_stream,
1506 supports_transcoding: s.supports_transcoding,
1507 direct_stream_url: s.direct_stream_url,
1508 })
1509 .collect()
1510 }),
1511 people: self.people,
1512 }
1513 }
1514}
1515
1516#[derive(Debug, Serialize)]
1529#[serde(rename_all = "PascalCase")]
1530struct PlaybackInfoRequest {
1531 user_id: String,
1532 #[serde(skip_serializing_if = "Option::is_none")]
1536 audio_stream_index: Option<i32>,
1537 #[serde(skip_serializing_if = "Option::is_none")]
1538 subtitle_stream_index: Option<i32>,
1539 start_time_ticks: i64,
1540 is_playback: bool,
1541 auto_open_live_stream: bool,
1542 max_streaming_bitrate: i64,
1543 #[serde(skip_serializing_if = "Option::is_none")]
1544 device_profile: Option<DeviceProfile>,
1545}
1546
1547#[derive(Debug, Serialize)]
1548#[serde(rename_all = "PascalCase")]
1549struct DeviceProfile {
1550 name: String,
1551 max_streaming_bitrate: i64,
1552 max_static_bitrate: i64,
1553 max_audio_channels: String,
1557 direct_play_profiles: Vec<DirectPlayProfile>,
1558 transcoding_profiles: Vec<TranscodingProfile>,
1559 subtitle_profiles: Vec<SubtitleProfile>,
1560}
1561
1562#[derive(Debug, Serialize)]
1563#[serde(rename_all = "PascalCase")]
1564struct DirectPlayProfile {
1565 #[serde(rename = "Type")]
1566 profile_type: String,
1567 container: String,
1568 #[serde(skip_serializing_if = "Option::is_none")]
1569 video_codec: Option<String>,
1570 audio_codec: String,
1571}
1572
1573#[derive(Debug, Serialize)]
1574#[serde(rename_all = "PascalCase")]
1575struct TranscodingProfile {
1576 #[serde(rename = "Type")]
1577 profile_type: String,
1578 context: String,
1579 protocol: String,
1580 container: String,
1581 #[serde(skip_serializing_if = "Option::is_none")]
1582 video_codec: Option<String>,
1583 audio_codec: String,
1584 max_audio_channels: String,
1585}
1586
1587#[derive(Debug, Serialize)]
1588#[serde(rename_all = "PascalCase")]
1589struct SubtitleProfile {
1590 format: String,
1591 method: String,
1592}
1593
1594#[derive(Debug, Deserialize)]
1595#[serde(rename_all = "PascalCase")]
1596struct PlaybackInfoResponse {
1597 media_sources: Vec<NegotiatedSource>,
1598 play_session_id: String,
1599}
1600
1601#[derive(Debug, Deserialize)]
1602#[serde(rename_all = "PascalCase")]
1603pub struct NegotiatedSource {
1604 pub id: String,
1605 pub supports_direct_play: bool,
1606 #[serde(default)]
1612 pub supports_direct_stream: bool,
1613 pub supports_transcoding: bool,
1614 pub transcoding_url: Option<String>,
1615 #[serde(default)]
1622 pub bitrate: Option<i64>,
1623 #[serde(default)]
1624 pub media_streams: Vec<NegotiatedStream>,
1625}
1626
1627#[derive(Debug, Deserialize)]
1628#[serde(rename_all = "PascalCase")]
1629pub struct NegotiatedStream {
1630 #[serde(rename = "Type")]
1631 stream_type: String,
1632 #[serde(default)]
1633 index: i32,
1634 #[serde(default)]
1635 codec: Option<String>,
1636 #[serde(default)]
1638 is_default: bool,
1639}
1640
1641pub fn decide_playback_kind(
1655 source: &NegotiatedSource,
1656 audio_forces_transcode: bool,
1657 audio_track_pinned: bool,
1658) -> PlaybackKind {
1659 if audio_forces_transcode {
1660 warn!(
1661 "[StreamSelection] Server offered direct play for audio this renderer cannot decode — forcing a transcode"
1662 );
1663 return PlaybackKind::Transcode;
1664 }
1665 if audio_track_pinned {
1666 return PlaybackKind::Transcode;
1669 }
1670 if source.supports_direct_play {
1671 PlaybackKind::DirectPlay
1672 } else if source.supports_direct_stream {
1673 PlaybackKind::DirectStream
1674 } else {
1675 PlaybackKind::Transcode
1676 }
1677}
1678
1679#[async_trait]
1680impl MediaRepository for OnlineRepository {
1681 async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
1682 #[derive(Debug, Deserialize)]
1683 #[serde(rename_all = "PascalCase")]
1684 struct LibrariesResponse {
1685 items: Vec<JellyfinLibrary>,
1686 }
1687
1688 #[derive(Debug, Deserialize)]
1689 #[serde(rename_all = "PascalCase")]
1690 struct JellyfinLibrary {
1691 id: String,
1692 name: String,
1693 collection_type: Option<String>,
1694 image_tags: Option<ImageTags>,
1695 }
1696
1697 let endpoint = endpoints::user_views(&self.capabilities, &self.user_id);
1698 let response: LibrariesResponse = self.get_json(&endpoint).await?;
1699
1700 Ok(response
1701 .items
1702 .into_iter()
1703 .map(|lib| {
1704 Library::new(
1705 lib.id,
1706 lib.name,
1707 lib.collection_type.unwrap_or_else(|| "unknown".to_string()),
1708 lib.image_tags.and_then(|tags| tags.primary()),
1709 )
1710 })
1711 .collect())
1712 }
1713
1714 async fn get_items(
1715 &self,
1716 parent_id: &str,
1717 options: Option<GetItemsOptions>,
1718 ) -> Result<SearchResult, RepoError> {
1719 let endpoint = endpoints::get_items(
1720 &self.capabilities,
1721 &self.user_id,
1722 parent_id,
1723 options.as_ref(),
1724 );
1725
1726 let response: ItemsResponse = self.get_json(&endpoint).await?;
1727
1728 Ok(SearchResult {
1729 items: response
1730 .items
1731 .into_iter()
1732 .map(|item| item.into_media_item(self.user_id.clone()))
1733 .collect(),
1734 total_record_count: response.total_record_count,
1735 })
1736 }
1737
1738 async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
1750 let endpoint = endpoints::item_detail(&self.capabilities, &self.user_id, item_id);
1751
1752 let item: JellyfinItem = self.get_json(&endpoint).await?;
1753 let media_item = item.into_media_item(self.user_id.clone());
1754
1755 Ok(media_item)
1756 }
1757
1758 async fn get_latest_items(
1766 &self,
1767 parent_id: &str,
1768 limit: Option<usize>,
1769 ) -> Result<Vec<MediaItem>, RepoError> {
1770 let limit_val = limit.unwrap_or(16);
1771 let endpoint = endpoints::latest_items(
1772 &self.capabilities,
1773 &self.user_id,
1774 parent_id,
1775 Some(latest_items_fetch_limit(limit_val)),
1776 );
1777
1778 let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
1779 let items = items
1780 .into_iter()
1781 .map(|item| item.into_media_item(self.user_id.clone()))
1782 .collect();
1783
1784 let mut collapsed = collapse_tracks_into_albums(items);
1785 collapsed.truncate(limit_val);
1786 Ok(collapsed)
1787 }
1788
1789 async fn get_resume_items(
1798 &self,
1799 parent_id: Option<&str>,
1800 limit: Option<usize>,
1801 ) -> Result<Vec<MediaItem>, RepoError> {
1802 let endpoint = endpoints::resume_items(
1803 &self.capabilities,
1804 &self.user_id,
1805 limit.unwrap_or(16),
1806 None,
1807 parent_id,
1808 );
1809
1810 let response: ItemsResponse = self.get_json(&endpoint).await?;
1811 Ok(response
1812 .items
1813 .into_iter()
1814 .map(|item| item.into_media_item(self.user_id.clone()))
1815 .collect())
1816 }
1817
1818 async fn get_next_up_episodes(
1823 &self,
1824 series_id: Option<&str>,
1825 limit: Option<usize>,
1826 ) -> Result<Vec<MediaItem>, RepoError> {
1827 let endpoint = endpoints::next_up(&self.capabilities, &self.user_id, series_id, limit);
1828
1829 let response: ItemsResponse = self.get_json(&endpoint).await?;
1830 Ok(response
1831 .items
1832 .into_iter()
1833 .map(|item| item.into_media_item(self.user_id.clone()))
1834 .collect())
1835 }
1836
1837 async fn get_recently_played_audio(
1838 &self,
1839 limit: Option<usize>,
1840 ) -> Result<Vec<MediaItem>, RepoError> {
1841 let limit_val = limit.unwrap_or(12);
1842 let fetch_limit = limit_val * 3;
1844 let endpoint = endpoints::played_items_by_date(
1845 &self.capabilities,
1846 &self.user_id,
1847 "Audio",
1848 fetch_limit,
1849 "Descending",
1850 None,
1851 );
1852
1853 let response: ItemsResponse = self.get_json(&endpoint).await?;
1854 let items: Vec<MediaItem> = response
1855 .items
1856 .into_iter()
1857 .map(|item| item.into_media_item(self.user_id.clone()))
1858 .collect();
1859
1860 debug!("[get_recently_played_audio] Fetched {} items", items.len());
1861 for item in &items {
1862 debug!("[get_recently_played_audio] Item: name={}, type={}, album_id={:?}, album_name={:?}",
1863 item.name, item.item_type, item.album_id, item.album_name);
1864 }
1865
1866 use std::collections::BTreeMap;
1868 let mut album_map: BTreeMap<String, Vec<MediaItem>> = BTreeMap::new();
1869 let mut ungrouped = Vec::new();
1870
1871 for item in items {
1872 let group_key = item.album_id.clone().or_else(|| item.album_name.clone());
1874
1875 if let Some(key) = group_key {
1876 debug!(
1877 "[get_recently_played_audio] Grouping item '{}' into album '{}'",
1878 item.name, key
1879 );
1880 album_map.entry(key).or_default().push(item);
1881 } else {
1882 debug!(
1883 "[get_recently_played_audio] No album_id or album_name for item: '{}'",
1884 item.name
1885 );
1886 ungrouped.push(item);
1887 }
1888 }
1889
1890 let mut result: Vec<MediaItem> = album_map
1892 .into_iter()
1893 .map(|(album_id, tracks)| {
1894 let first_track = &tracks[0];
1895 let most_recent = tracks
1896 .iter()
1897 .max_by(|a, b| {
1898 let date_a = a
1899 .user_data
1900 .as_ref()
1901 .and_then(|ud| ud.last_played_date.as_deref())
1902 .unwrap_or("");
1903 let date_b = b
1904 .user_data
1905 .as_ref()
1906 .and_then(|ud| ud.last_played_date.as_deref())
1907 .unwrap_or("");
1908 date_b.cmp(date_a)
1909 })
1910 .unwrap_or(first_track);
1911
1912 MediaItem {
1913 id: album_id,
1914 name: first_track
1915 .album_name
1916 .clone()
1917 .unwrap_or_else(|| "Unknown Album".to_string()),
1918 item_type: "MusicAlbum".to_string(),
1919 kind: crate::domain::MediaKind::Album,
1920 is_folder: true,
1921 server_id: first_track.server_id.clone(),
1922 parent_id: None,
1923 library_id: None,
1924 overview: None,
1925 genres: None,
1926 production_year: None,
1927 premiere_date: None,
1928 community_rating: None,
1929 official_rating: None,
1930 runtime_ticks: None,
1931 duration_ms: None,
1932 primary_image_tag: first_track.primary_image_tag.clone(),
1933 image_id: first_track.primary_image_tag.clone(),
1934 backdrop_image_tags: None,
1935 parent_backdrop_image_tags: None,
1936 album_id: None,
1937 album_name: None,
1938 album_artist: None,
1939 artists: first_track.artists.clone(),
1940 artist_items: first_track.artist_items.clone(),
1941 index_number: None,
1942 parent_index_number: None,
1943 series_id: None,
1944 series_name: None,
1945 season_id: None,
1946 season_name: None,
1947 user_data: most_recent.user_data.clone(),
1948 media_streams: None,
1949 media_sources: None,
1950 people: None,
1951 }
1952 })
1953 .collect();
1954
1955 result.extend(ungrouped);
1957
1958 let final_result: Vec<MediaItem> = result.into_iter().take(limit_val).collect();
1960 debug!(
1961 "[get_recently_played_audio] Returning {} items after grouping",
1962 final_result.len()
1963 );
1964 for item in &final_result {
1965 debug!(
1966 "[get_recently_played_audio] Return: name={}, type={}",
1967 item.name, item.item_type
1968 );
1969 }
1970 Ok(final_result)
1971 }
1972
1973 async fn get_rediscover_albums(
1974 &self,
1975 parent_id: Option<&str>,
1976 limit: Option<usize>,
1977 ) -> Result<Vec<MediaItem>, RepoError> {
1978 let limit_val = limit.unwrap_or(12);
1979 let endpoint = endpoints::played_items_by_date(
1983 &self.capabilities,
1984 &self.user_id,
1985 "MusicAlbum",
1986 limit_val,
1987 "Ascending",
1988 parent_id,
1989 );
1990
1991 let response: ItemsResponse = self.get_json(&endpoint).await?;
1992 Ok(response
1993 .items
1994 .into_iter()
1995 .map(|item| item.into_media_item(self.user_id.clone()))
1996 .collect())
1997 }
1998
1999 async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
2005 let endpoint = endpoints::resume_items(
2006 &self.capabilities,
2007 &self.user_id,
2008 limit.unwrap_or(16),
2009 Some("Movie"),
2010 None,
2011 );
2012
2013 let response: ItemsResponse = self.get_json(&endpoint).await?;
2014 Ok(response
2015 .items
2016 .into_iter()
2017 .map(|item| item.into_media_item(self.user_id.clone()))
2018 .collect())
2019 }
2020
2021 async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
2022 let endpoint =
2025 endpoints::genres(&self.capabilities, &self.user_id, "MusicAlbum", parent_id);
2026
2027 #[derive(Debug, Deserialize)]
2028 #[serde(rename_all = "PascalCase")]
2029 struct GenresResponse {
2030 items: Vec<JellyfinGenre>,
2031 }
2032
2033 #[derive(Debug, Deserialize)]
2034 #[serde(rename_all = "PascalCase")]
2035 struct JellyfinGenre {
2036 id: String,
2037 name: String,
2038 album_count: Option<u32>,
2044 child_count: Option<u32>,
2045 }
2046
2047 let response: GenresResponse = self.get_json(&endpoint).await?;
2048 let genres: Vec<Genre> = response
2049 .items
2050 .into_iter()
2051 .map(|g| Genre {
2052 id: g.id,
2053 name: g.name,
2054 album_count: g.album_count.or(g.child_count),
2055 })
2056 .collect();
2057
2058 let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
2059 log::warn!(
2062 "get_genres: {} genres, {} carry counts. sample: {:?}",
2063 genres.len(),
2064 with_counts,
2065 genres
2066 .iter()
2067 .take(8)
2068 .map(|g| (g.name.as_str(), g.album_count))
2069 .collect::<Vec<_>>()
2070 );
2071
2072 Ok(genres)
2073 }
2074
2075 async fn search(
2084 &self,
2085 query: &str,
2086 options: Option<SearchOptions>,
2087 ) -> Result<SearchResult, RepoError> {
2088 let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50);
2089 let endpoint = endpoints::search(
2093 &self.capabilities,
2094 &self.user_id,
2095 query,
2096 limit,
2097 options.and_then(|o| o.include_item_types).as_deref(),
2098 );
2099
2100 let response: ItemsResponse = self.get_json(&endpoint).await?;
2101 Ok(SearchResult {
2102 items: response
2103 .items
2104 .into_iter()
2105 .map(|item| item.into_media_item(self.user_id.clone()))
2106 .collect(),
2107 total_record_count: response.total_record_count,
2108 })
2109 }
2110
2111 async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
2112 let (source, play_session_id) = self.negotiate_playback(item_id).await?;
2113
2114 info!(
2116 "PlaybackInfo MediaSource has {} streams",
2117 source.media_streams.len()
2118 );
2119 for stream in &source.media_streams {
2120 info!(
2121 " Stream type={}, index={}, codec={:?}",
2122 stream.stream_type, stream.index, stream.codec
2123 );
2124 }
2125
2126 for stream in &source.media_streams {
2131 if stream.stream_type == "Subtitle" {
2132 if let Some(codec) = stream.codec.as_deref() {
2133 if super::device_profile::subtitle_forces_burn_in(codec) {
2134 info!(
2135 " Subtitle index={} ({}) is image-based — not requested; the app renders text tracks itself rather than have the server burn it in (which would force a video re-encode)",
2136 stream.index, codec
2137 );
2138 }
2139 }
2140 }
2141 }
2142
2143 let audio_streams: Vec<(Option<&str>, bool)> = source
2149 .media_streams
2150 .iter()
2151 .filter(|stream| stream.stream_type == "Audio")
2152 .map(|stream| (stream.codec.as_deref(), stream.is_default))
2153 .collect();
2154 let audio_forces_transcode = super::device_profile::audio_forces_transcode(&audio_streams);
2155
2156 let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
2158 if let Some(previous) = adopt_video_play_session(play_session_id.clone()) {
2162 self.stop_transcode(&previous).await;
2163 }
2164 format!(
2170 "{}{}",
2171 self.server_url,
2172 super::device_profile::without_server_chosen_subtitle(transcoding_url)
2173 )
2174 } else if audio_forces_transcode {
2175 warn!(
2176 "[PlaybackInfo] Server offered direct play for audio the webview cannot decode ({:?}) — forcing an HLS transcode",
2177 audio_streams.first().and_then(|(codec, _)| *codec)
2178 );
2179 self.get_video_stream_url(item_id, Some(&source.id), None)
2180 .await?
2181 } else {
2182 format!(
2186 "{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&ApiKey={}&userId={}",
2187 self.server_url,
2188 item_id,
2189 source.id,
2190 self.access_token,
2191 self.user_id
2192 )
2193 };
2194
2195 info!("Final stream URL: {}", stream_url);
2196
2197 Ok(PlaybackInfo {
2198 media_source_id: source.id.clone(),
2199 play_session_id,
2200 stream_url,
2201 direct_play: source.supports_direct_play && !audio_forces_transcode,
2202 needs_transcoding: audio_forces_transcode
2203 || (!source.supports_direct_play && source.supports_transcoding),
2204 })
2205 }
2206
2207 async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
2208 let url = format!(
2210 "{}/Audio/{}/stream?UserId={}&ApiKey={}&Static=true",
2211 self.server_url, item_id, self.user_id, self.access_token
2212 );
2213 Ok(url)
2214 }
2215
2216 async fn get_audio_only_stream_url_for_video(
2217 &self,
2218 item_id: &str,
2219 media_source_id: Option<&str>,
2220 start_time_seconds: Option<f64>,
2221 audio_stream_index: Option<i32>,
2222 ) -> Result<String, RepoError> {
2223 self.build_audio_only_stream_url_for_video(
2224 item_id,
2225 media_source_id,
2226 start_time_seconds,
2227 audio_stream_index,
2228 )
2229 .await
2230 }
2231
2232 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
2233 let endpoint = endpoints::live_tv_channels(&self.capabilities, &self.user_id);
2236 let response: ItemsResponse = self.get_json(&endpoint).await?;
2237 Ok(response
2238 .items
2239 .into_iter()
2240 .map(|item| item.into_media_item(self.server_url.clone()))
2241 .collect())
2242 }
2243
2244 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
2245 let endpoint = endpoints::channels(&self.capabilities, &self.user_id);
2248 let response: ItemsResponse = self.get_json(&endpoint).await?;
2249 let total = response.total_record_count;
2250 let items = response
2251 .items
2252 .into_iter()
2253 .map(|item| item.into_media_item(self.server_url.clone()))
2254 .collect();
2255 Ok(SearchResult {
2256 items,
2257 total_record_count: total,
2258 })
2259 }
2260
2261 async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
2262 #[derive(Debug, Serialize)]
2266 #[serde(rename_all = "PascalCase")]
2267 struct OpenLiveStreamRequest {
2268 user_id: String,
2269 #[serde(rename = "AutoOpenLiveStream")]
2270 auto_open_live_stream: bool,
2271 is_playback: bool,
2272 max_streaming_bitrate: u64,
2273 subtitle_stream_index: i32,
2280 }
2281
2282 #[derive(Debug, Deserialize)]
2283 #[serde(rename_all = "PascalCase")]
2284 struct OpenLiveStreamResponse {
2285 #[serde(default)]
2286 media_sources: Vec<LiveMediaSource>,
2287 play_session_id: Option<String>,
2288 }
2289
2290 #[derive(Debug, Deserialize)]
2291 #[serde(rename_all = "PascalCase")]
2292 struct LiveMediaSource {
2293 id: String,
2294 transcoding_url: Option<String>,
2295 live_stream_id: Option<String>,
2296 }
2297
2298 let endpoint = endpoints::playback_info(&self.capabilities, item_id);
2299 let request = OpenLiveStreamRequest {
2300 user_id: self.user_id.clone(),
2301 auto_open_live_stream: true,
2302 is_playback: true,
2303 max_streaming_bitrate: effective_streaming_quality()
2307 .max_bitrate()
2308 .unwrap_or(20_000_000),
2309 subtitle_stream_index: super::device_profile::playback_subtitle_stream_index(),
2310 };
2311
2312 let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?;
2313
2314 let source = response
2315 .media_sources
2316 .into_iter()
2317 .next()
2318 .ok_or(RepoError::NotFound {
2319 message: "No live media source returned".to_string(),
2320 })?;
2321
2322 let stream_url = match source.transcoding_url {
2325 Some(url) => format!(
2328 "{}{}",
2329 self.server_url,
2330 super::device_profile::without_server_chosen_subtitle(&url)
2331 ),
2332 None => format!(
2333 "{}/Videos/{}/master.m3u8?ApiKey={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts&SubtitleStreamIndex={}",
2334 self.server_url,
2335 item_id,
2336 self.access_token,
2337 source.id,
2338 source.live_stream_id.clone().unwrap_or_default(),
2339 super::device_profile::playback_subtitle_stream_index(),
2340 ),
2341 };
2342
2343 Ok(LiveStreamInfo {
2344 stream_url,
2345 play_session_id: response.play_session_id,
2346 live_stream_id: source.live_stream_id,
2347 media_source_id: Some(source.id),
2348 transport: Transport::Hls,
2351 })
2352 }
2353
2354 async fn report_playback_start(
2355 &self,
2356 item_id: &str,
2357 position_ticks: i64,
2358 ) -> Result<(), RepoError> {
2359 #[derive(Serialize)]
2360 #[serde(rename_all = "PascalCase")]
2361 struct PlaybackStartRequest {
2362 item_id: String,
2363 position_ticks: i64,
2364 play_command: String,
2365 is_paused: bool,
2366 }
2367
2368 let request = PlaybackStartRequest {
2369 item_id: item_id.to_string(),
2370 position_ticks,
2371 play_command: "PlayNow".to_string(),
2372 is_paused: false,
2373 };
2374
2375 self.post_json(endpoints::sessions_playing(&self.capabilities), &request)
2376 .await
2377 }
2378
2379 async fn report_playback_progress(
2380 &self,
2381 item_id: &str,
2382 position_ticks: i64,
2383 ) -> Result<(), RepoError> {
2384 #[derive(Serialize)]
2385 #[serde(rename_all = "PascalCase")]
2386 struct PlaybackProgressRequest {
2387 item_id: String,
2388 position_ticks: i64,
2389 is_paused: bool,
2390 }
2391
2392 let request = PlaybackProgressRequest {
2393 item_id: item_id.to_string(),
2394 position_ticks,
2395 is_paused: false,
2396 };
2397
2398 self.post_json(
2399 endpoints::sessions_playing_progress(&self.capabilities),
2400 &request,
2401 )
2402 .await
2403 }
2404
2405 async fn report_playback_stopped(
2406 &self,
2407 item_id: &str,
2408 position_ticks: i64,
2409 ) -> Result<(), RepoError> {
2410 #[derive(Serialize)]
2411 #[serde(rename_all = "PascalCase")]
2412 struct PlaybackStoppedRequest {
2413 item_id: String,
2414 position_ticks: i64,
2415 }
2416
2417 let request = PlaybackStoppedRequest {
2418 item_id: item_id.to_string(),
2419 position_ticks,
2420 };
2421
2422 self.post_json(
2423 endpoints::sessions_playing_stopped(&self.capabilities),
2424 &request,
2425 )
2426 .await
2427 }
2428
2429 fn get_image_url(
2430 &self,
2431 item_id: &str,
2432 image_type: ImageType,
2433 options: Option<ImageOptions>,
2434 ) -> String {
2435 let mut url = format!(
2436 "{}/Items/{}/Images/{}",
2437 self.server_url,
2438 item_id,
2439 image_type.as_str()
2440 );
2441
2442 let mut params: Vec<String> = Vec::new();
2447
2448 if let Some(opts) = options {
2449 if let Some(width) = opts.max_width {
2450 params.push(format!("maxWidth={}", width));
2451 }
2452 if let Some(height) = opts.max_height {
2453 params.push(format!("maxHeight={}", height));
2454 }
2455 if let Some(quality) = opts.quality {
2456 params.push(format!("quality={}", quality));
2457 }
2458 if let Some(tag) = opts.tag {
2459 params.push(format!("tag={}", tag));
2460 }
2461 }
2462
2463 if !params.is_empty() {
2464 url.push('?');
2465 url.push_str(¶ms.join("&"));
2466 }
2467
2468 url
2469 }
2470
2471 fn get_subtitle_url(
2472 &self,
2473 item_id: &str,
2474 media_source_id: &str,
2475 stream_index: i32,
2476 format: &str,
2477 ) -> String {
2478 format!(
2486 "{}/Videos/{}/{}/Subtitles/{}/Stream.{}",
2487 self.server_url, item_id, media_source_id, stream_index, format
2488 )
2489 }
2490
2491 fn get_video_download_url(
2493 &self,
2494 item_id: &str,
2495 quality: &str,
2496 media_source_id: Option<&str>,
2497 source_audio_codec: Option<&str>,
2498 ) -> String {
2499 let mut url = format!("{}/Videos/{}/stream.mp4", self.server_url, item_id);
2505 let mut params = vec![format!("ApiKey={}", self.access_token)];
2506
2507 match crate::download::presets::download_preset(quality) {
2524 Some(preset) => {
2525 params.push(format!("videoBitRate={}", preset.video_bit_rate));
2526 params.push(format!("maxHeight={}", preset.max_height));
2527 params.push(format!("audioBitRate={}", preset.audio_bit_rate));
2528 params.push("videoCodec=h264".to_string());
2529 params.push("audioCodec=aac".to_string());
2530 params.push("allowVideoStreamCopy=false".to_string());
2531 }
2532 None => match source_audio_codec {
2554 Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
2555 params.push("videoCodec=h264".to_string());
2556 params.push("allowVideoStreamCopy=true".to_string());
2557 params.push("audioCodec=aac".to_string());
2558 params.push("audioBitRate=384000".to_string());
2559 }
2560 _ => params.push("Static=true".to_string()),
2564 },
2565 }
2566
2567 if let Some(source_id) = media_source_id {
2569 params.push(format!("mediaSourceId={}", source_id));
2570 }
2571
2572 url.push('?');
2573 url.push_str(¶ms.join("&"));
2574
2575 url
2576 }
2577
2578 async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
2579 let endpoint = endpoints::favorite_item(&self.capabilities, &self.user_id, item_id);
2580 self.post_json(&endpoint, &serde_json::json!({})).await
2581 }
2582
2583 async fn get_favorites(
2585 &self,
2586 scope: SearchScope,
2587 options: Option<GetItemsOptions>,
2588 ) -> Result<SearchResult, RepoError> {
2589 let endpoint =
2590 endpoints::favorites(&self.capabilities, &self.user_id, scope, options.as_ref());
2591 let response: ItemsResponse = self.get_json(&endpoint).await?;
2592
2593 Ok(SearchResult {
2594 items: response
2595 .items
2596 .into_iter()
2597 .map(|item| item.into_media_item(self.user_id.clone()))
2598 .collect(),
2599 total_record_count: response.total_record_count,
2600 })
2601 }
2602
2603 async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
2610 let endpoint = endpoints::favorite_item(&self.capabilities, &self.user_id, item_id);
2611 let url = format!("{}{}", self.server_url, endpoint);
2612
2613 let result = async {
2614 let request = self
2615 .http_client
2616 .client
2617 .delete(&url)
2618 .header("Authorization", self.auth_header())
2619 .build()
2620 .map_err(|e| RepoError::Network {
2621 message: format!("Failed to build request: {}", e),
2622 })?;
2623
2624 let response = self
2625 .http_client
2626 .request_with_retry(request)
2627 .await
2628 .map_err(|e| RepoError::Network {
2629 message: e.to_string(),
2630 })?;
2631
2632 if !response.status().is_success() {
2633 return Err(RepoError::Server {
2634 message: format!("HTTP {}", response.status()),
2635 });
2636 }
2637
2638 Ok(())
2639 }
2640 .await;
2641
2642 self.report_outcome(&result).await;
2643 result
2644 }
2645
2646 async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
2652 let endpoint = endpoints::played_item(&self.capabilities, &self.user_id, item_id);
2653 let url = format!("{}{}", self.server_url, endpoint);
2654
2655 let result = async {
2656 let request = self
2657 .http_client
2658 .client
2659 .delete(&url)
2660 .header("Authorization", self.auth_header())
2661 .build()
2662 .map_err(|e| RepoError::Network {
2663 message: format!("Failed to build request: {}", e),
2664 })?;
2665
2666 let response = self
2667 .http_client
2668 .request_with_retry(request)
2669 .await
2670 .map_err(|e| RepoError::Network {
2671 message: e.to_string(),
2672 })?;
2673
2674 if !response.status().is_success() {
2675 return Err(RepoError::Server {
2676 message: format!("HTTP {}", response.status()),
2677 });
2678 }
2679
2680 Ok(())
2681 }
2682 .await;
2683
2684 self.report_outcome(&result).await;
2685 result
2686 }
2687
2688 async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
2693 let endpoint = endpoints::played_item(&self.capabilities, &self.user_id, item_id);
2694 let url = format!("{}{}", self.server_url, endpoint);
2695
2696 let result = async {
2697 let request = self
2698 .http_client
2699 .client
2700 .post(&url)
2701 .header("Authorization", self.auth_header())
2702 .header("Content-Length", "0")
2703 .build()
2704 .map_err(|e| RepoError::Network {
2705 message: format!("Failed to build request: {}", e),
2706 })?;
2707
2708 let response = self
2709 .http_client
2710 .request_with_retry(request)
2711 .await
2712 .map_err(|e| RepoError::Network {
2713 message: e.to_string(),
2714 })?;
2715
2716 if !response.status().is_success() {
2717 return Err(RepoError::Server {
2718 message: format!("HTTP {}", response.status()),
2719 });
2720 }
2721
2722 Ok(())
2723 }
2724 .await;
2725
2726 self.report_outcome(&result).await;
2727 result
2728 }
2729
2730 async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
2738 let endpoint = endpoints::person(&self.capabilities, &self.user_id, person_id);
2739 let item: JellyfinItem = self.get_json(&endpoint).await?;
2740 Ok(item.into_media_item(self.user_id.clone()))
2741 }
2742
2743 async fn get_items_by_person(
2747 &self,
2748 person_id: &str,
2749 options: Option<GetItemsOptions>,
2750 ) -> Result<SearchResult, RepoError> {
2751 let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
2752
2753 let endpoint = endpoints::items_by_person(
2754 &self.capabilities,
2755 &self.user_id,
2756 person_id,
2757 limit,
2758 options
2759 .as_ref()
2760 .and_then(|o| o.include_item_types.as_deref()),
2761 );
2762
2763 let response: ItemsResponse = self.get_json(&endpoint).await?;
2764 Ok(SearchResult {
2765 items: response
2766 .items
2767 .into_iter()
2768 .map(|item| item.into_media_item(self.user_id.clone()))
2769 .collect(),
2770 total_record_count: response.total_record_count,
2771 })
2772 }
2773
2774 async fn get_similar_items(
2775 &self,
2776 item_id: &str,
2777 limit: Option<usize>,
2778 ) -> Result<SearchResult, RepoError> {
2779 let limit_str = limit.unwrap_or(20);
2780
2781 let endpoint =
2783 endpoints::similar_items(&self.capabilities, item_id, &self.user_id, limit_str);
2784
2785 let response: ItemsResponse = self.get_json(&endpoint).await?;
2786 Ok(SearchResult {
2787 items: response
2788 .items
2789 .into_iter()
2790 .map(|item| item.into_media_item(self.user_id.clone()))
2791 .collect(),
2792 total_record_count: response.total_record_count,
2793 })
2794 }
2795
2796 async fn create_playlist(
2799 &self,
2800 name: &str,
2801 item_ids: &[String],
2802 ) -> Result<PlaylistCreatedResult, RepoError> {
2803 info!(
2804 "[OnlineRepo] Creating playlist '{}' with {} items",
2805 name,
2806 item_ids.len()
2807 );
2808 let body = serde_json::json!({
2809 "Name": name,
2810 "Ids": item_ids,
2811 "MediaType": "Audio",
2812 "UserId": self.user_id,
2813 });
2814 let response: CreatePlaylistResponse = self
2815 .post_json_response(endpoints::playlists(&self.capabilities), &body)
2816 .await?;
2817 Ok(PlaylistCreatedResult { id: response.id })
2818 }
2819
2820 async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
2821 info!("[OnlineRepo] Deleting playlist {}", playlist_id);
2822 let endpoint = endpoints::playlist_as_item(&self.capabilities, playlist_id);
2823 let url = format!("{}{}", self.server_url, endpoint);
2824
2825 let request = self
2826 .http_client
2827 .client
2828 .delete(&url)
2829 .header("Authorization", self.auth_header())
2830 .build()
2831 .map_err(|e| RepoError::Network {
2832 message: format!("Failed to build request: {}", e),
2833 })?;
2834
2835 let response = self
2836 .http_client
2837 .request_with_retry(request)
2838 .await
2839 .map_err(|e| RepoError::Network {
2840 message: e.to_string(),
2841 })?;
2842
2843 if !response.status().is_success() {
2844 return Err(RepoError::Server {
2845 message: format!("HTTP {}", response.status()),
2846 });
2847 }
2848
2849 Ok(())
2850 }
2851
2852 async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
2853 info!(
2854 "[OnlineRepo] Renaming playlist {} to '{}'",
2855 playlist_id, name
2856 );
2857 let endpoint = endpoints::playlist_as_item(&self.capabilities, playlist_id);
2858 self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
2859 .await
2860 }
2861
2862 async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
2863 let endpoint = endpoints::playlist_items(&self.capabilities, playlist_id, &self.user_id);
2864
2865 let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
2866 debug!(
2867 "[OnlineRepo] Got {} playlist items for {}",
2868 response.items.len(),
2869 playlist_id
2870 );
2871
2872 Ok(response
2873 .items
2874 .into_iter()
2875 .map(|pi| PlaylistEntry {
2876 playlist_item_id: pi.playlist_item_id,
2877 item: pi.item.into_media_item(self.user_id.clone()),
2878 })
2879 .collect())
2880 }
2881
2882 async fn add_to_playlist(
2883 &self,
2884 playlist_id: &str,
2885 item_ids: &[String],
2886 ) -> Result<(), RepoError> {
2887 info!(
2888 "[OnlineRepo] Adding {} items to playlist {}",
2889 item_ids.len(),
2890 playlist_id
2891 );
2892 let ids_param = item_ids
2894 .iter()
2895 .map(|id| urlencoding::encode(id).into_owned())
2896 .collect::<Vec<_>>()
2897 .join(",");
2898 let endpoint = endpoints::playlist_items_add(&self.capabilities, playlist_id, &ids_param);
2899 self.post_json(&endpoint, &serde_json::json!({})).await
2900 }
2901
2902 async fn remove_from_playlist(
2903 &self,
2904 playlist_id: &str,
2905 entry_ids: &[String],
2906 ) -> Result<(), RepoError> {
2907 info!(
2908 "[OnlineRepo] Removing {} entries from playlist {}",
2909 entry_ids.len(),
2910 playlist_id
2911 );
2912 let ids_param = entry_ids
2913 .iter()
2914 .map(|id| urlencoding::encode(id).into_owned())
2915 .collect::<Vec<_>>()
2916 .join(",");
2917 let endpoint =
2918 endpoints::playlist_items_remove(&self.capabilities, playlist_id, &ids_param);
2919 let url = format!("{}{}", self.server_url, endpoint);
2920
2921 let request = self
2922 .http_client
2923 .client
2924 .delete(&url)
2925 .header("Authorization", self.auth_header())
2926 .build()
2927 .map_err(|e| RepoError::Network {
2928 message: format!("Failed to build request: {}", e),
2929 })?;
2930
2931 let response = self
2932 .http_client
2933 .request_with_retry(request)
2934 .await
2935 .map_err(|e| RepoError::Network {
2936 message: e.to_string(),
2937 })?;
2938
2939 if !response.status().is_success() {
2940 return Err(RepoError::Server {
2941 message: format!("HTTP {}", response.status()),
2942 });
2943 }
2944
2945 Ok(())
2946 }
2947
2948 async fn move_playlist_item(
2949 &self,
2950 playlist_id: &str,
2951 item_id: &str,
2952 new_index: u32,
2953 ) -> Result<(), RepoError> {
2954 info!(
2955 "[OnlineRepo] Moving item {} in playlist {} to index {}",
2956 item_id, playlist_id, new_index
2957 );
2958 let endpoint =
2959 endpoints::playlist_item_move(&self.capabilities, playlist_id, item_id, new_index);
2960 self.post_json(&endpoint, &serde_json::json!({})).await
2961 }
2962}
2963
2964#[cfg(test)]
2965mod tests {
2966 use super::*;
2967 use crate::domain::MediaKind;
2968 use crate::utils::lock::MutexSafe;
2969 use std::sync::Arc;
2970
2971 fn create_test_repository() -> OnlineRepository {
2972 let http_config = crate::jellyfin::HttpConfig::default();
2973 let http_client =
2974 Arc::new(HttpClient::new(http_config).expect("Failed to create HTTP client for test"));
2975 OnlineRepository::new(
2976 http_client,
2977 "https://test.server.com".to_string(),
2978 "test-user-id".to_string(),
2979 "test-access-token".to_string(),
2980 )
2981 }
2982
2983 #[test]
3003 fn subtitle_url_uses_jellyfins_stream_route() {
3004 let repo = create_test_repository();
3005
3006 assert_eq!(
3007 repo.get_subtitle_url("item123", "source456", 2, "vtt"),
3008 "https://test.server.com/Videos/item123/source456/Subtitles/2/Stream.vtt"
3009 );
3010 }
3011
3012 fn create_test_repository_with_connectivity(
3016 ) -> (OnlineRepository, crate::connectivity::ConnectivityReporter) {
3017 let monitor_http = HttpClient::new(crate::jellyfin::HttpConfig::default())
3018 .expect("Failed to create HTTP client for monitor");
3019 let monitor = crate::connectivity::ConnectivityMonitor::new(monitor_http);
3020 let reporter = monitor.reporter();
3021 let repo = create_test_repository().with_connectivity(reporter.clone());
3022 (repo, reporter)
3023 }
3024
3025 #[tokio::test]
3034 async fn test_report_outcome_classifies_server_answered_as_reachable() {
3035 let (repo, reporter) = create_test_repository_with_connectivity();
3036
3037 for err in [
3039 RepoError::Authentication {
3040 message: "401".into(),
3041 },
3042 RepoError::NotFound {
3043 message: "404".into(),
3044 },
3045 RepoError::Server {
3046 message: "500".into(),
3047 },
3048 ] {
3049 reporter.mark_unreachable_for_test().await;
3050 assert!(!reporter.is_reachable().await, "precondition: offline");
3051
3052 let result: Result<(), RepoError> = Err(err);
3053 repo.report_outcome(&result).await;
3054
3055 assert!(
3056 reporter.is_reachable().await,
3057 "a server that answers should be reported reachable"
3058 );
3059 }
3060
3061 reporter.mark_unreachable_for_test().await;
3063 let ok: Result<(), RepoError> = Ok(());
3064 repo.report_outcome(&ok).await;
3065 assert!(reporter.is_reachable().await, "Ok ⇒ reachable");
3066 }
3067
3068 #[tokio::test]
3071 async fn test_report_outcome_ignores_local_errors() {
3072 let (repo, reporter) = create_test_repository_with_connectivity();
3073
3074 reporter.mark_unreachable_for_test().await;
3077 for err in [
3078 RepoError::Database {
3079 message: "cache".into(),
3080 },
3081 RepoError::Offline,
3082 ] {
3083 let result: Result<(), RepoError> = Err(err);
3084 repo.report_outcome(&result).await;
3085 assert!(
3086 !reporter.is_reachable().await,
3087 "local-side error must not change reachability"
3088 );
3089 }
3090 }
3091
3092 #[tokio::test]
3098 async fn test_get_json_fast_fails_when_offline() {
3099 let (repo, reporter) = create_test_repository_with_connectivity();
3100 reporter.mark_unreachable_for_test().await;
3101 assert!(!reporter.is_reachable().await, "precondition: offline");
3102
3103 let result: Result<serde_json::Value, RepoError> = repo.get_json("/System/Info").await;
3104 assert!(
3105 matches!(result, Err(RepoError::Offline)),
3106 "known-offline get_json should return Offline immediately, got {:?}",
3107 result
3108 );
3109 }
3110
3111 #[tokio::test]
3114 async fn test_report_outcome_network_error_is_debounced() {
3115 let (repo, reporter) = create_test_repository_with_connectivity();
3116 assert!(reporter.is_reachable().await, "starts online");
3117
3118 let result: Result<(), RepoError> = Err(RepoError::Network {
3119 message: "timeout".into(),
3120 });
3121 repo.report_outcome(&result).await;
3122
3123 assert!(
3124 reporter.is_reachable().await,
3125 "a single network failure stays online (debounced)"
3126 );
3127 }
3128
3129 #[tokio::test]
3130 async fn test_get_audio_stream_url_formats_correctly() {
3131 let repo = create_test_repository();
3132 let item_id = "test-track-123";
3133
3134 let result = repo.get_audio_stream_url(item_id).await;
3135
3136 assert!(result.is_ok());
3137 let url = result.unwrap();
3138 assert_eq!(
3139 url,
3140 "https://test.server.com/Audio/test-track-123/stream?UserId=test-user-id&ApiKey=test-access-token&Static=true"
3141 );
3142 }
3143
3144 static QUALITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3150
3151 struct QualityFixture(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
3152
3153 impl QualityFixture {
3154 fn set(quality: StreamingQuality) -> Self {
3155 let guard = QUALITY_LOCK.lock_safe();
3156 set_streaming_quality(quality);
3157 Self(guard)
3158 }
3159 }
3160
3161 impl Drop for QualityFixture {
3162 fn drop(&mut self) {
3163 set_streaming_quality(StreamingQuality::Original);
3164 clear_playback_quality_override();
3168 }
3169 }
3170
3171 #[tokio::test]
3178 async fn test_video_stream_url_applies_bitrate_cap() {
3179 let _fixture = QualityFixture::set(StreamingQuality::Mbps2);
3180 let repo = create_test_repository();
3181
3182 let url = repo
3183 .get_video_stream_url("vid-1", None, None)
3184 .await
3185 .unwrap();
3186
3187 assert!(url.contains("MaxStreamingBitrate=2000000"), "url: {url}");
3188 assert!(url.contains("VideoBitrate=1808000"), "url: {url}");
3191 assert!(url.contains("AudioBitrate=192000"), "url: {url}");
3192 assert!(url.contains("MaxHeight=720"), "url: {url}");
3193 }
3194
3195 #[tokio::test]
3200 async fn test_video_stream_url_uncapped_keeps_legacy_allowance() {
3201 let _fixture = QualityFixture::set(StreamingQuality::Original);
3202 let repo = create_test_repository();
3203
3204 let url = repo
3205 .get_video_stream_url("vid-1", None, None)
3206 .await
3207 .unwrap();
3208
3209 assert!(url.contains("MaxStreamingBitrate=20000000"), "url: {url}");
3210 assert!(url.contains("VideoBitrate=18000000"), "url: {url}");
3211 assert!(url.contains("AudioBitrate=384000"), "url: {url}");
3212 assert!(
3213 !url.contains("MaxHeight"),
3214 "uncapped must not scale the picture down: {url}"
3215 );
3216 }
3217
3218 #[tokio::test]
3223 async fn test_audio_only_stream_url_takes_the_lower_of_cap_and_default() {
3224 {
3225 let _fixture = QualityFixture::set(StreamingQuality::Kbps720);
3226 let repo = create_test_repository();
3227 let url = repo
3228 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
3229 .await
3230 .unwrap();
3231 assert!(url.contains("MaxStreamingBitrate=96000"), "url: {url}");
3232 }
3233
3234 let _fixture = QualityFixture::set(StreamingQuality::Original);
3235 let repo = create_test_repository();
3236 let url = repo
3237 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
3238 .await
3239 .unwrap();
3240 assert!(url.contains("MaxStreamingBitrate=384000"), "url: {url}");
3241 }
3242
3243 #[tokio::test]
3256 async fn test_get_video_stream_url_returns_an_hls_master_playlist() {
3257 let _fixture = QualityFixture::set(StreamingQuality::Original);
3258 let repo = create_test_repository();
3259
3260 let url = repo
3261 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
3262 .await
3263 .unwrap();
3264
3265 assert!(
3266 url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"),
3267 "expected HLS master playlist, got: {url}"
3268 );
3269 assert!(url.contains("VideoCodec=h264"));
3270 assert!(url.contains("MediaSourceId=source-1"));
3271 assert!(url.contains("AudioStreamIndex=1"));
3272 assert!(!url.contains("stream.mp4"));
3273 }
3274
3275 #[tokio::test]
3300 async fn test_video_stream_url_never_carries_start_time_ticks() {
3301 let _fixture = QualityFixture::set(StreamingQuality::Original);
3302 let repo = create_test_repository();
3303
3304 let url = repo
3305 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
3306 .await
3307 .unwrap();
3308
3309 assert!(
3310 !url.contains("StartTimeTicks"),
3311 "an HLS playlist must never carry StartTimeTicks — the server copies it \
3312 onto every segment URI and then rejects each one with 400: {url}"
3313 );
3314 }
3315
3316 #[tokio::test]
3317 async fn test_get_video_stream_url_omits_position_when_absent() {
3318 let _fixture = QualityFixture::set(StreamingQuality::Original);
3319 let repo = create_test_repository();
3320
3321 let url = repo
3322 .get_video_stream_url("vid-1", None, None)
3323 .await
3324 .unwrap();
3325
3326 assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
3327 assert!(!url.contains("StartTimeTicks"));
3328 assert!(!url.contains("MediaSourceId"));
3329 assert!(
3334 !url.contains("AudioStreamIndex"),
3335 "must not pin an audio index when none was chosen: {url}"
3336 );
3337 }
3338
3339 #[tokio::test]
3350 async fn test_video_stream_url_carries_a_play_session_id() {
3351 let _fixture = QualityFixture::set(StreamingQuality::Original);
3352 let repo = create_test_repository();
3353
3354 let url = repo
3355 .get_video_stream_url("vid-1", None, None)
3356 .await
3357 .unwrap();
3358
3359 assert!(
3360 url.contains("PlaySessionId="),
3361 "every transcode must be openable as its own job: {url}"
3362 );
3363 }
3364
3365 #[tokio::test]
3379 async fn test_video_stream_url_asks_for_no_subtitle_stream() {
3380 let _fixture = QualityFixture::set(StreamingQuality::Original);
3381 let repo = create_test_repository();
3382
3383 let url = repo
3384 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
3385 .await
3386 .unwrap();
3387
3388 assert!(
3389 url.contains("SubtitleStreamIndex=-1"),
3390 "the stream URL must ask for no subtitle, not leave the choice open: {url}"
3391 );
3392 }
3393
3394 #[test]
3402 fn test_media_streams_carry_whether_the_app_can_render_them() {
3403 let item: JellyfinItem = serde_json::from_value(serde_json::json!({
3404 "Id": "ep-1",
3405 "Name": "Partings",
3406 "Type": "Episode",
3407 "MediaStreams": [
3408 { "Type": "Video", "Index": 0, "Codec": "hevc", "IsDefault": true },
3409 { "Type": "Audio", "Index": 1, "Codec": "eac3", "IsDefault": true },
3410 { "Type": "Subtitle", "Index": 2, "Codec": "PGSSUB", "IsDefault": true },
3411 { "Type": "Subtitle", "Index": 3, "Codec": "subrip", "IsDefault": false },
3412 { "Type": "Subtitle", "Index": 4, "Codec": null, "IsDefault": false },
3413 ],
3414 }))
3415 .expect("fixture must deserialize");
3416
3417 let streams = item.into_media_item("server-1".to_string()).media_streams;
3418 let streams = streams.expect("the item carries streams");
3419 let deliverable = |index: i32| {
3420 streams
3421 .iter()
3422 .find(|s| s.index == index)
3423 .unwrap_or_else(|| panic!("stream {index} missing"))
3424 .supports_external_delivery
3425 };
3426
3427 assert_eq!(deliverable(2), Some(false));
3429 assert_eq!(deliverable(3), Some(true));
3431 assert_eq!(deliverable(4), Some(false));
3434 assert_eq!(deliverable(0), None);
3437 assert_eq!(deliverable(1), None);
3438 }
3439
3440 #[test]
3446 fn test_each_stream_open_gets_a_fresh_session_and_reports_the_previous() {
3447 let _lock = QUALITY_LOCK.lock_safe();
3448
3449 let (first, _) = begin_video_play_session();
3450 let (second, replaced) = begin_video_play_session();
3451
3452 assert_ne!(first, second, "each open needs its own job identity");
3453 assert_eq!(
3454 replaced,
3455 Some(first),
3456 "the open must hand back the job it superseded so it can be stopped"
3457 );
3458
3459 let replaced_by_adoption = adopt_video_play_session("server-named-session".to_string());
3463 assert_eq!(replaced_by_adoption, Some(second));
3464
3465 let (_, after_adoption) = begin_video_play_session();
3466 assert_eq!(
3467 after_adoption,
3468 Some("server-named-session".to_string()),
3469 "the adopted job must be the one the next open stops"
3470 );
3471 }
3472
3473 #[tokio::test]
3474 async fn test_get_audio_only_stream_url_for_video_carries_track_and_position() {
3475 let repo = create_test_repository();
3480
3481 let url = repo
3482 .get_audio_only_stream_url_for_video("vid-1", Some("source-1"), Some(193.0), Some(2))
3483 .await
3484 .unwrap();
3485
3486 assert!(
3487 url.starts_with("https://test.server.com/Audio/vid-1/universal?"),
3488 "expected audio-only universal endpoint, got: {url}"
3489 );
3490 assert!(
3492 !url.contains("/Videos/"),
3493 "url must not hit the video endpoint: {url}"
3494 );
3495 assert!(
3496 !url.contains("master.m3u8"),
3497 "url must not be a video HLS playlist: {url}"
3498 );
3499 assert!(url.contains("AudioStreamIndex=2"));
3500 assert!(url.contains("MediaSourceId=source-1"));
3501 assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
3503 assert!(url.contains("TranscodingProtocol=http"), "url: {url}");
3506 assert!(url.contains("TranscodingContainer=mp3"), "url: {url}");
3507 assert!(
3508 !url.contains("TranscodingProtocol=hls"),
3509 "url must not be HLS: {url}"
3510 );
3511 assert!(!url.contains("Container=ts"), "url must not be ts: {url}");
3512 }
3513
3514 #[tokio::test]
3515 async fn test_get_audio_only_stream_url_for_video_omits_position_when_absent() {
3516 let repo = create_test_repository();
3518
3519 let url = repo
3520 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
3521 .await
3522 .unwrap();
3523
3524 assert!(url.starts_with("https://test.server.com/Audio/vid-1/universal?"));
3525 assert!(!url.contains("StartTimeTicks"));
3526 assert!(!url.contains("MediaSourceId"));
3527 assert!(
3530 !url.contains("AudioStreamIndex"),
3531 "must not pin an audio index when none was chosen: {url}"
3532 );
3533 }
3534
3535 #[tokio::test]
3536 async fn test_get_audio_stream_url_with_special_characters() {
3537 let repo = create_test_repository();
3538 let item_id = "track-with-special-chars-!@#";
3539
3540 let result = repo.get_audio_stream_url(item_id).await;
3541
3542 assert!(result.is_ok());
3543 let url = result.unwrap();
3544 assert!(url.contains("track-with-special-chars-!@#"));
3545 assert!(url.starts_with("https://test.server.com/Audio/"));
3546 }
3547
3548 #[test]
3549 fn test_image_tags_deserialize_hashmap_format() {
3550 let json = r#"{"Primary":"abc123","Banner":"def456","Backdrop":"ghi789"}"#;
3552 let result: Result<ImageTags, _> = serde_json::from_str(json);
3553
3554 assert!(result.is_ok());
3555 let tags = result.unwrap();
3556 assert_eq!(tags.primary(), Some("abc123".to_string()));
3557 }
3558
3559 #[test]
3560 fn test_image_tags_deserialize_structured_format() {
3561 let json = r#"{"Primary":"xyz789"}"#;
3563 let result: Result<ImageTags, _> = serde_json::from_str(json);
3564
3565 assert!(result.is_ok());
3566 let tags = result.unwrap();
3567 assert_eq!(tags.primary(), Some("xyz789".to_string()));
3568 }
3569
3570 #[test]
3571 fn test_image_tags_deserialize_missing_primary() {
3572 let json = r#"{"Banner":"def456","Backdrop":"ghi789"}"#;
3574 let result: Result<ImageTags, _> = serde_json::from_str(json);
3575
3576 assert!(result.is_ok());
3577 let tags = result.unwrap();
3578 assert_eq!(tags.primary(), None);
3579 }
3580
3581 #[test]
3582 fn test_image_tags_deserialize_empty_map() {
3583 let json = r#"{}"#;
3585 let result: Result<ImageTags, _> = serde_json::from_str(json);
3586
3587 assert!(result.is_ok());
3588 let tags = result.unwrap();
3589 assert_eq!(tags.primary(), None);
3590 }
3591
3592 #[test]
3607 fn test_video_download_url_uses_stream_not_download_endpoint() {
3608 let repo = create_test_repository();
3609 let url = repo.get_video_download_url("item123", "original", None, None);
3610
3611 assert!(
3613 !url.contains("/download"),
3614 "download URL must not use the broken /Videos/{{id}}/download endpoint: {url}"
3615 );
3616 assert!(
3618 url.contains("/Videos/item123/stream.mp4"),
3619 "download URL must target /Videos/{{id}}/stream.mp4: {url}"
3620 );
3621 assert!(url.contains("ApiKey=test-access-token"), "url: {url}");
3622 }
3623
3624 #[test]
3625 fn test_video_download_url_original_is_static_direct_copy() {
3626 let repo = create_test_repository();
3627 let url = repo.get_video_download_url("item123", "original", None, None);
3628
3629 assert!(url.contains("Static=true"), "url: {url}");
3632 assert!(
3633 !url.contains("videoBitRate"),
3634 "original must not transcode: {url}"
3635 );
3636 assert!(
3637 !url.contains("maxHeight"),
3638 "original must not transcode: {url}"
3639 );
3640 }
3641
3642 #[test]
3643 fn test_video_download_url_quality_presets_transcode() {
3644 let repo = create_test_repository();
3645
3646 for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
3647 let url = repo.get_video_download_url("item123", quality, None, None);
3648 assert!(
3649 url.contains("/Videos/item123/stream.mp4"),
3650 "{quality} must use stream.mp4: {url}"
3651 );
3652 assert!(
3653 url.contains("videoBitRate="),
3654 "{quality} must set bitrate: {url}"
3655 );
3656 assert!(
3657 url.contains(&format!("maxHeight={height}")),
3658 "{quality} must cap height at {height}: {url}"
3659 );
3660 assert!(url.contains("videoCodec=h264"), "{quality}: {url}");
3661 assert!(
3663 !url.contains("Static=true"),
3664 "{quality} must not be Static: {url}"
3665 );
3666 }
3667 }
3668
3669 #[test]
3676 fn test_video_download_url_bitrate_params_use_capital_r_spelling() {
3677 let repo = create_test_repository();
3678
3679 for quality in ["high", "medium", "low"] {
3680 let url = repo.get_video_download_url("item123", quality, None, None);
3681
3682 assert!(
3683 url.contains("videoBitRate="),
3684 "{quality} must spell it videoBitRate (capital R): {url}"
3685 );
3686 assert!(
3687 url.contains("audioBitRate="),
3688 "{quality} must spell it audioBitRate (capital R): {url}"
3689 );
3690
3691 assert!(
3694 !url.contains("videoBitrate="),
3695 "{quality} emits the unbindable lowercase-r spelling: {url}"
3696 );
3697 assert!(
3698 !url.contains("audioBitrate="),
3699 "{quality} emits the unbindable lowercase-r spelling: {url}"
3700 );
3701 }
3702 }
3703
3704 #[test]
3710 fn test_video_download_url_transcode_presets_forbid_video_stream_copy() {
3711 let repo = create_test_repository();
3712
3713 for quality in ["high", "medium", "low"] {
3714 let url = repo.get_video_download_url("item123", quality, None, None);
3715 assert!(
3716 url.contains("allowVideoStreamCopy=false"),
3717 "{quality} must forbid video stream copy: {url}"
3718 );
3719 }
3720
3721 let original = repo.get_video_download_url("item123", "original", None, None);
3723 assert!(
3724 !original.contains("allowVideoStreamCopy=false"),
3725 "original must remain a direct copy: {original}"
3726 );
3727 }
3728
3729 #[test]
3742 fn test_video_download_url_original_transcodes_undecodable_audio() {
3743 let repo = create_test_repository();
3744
3745 for codec in ["eac3", "ac3", "dts", "truehd", "EAC3"] {
3746 let url = repo.get_video_download_url("item123", "original", None, Some(codec));
3747 assert!(
3748 !url.contains("Static=true"),
3749 "{codec} cannot be decoded here, so the source must not be copied verbatim: {url}"
3750 );
3751 assert!(
3752 url.contains("audioCodec=aac"),
3753 "{codec} must be re-encoded to aac on the way down: {url}"
3754 );
3755 assert!(
3758 url.contains("allowVideoStreamCopy=true"),
3759 "the video stream must still be copied where possible: {url}"
3760 );
3761 assert!(
3762 !url.contains("videoBitRate") && !url.contains("maxHeight"),
3763 "original must not degrade the picture to fix the audio: {url}"
3764 );
3765 }
3766 }
3767
3768 #[test]
3774 fn test_video_download_url_original_keeps_static_copy_for_playable_audio() {
3775 let repo = create_test_repository();
3776
3777 for codec in ["aac", "mp3", "opus", "vorbis", "flac", "AAC"] {
3778 let url = repo.get_video_download_url("item123", "original", None, Some(codec));
3779 assert!(
3780 url.contains("Static=true"),
3781 "{codec} plays here — the download must stay a direct copy: {url}"
3782 );
3783 assert!(
3784 !url.contains("audioCodec="),
3785 "{codec} needs no transcode: {url}"
3786 );
3787 }
3788
3789 let unknown = repo.get_video_download_url("item123", "original", None, None);
3792 assert!(unknown.contains("Static=true"), "url: {unknown}");
3793 }
3794
3795 #[test]
3800 fn test_video_download_url_presets_ignore_the_audio_policy() {
3801 let repo = create_test_repository();
3802
3803 for quality in ["high", "medium", "low"] {
3804 let with = repo.get_video_download_url("item123", quality, None, Some("eac3"));
3805 let without = repo.get_video_download_url("item123", quality, None, None);
3806 assert_eq!(with, without, "{quality} must not vary with source audio");
3807 assert!(with.contains("audioCodec=aac"), "url: {with}");
3808 }
3809 }
3810
3811 #[test]
3812 fn test_video_download_url_passes_media_source_id() {
3813 let repo = create_test_repository();
3814 let url = repo.get_video_download_url("item123", "original", Some("src-42"), None);
3815 assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
3816 }
3817
3818 #[test]
3819 fn test_jellyfin_item_deserialize_with_image_tags() {
3820 let json = r#"{
3822 "Id": "album123",
3823 "Name": "Test Album",
3824 "Type": "MusicAlbum",
3825 "ImageTags": {"Primary": "tag123"},
3826 "ArtistItems": [
3827 {"Id": "artist1", "Name": "Artist One"},
3828 {"Id": "artist2", "Name": "Artist Two"}
3829 ]
3830 }"#;
3831
3832 let result: Result<JellyfinItem, _> = serde_json::from_str(json);
3833 assert!(result.is_ok());
3834
3835 let item = result.unwrap();
3836 assert_eq!(item.id, "album123");
3837 assert_eq!(item.name, "Test Album");
3838 assert_eq!(item.item_type, "MusicAlbum");
3839 assert!(item.image_tags.is_some());
3840 assert_eq!(
3841 item.image_tags.unwrap().primary(),
3842 Some("tag123".to_string())
3843 );
3844 }
3845
3846 #[test]
3850 fn test_build_favorites_endpoint_scopes_and_filters() {
3851 let movies = build_favorites_endpoint("u1", SearchScope::Movies, None);
3852 assert!(movies.starts_with("/Users/u1/Items?Filters=IsFavorite&Recursive=true"));
3853 assert!(movies.contains("&IncludeItemTypes=Movie"));
3854 assert!(movies.contains("&SortBy=SortName&SortOrder=Ascending"));
3856 assert!(movies.contains("UserData"));
3858
3859 let tv = build_favorites_endpoint("u1", SearchScope::Tv, None);
3861 assert!(tv.contains("&IncludeItemTypes=Series,Episode"));
3862
3863 let music = build_favorites_endpoint("u1", SearchScope::Music, None);
3864 assert!(music.contains("&IncludeItemTypes=MusicAlbum,MusicArtist,Audio,Playlist"));
3865 }
3866
3867 #[test]
3872 fn test_build_favorites_endpoint_all_scope_omits_type_filter() {
3873 let all = build_favorites_endpoint("u1", SearchScope::All, None);
3874 assert!(!all.contains("IncludeItemTypes"));
3875 }
3876
3877 #[test]
3881 fn test_build_favorites_endpoint_honours_paging_and_sort() {
3882 let endpoint = build_favorites_endpoint(
3883 "u1",
3884 SearchScope::All,
3885 Some(&GetItemsOptions {
3886 limit: Some(20),
3887 start_index: Some(40),
3888 sort_by: Some("Random".to_string()),
3889 sort_order: Some("Descending".to_string()),
3890 ..Default::default()
3891 }),
3892 );
3893 assert!(endpoint.contains("&Limit=20"));
3894 assert!(endpoint.contains("&StartIndex=40"));
3895 assert!(endpoint.contains("&SortBy=Random&SortOrder=Descending"));
3896 }
3897
3898 #[test]
3903 fn test_get_items_endpoint_applies_favorites_only() {
3904 let plain = build_get_items_endpoint("u1", "lib-1", None);
3905 assert!(!plain.contains("Filters=IsFavorite"));
3906
3907 let filtered = build_get_items_endpoint(
3908 "u1",
3909 "lib-1",
3910 Some(&GetItemsOptions {
3911 favorites_only: Some(true),
3912 include_item_types: Some(vec!["Movie".to_string()]),
3913 ..Default::default()
3914 }),
3915 );
3916 assert!(filtered.contains("&Filters=IsFavorite"));
3917 assert!(filtered.contains("&IncludeItemTypes=Movie"));
3919 assert!(filtered.contains("ParentId=lib-1"));
3920
3921 let off = build_get_items_endpoint(
3923 "u1",
3924 "lib-1",
3925 Some(&GetItemsOptions {
3926 favorites_only: Some(false),
3927 ..Default::default()
3928 }),
3929 );
3930 assert!(!off.contains("Filters=IsFavorite"));
3931 }
3932
3933 #[test]
3942 fn test_get_items_endpoint_encodes_query_values() {
3943 let endpoint = build_get_items_endpoint(
3944 "u1",
3945 "lib 1&Filters=IsFavorite",
3946 Some(&GetItemsOptions {
3947 include_item_types: Some(vec!["Movie&x=1".to_string()]),
3948 sort_by: Some("Sort Name".to_string()),
3949 sort_order: Some("Ascending&y=2".to_string()),
3950 ..Default::default()
3951 }),
3952 );
3953 assert!(
3954 endpoint.contains("ParentId=lib%201%26Filters%3DIsFavorite"),
3955 "{endpoint}"
3956 );
3957 assert!(
3958 endpoint.contains("&IncludeItemTypes=Movie%26x%3D1"),
3959 "{endpoint}"
3960 );
3961 assert!(endpoint.contains("&SortBy=Sort%20Name"), "{endpoint}");
3962 assert!(
3963 endpoint.contains("&SortOrder=Ascending%26y%3D2"),
3964 "{endpoint}"
3965 );
3966 assert!(!endpoint.contains("&Filters=IsFavorite"), "{endpoint}");
3968 assert!(!endpoint.contains("&x=1"), "{endpoint}");
3969 assert!(!endpoint.contains("&y=2"), "{endpoint}");
3970 }
3971
3972 #[test]
3978 fn test_get_items_endpoint_keeps_list_separators() {
3979 let endpoint = build_get_items_endpoint(
3980 "u1",
3981 "lib-1",
3982 Some(&GetItemsOptions {
3983 sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
3984 include_item_types: Some(vec!["Movie".to_string(), "Series".to_string()]),
3985 ..Default::default()
3986 }),
3987 );
3988 assert!(
3989 endpoint.contains("&SortBy=ParentIndexNumber,IndexNumber,SortName"),
3990 "{endpoint}"
3991 );
3992 assert!(
3993 endpoint.contains("&IncludeItemTypes=Movie,Series"),
3994 "{endpoint}"
3995 );
3996 assert!(endpoint.contains("ParentId=lib-1"), "{endpoint}");
3998 }
3999
4000 #[test]
4013 fn test_get_items_endpoint_orders_channel_folders_by_release_date() {
4014 let podcast = build_get_items_endpoint(
4015 "u1",
4016 "podcast-1",
4017 Some(&GetItemsOptions {
4018 parent_kind: Some(MediaKind::ChannelFolder),
4019 ..Default::default()
4020 }),
4021 );
4022 assert!(
4023 podcast.contains("&SortBy=PremiereDate&SortOrder=Descending"),
4024 "{podcast}"
4025 );
4026
4027 let season = build_get_items_endpoint(
4029 "u1",
4030 "season-1",
4031 Some(&GetItemsOptions {
4032 parent_kind: Some(MediaKind::Season),
4033 ..Default::default()
4034 }),
4035 );
4036 assert!(
4037 season.contains("&SortBy=SortName&SortOrder=Ascending"),
4038 "{season}"
4039 );
4040
4041 let explicit = build_get_items_endpoint(
4043 "u1",
4044 "podcast-1",
4045 Some(&GetItemsOptions {
4046 parent_kind: Some(MediaKind::ChannelFolder),
4047 sort_by: Some("SortName".to_string()),
4048 sort_order: Some("Ascending".to_string()),
4049 ..Default::default()
4050 }),
4051 );
4052 assert!(
4053 explicit.contains("&SortBy=SortName&SortOrder=Ascending"),
4054 "{explicit}"
4055 );
4056 assert!(!explicit.contains("SortBy=PremiereDate"), "{explicit}");
4057
4058 let unspecified = build_get_items_endpoint("u1", "lib-1", None);
4061 assert!(!unspecified.contains("SortBy="), "{unspecified}");
4062 }
4063
4064 #[test]
4071 fn test_latest_items_endpoint_groups_children_into_containers() {
4072 let endpoint = build_latest_items_endpoint("u1", "lib-1", Some(16));
4073
4074 assert!(
4075 endpoint.contains("GroupItems=true"),
4076 "latest items must be grouped so an album counts once, got: {}",
4077 endpoint
4078 );
4079 assert!(endpoint.contains("ParentId=lib-1"));
4080 assert!(endpoint.contains("Limit=16"));
4081 }
4082
4083 fn item_from_json(json: &str) -> MediaItem {
4086 let parsed: JellyfinItem = serde_json::from_str(json).expect("fixture must parse");
4087 parsed.into_media_item("srv".to_string())
4088 }
4089
4090 fn track(id: &str, name: &str, album_id: Option<&str>) -> MediaItem {
4091 let album = match album_id {
4092 Some(a) => format!(r#""AlbumId": "{a}", "Album": "Kind of Blue","#),
4093 None => String::new(),
4094 };
4095 item_from_json(&format!(
4096 r#"{{
4097 "Id": "{id}",
4098 "Name": "{name}",
4099 "Type": "Audio",
4100 {album}
4101 "ImageTags": {{"Primary": "art-{id}"}},
4102 "AlbumArtist": "Miles Davis",
4103 "Artists": ["Miles Davis"],
4104 "IndexNumber": 1,
4105 "RunTimeTicks": 1000
4106 }}"#
4107 ))
4108 }
4109
4110 #[test]
4117 fn test_collapse_tracks_into_albums_shows_one_card_per_album() {
4118 let movie = item_from_json(
4119 r#"{"Id": "mov-1", "Name": "Heat", "Type": "Movie", "ImageTags": {"Primary": "art-mov"}}"#,
4120 );
4121 let items = vec![
4122 track("trk-1", "So What", Some("alb-1")),
4123 track("trk-2", "Blue in Green", Some("alb-1")),
4124 movie,
4125 track("trk-3", "Flamenco Sketches", Some("alb-1")),
4126 ];
4127
4128 let collapsed = collapse_tracks_into_albums(items);
4129
4130 assert_eq!(
4131 collapsed.len(),
4132 2,
4133 "three tracks of one album plus a movie must read as two cards, got: {:?}",
4134 collapsed.iter().map(|i| &i.name).collect::<Vec<_>>()
4135 );
4136
4137 let album = &collapsed[0];
4138 assert_eq!(album.id, "alb-1", "the card must open the album");
4139 assert_eq!(album.name, "Kind of Blue");
4140 assert_eq!(album.item_type, "MusicAlbum");
4141 assert_eq!(album.kind, crate::domain::MediaKind::Album);
4142 assert!(album.is_folder);
4143 assert_eq!(album.album_artist.as_deref(), Some("Miles Davis"));
4144 assert!(album.image_id.is_some(), "album card needs artwork");
4145 assert!(album.index_number.is_none());
4147 assert!(album.album_id.is_none());
4148 assert!(album.runtime_ticks.is_none());
4149
4150 assert_eq!(collapsed[1].id, "mov-1");
4152 }
4153
4154 #[test]
4159 fn test_collapse_prefers_the_album_row_the_server_returned() {
4160 let album = item_from_json(
4161 r#"{"Id": "alb-1", "Name": "Kind of Blue", "Type": "MusicAlbum", "IsFolder": true,
4162 "Overview": "1959", "ImageTags": {"Primary": "art-alb"}}"#,
4163 );
4164 let items = vec![
4165 album,
4166 track("trk-1", "So What", Some("alb-1")),
4167 track("trk-2", "Blue in Green", Some("alb-1")),
4168 ];
4169
4170 let collapsed = collapse_tracks_into_albums(items);
4171
4172 assert_eq!(collapsed.len(), 1, "one album, one card");
4173 assert_eq!(collapsed[0].id, "alb-1");
4174 assert_eq!(
4175 collapsed[0].overview.as_deref(),
4176 Some("1959"),
4177 "the server's own album row must survive, not a track-built stand-in"
4178 );
4179 }
4180
4181 #[test]
4186 fn test_collapse_leaves_a_standalone_track_alone() {
4187 let items = vec![track("trk-1", "Field Recording", None)];
4188
4189 let collapsed = collapse_tracks_into_albums(items);
4190
4191 assert_eq!(collapsed.len(), 1);
4192 assert_eq!(collapsed[0].id, "trk-1");
4193 assert_eq!(collapsed[0].item_type, "Audio");
4194 }
4195
4196 #[test]
4202 fn test_latest_items_over_fetches_before_collapsing() {
4203 assert!(
4204 latest_items_fetch_limit(16) > 16,
4205 "must ask for more rows than the row shows"
4206 );
4207 let endpoint =
4208 build_latest_items_endpoint("u1", "lib-1", Some(latest_items_fetch_limit(16)));
4209 assert!(endpoint.contains(&format!("Limit={}", latest_items_fetch_limit(16))));
4210 }
4211
4212 #[test]
4221 fn test_build_next_up_endpoint_excludes_resumable() {
4222 let endpoint = build_next_up_endpoint("u1", None, Some(12));
4223
4224 assert!(
4225 endpoint.contains("EnableResumable=false"),
4226 "next up must exclude in-progress episodes, got: {}",
4227 endpoint
4228 );
4229 assert!(endpoint.contains("UserId=u1"));
4230 assert!(endpoint.contains("Limit=12"));
4231 assert!(
4232 !endpoint.contains("SeriesId"),
4233 "no series filter when none was requested, got: {}",
4234 endpoint
4235 );
4236 }
4237
4238 #[test]
4242 fn test_build_next_up_endpoint_scopes_to_series() {
4243 let endpoint = build_next_up_endpoint("u1", Some("series-a"), None);
4244
4245 assert!(endpoint.contains("SeriesId=series-a"));
4246 assert!(endpoint.contains("EnableResumable=false"));
4247 assert!(
4248 endpoint.contains("Limit=16"),
4249 "default limit, got: {}",
4250 endpoint
4251 );
4252 }
4253
4254 #[test]
4261 fn test_jellyfin_item_maps_user_data_favorite() {
4262 let json = r#"{
4263 "Id": "movie123",
4264 "Name": "Test Movie",
4265 "Type": "Movie",
4266 "UserData": {
4267 "PlaybackPositionTicks": 6000000000,
4268 "Played": false,
4269 "IsFavorite": true,
4270 "PlayCount": 2,
4271 "LastPlayedDate": "2026-08-01T12:00:00Z"
4272 }
4273 }"#;
4274
4275 let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
4276 let media = item.into_media_item("server1".to_string());
4277
4278 let user_data = media.user_data.expect("user data should be mapped");
4279 assert_eq!(user_data.is_favorite, Some(true));
4280 assert_eq!(user_data.is_played, Some(false));
4281 assert_eq!(user_data.play_count, Some(2));
4282 assert_eq!(user_data.playback_position_ticks, Some(6_000_000_000));
4283 assert_eq!(user_data.playback_position_ms, Some(600_000));
4285 }
4286
4287 #[test]
4292 fn test_jellyfin_item_without_user_data_maps_to_none() {
4293 let json = r#"{"Id": "x", "Name": "No User Data", "Type": "Movie"}"#;
4294
4295 let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
4296 let media = item.into_media_item("server1".to_string());
4297
4298 assert!(media.user_data.is_none());
4299 }
4300
4301 #[test]
4302 fn test_jellyfin_item_deserialize_with_artist_items() {
4303 let json = r#"{
4305 "Id": "track123",
4306 "Name": "Test Track",
4307 "Type": "Audio",
4308 "ArtistItems": [
4309 {"Id": "artist1", "Name": "Bob Dylan"},
4310 {"Id": "artist2", "Name": "Johnny Cash"}
4311 ]
4312 }"#;
4313
4314 let result: Result<JellyfinItem, _> = serde_json::from_str(json);
4315 assert!(result.is_ok());
4316
4317 let item = result.unwrap();
4318 let artist_items = item.artist_items.expect("Expected artist items");
4319 assert_eq!(artist_items.len(), 2);
4320 assert_eq!(artist_items[0].id, "artist1");
4321 assert_eq!(artist_items[0].name, "Bob Dylan");
4322 assert_eq!(artist_items[1].id, "artist2");
4323 assert_eq!(artist_items[1].name, "Johnny Cash");
4324 }
4325
4326 #[test]
4327 fn test_jellyfin_item_to_media_item_conversion() {
4328 let json = r#"{
4330 "Id": "album456",
4331 "Name": "Love and Theft",
4332 "Type": "MusicAlbum",
4333 "ImageTags": {"Primary": "7ebab4f6a80cd09d"},
4334 "Artists": ["Bob Dylan"],
4335 "ArtistItems": [{"Id": "0b2a6e969a27f22aba97f9f0e69fa849", "Name": "Bob Dylan"}],
4336 "RunTimeTicks": 33900137190
4337 }"#;
4338
4339 let jellyfin_item: JellyfinItem = serde_json::from_str(json).expect("Failed to parse");
4340 let media_item = jellyfin_item.into_media_item("test-server-id".to_string());
4341
4342 assert_eq!(media_item.id, "album456");
4343 assert_eq!(media_item.name, "Love and Theft");
4344 assert_eq!(media_item.item_type, "MusicAlbum");
4345 assert_eq!(
4346 media_item.primary_image_tag,
4347 Some("7ebab4f6a80cd09d".to_string())
4348 );
4349 assert_eq!(media_item.server_id, "test-server-id");
4350 }
4351
4352 #[test]
4353 fn test_items_response_deserialize() {
4354 let json = r#"{
4356 "Items": [
4357 {
4358 "Id": "item1",
4359 "Name": "Item One",
4360 "Type": "MusicAlbum",
4361 "ImageTags": {"Primary": "tag1"}
4362 },
4363 {
4364 "Id": "item2",
4365 "Name": "Item Two",
4366 "Type": "Audio",
4367 "ImageTags": {"Primary": "tag2"}
4368 }
4369 ],
4370 "TotalRecordCount": 2
4371 }"#;
4372
4373 let result: Result<ItemsResponse, _> = serde_json::from_str(json);
4374 assert!(result.is_ok());
4375
4376 let response = result.unwrap();
4377 assert_eq!(response.total_record_count, 2);
4378 assert_eq!(response.items.len(), 2);
4379 assert_eq!(response.items[0].id, "item1");
4380 assert_eq!(response.items[1].id, "item2");
4381 }
4382
4383 #[test]
4384 fn test_search_term_is_url_encoded() {
4385 assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
4389 assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
4390 }
4391
4392 #[test]
4393 fn test_jray_context_deserializes_actors() {
4394 let json = r#"{
4396 "actors": [
4397 { "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
4398 ]
4399 }"#;
4400 let ctx: JRayContext = serde_json::from_str(json).expect("should parse");
4401 assert_eq!(ctx.actors.len(), 1);
4402 assert_eq!(ctx.actors[0].name, "Tom Hanks");
4403 assert_eq!(ctx.actors[0].jellyfin_id, "abc123-guid");
4404 }
4405
4406 #[test]
4407 fn test_jray_context_ignores_unknown_keys_and_missing_ids() {
4408 let json = r#"{
4411 "actors": [ { "name": "Extra" } ],
4412 "locations": ["Beach"],
4413 "trivia": "filmed in 1994"
4414 }"#;
4415 let ctx: JRayContext = serde_json::from_str(json).expect("should tolerate extra keys");
4416 assert_eq!(ctx.actors.len(), 1);
4417 assert_eq!(ctx.actors[0].name, "Extra");
4418 assert_eq!(ctx.actors[0].imdb_id, "");
4419 assert_eq!(ctx.actors[0].jellyfin_id, "");
4420 }
4421
4422 fn source_fixture() -> NegotiatedSource {
4437 NegotiatedSource {
4438 id: "source-1".to_string(),
4439 supports_direct_play: true,
4440 supports_direct_stream: true,
4441 supports_transcoding: true,
4442 transcoding_url: None,
4443 bitrate: Some(6_652_961),
4444 media_streams: Vec::new(),
4445 }
4446 }
4447
4448 #[test]
4454 fn test_a_supported_source_direct_plays() {
4455 let source = source_fixture();
4456 assert_eq!(
4457 decide_playback_kind(&source, false, false),
4458 PlaybackKind::DirectPlay
4459 );
4460 }
4461
4462 #[test]
4467 fn test_a_remuxable_source_direct_streams() {
4468 let source = NegotiatedSource {
4469 supports_direct_play: false,
4470 supports_direct_stream: true,
4471 ..source_fixture()
4472 };
4473 let kind = decide_playback_kind(&source, false, false);
4474 assert_eq!(kind, PlaybackKind::DirectStream);
4475 assert!(
4476 !kind.needs_transcoding(),
4477 "a remux costs no encoder time and must not be reported as transcoding"
4478 );
4479 }
4480
4481 #[test]
4486 fn test_an_unsupported_source_transcodes() {
4487 let source = NegotiatedSource {
4488 supports_direct_play: false,
4489 supports_direct_stream: false,
4490 ..source_fixture()
4491 };
4492 assert_eq!(
4493 decide_playback_kind(&source, false, false),
4494 PlaybackKind::Transcode
4495 );
4496 }
4497
4498 #[test]
4505 fn test_undecodable_audio_overrides_the_servers_direct_play_offer() {
4506 let source = source_fixture();
4507 assert!(source.supports_direct_play, "the server said yes");
4508 assert_eq!(
4509 decide_playback_kind(&source, true, false),
4510 PlaybackKind::Transcode,
4511 "silent direct play is worse than a transcode"
4512 );
4513 }
4514
4515 #[test]
4521 fn test_pinning_an_audio_track_forces_a_transcode() {
4522 let source = source_fixture();
4523 assert_eq!(
4524 decide_playback_kind(&source, false, true),
4525 PlaybackKind::Transcode
4526 );
4527 }
4528
4529 #[test]
4537 fn test_a_ceiling_below_the_source_bitrate_transcodes() {
4538 let source = NegotiatedSource {
4540 supports_direct_play: false,
4541 supports_direct_stream: false,
4542 bitrate: Some(6_652_961),
4543 ..source_fixture()
4544 };
4545 assert_eq!(
4546 decide_playback_kind(&source, false, false),
4547 PlaybackKind::Transcode
4548 );
4549
4550 let options =
4552 crate::repository::stream_selection::quality_options_for_source(Some(6_652_961));
4553 let two_mbps = options
4554 .iter()
4555 .find(|o| o.quality == StreamingQuality::Mbps2)
4556 .expect("2 Mbps is on the ladder");
4557 assert!(!two_mbps.exceeds_source);
4558 }
4559
4560 #[test]
4565 fn test_direct_play_is_preferred_over_direct_stream() {
4566 let source = source_fixture();
4567 assert!(source.supports_direct_play && source.supports_direct_stream);
4568 assert_eq!(
4569 decide_playback_kind(&source, false, false),
4570 PlaybackKind::DirectPlay
4571 );
4572 }
4573
4574 #[test]
4585 fn test_a_playback_override_does_not_disturb_the_device_default() {
4586 let _guard = QUALITY_LOCK.lock_safe();
4587 set_streaming_quality(StreamingQuality::Mbps10);
4588 clear_playback_quality_override();
4589 assert_eq!(effective_streaming_quality(), StreamingQuality::Mbps10);
4590
4591 set_playback_quality_override(StreamingQuality::Kbps720);
4592 assert_eq!(
4593 effective_streaming_quality(),
4594 StreamingQuality::Kbps720,
4595 "the override governs the stream being opened now"
4596 );
4597 assert_eq!(
4598 streaming_quality(),
4599 StreamingQuality::Mbps10,
4600 "but the durable default the Settings screen shows is untouched"
4601 );
4602
4603 clear_playback_quality_override();
4604 assert_eq!(
4605 effective_streaming_quality(),
4606 StreamingQuality::Mbps10,
4607 "and dropping the override returns to it"
4608 );
4609 set_streaming_quality(StreamingQuality::Original);
4610 }
4611
4612 #[test]
4618 fn test_the_override_is_droppable_so_it_cannot_outlive_its_playback() {
4619 let _guard = QUALITY_LOCK.lock_safe();
4620 set_streaming_quality(StreamingQuality::Original);
4621 set_playback_quality_override(StreamingQuality::Mbps1);
4622 assert_eq!(playback_quality_override(), Some(StreamingQuality::Mbps1));
4623
4624 clear_playback_quality_override();
4625 assert_eq!(playback_quality_override(), None);
4626 assert_eq!(effective_streaming_quality(), StreamingQuality::Original);
4627 }
4628}