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 {
2558 Some(codec) if !super::device_profile::renderer_can_decode_audio(codec) => {
2559 params.push("videoCodec=h264".to_string());
2560 params.push("allowVideoStreamCopy=true".to_string());
2561 params.push("audioCodec=aac".to_string());
2562 params.push("audioBitRate=384000".to_string());
2563 }
2564 _ => params.push("Static=true".to_string()),
2568 },
2569 }
2570
2571 if let Some(source_id) = media_source_id {
2573 params.push(format!("mediaSourceId={}", source_id));
2574 }
2575
2576 url.push('?');
2577 url.push_str(¶ms.join("&"));
2578
2579 url
2580 }
2581
2582 async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
2583 let endpoint = endpoints::favorite_item(&self.capabilities, &self.user_id, item_id);
2584 self.post_json(&endpoint, &serde_json::json!({})).await
2585 }
2586
2587 async fn get_favorites(
2589 &self,
2590 scope: SearchScope,
2591 options: Option<GetItemsOptions>,
2592 ) -> Result<SearchResult, RepoError> {
2593 let endpoint =
2594 endpoints::favorites(&self.capabilities, &self.user_id, scope, options.as_ref());
2595 let response: ItemsResponse = self.get_json(&endpoint).await?;
2596
2597 Ok(SearchResult {
2598 items: response
2599 .items
2600 .into_iter()
2601 .map(|item| item.into_media_item(self.user_id.clone()))
2602 .collect(),
2603 total_record_count: response.total_record_count,
2604 })
2605 }
2606
2607 async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
2614 let endpoint = endpoints::favorite_item(&self.capabilities, &self.user_id, item_id);
2615 let url = format!("{}{}", self.server_url, endpoint);
2616
2617 let result = async {
2618 let request = self
2619 .http_client
2620 .client
2621 .delete(&url)
2622 .header("Authorization", self.auth_header())
2623 .build()
2624 .map_err(|e| RepoError::Network {
2625 message: format!("Failed to build request: {}", e),
2626 })?;
2627
2628 let response = self
2629 .http_client
2630 .request_with_retry(request)
2631 .await
2632 .map_err(|e| RepoError::Network {
2633 message: e.to_string(),
2634 })?;
2635
2636 if !response.status().is_success() {
2637 return Err(RepoError::Server {
2638 message: format!("HTTP {}", response.status()),
2639 });
2640 }
2641
2642 Ok(())
2643 }
2644 .await;
2645
2646 self.report_outcome(&result).await;
2647 result
2648 }
2649
2650 async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
2656 let endpoint = endpoints::played_item(&self.capabilities, &self.user_id, item_id);
2657 let url = format!("{}{}", self.server_url, endpoint);
2658
2659 let result = async {
2660 let request = self
2661 .http_client
2662 .client
2663 .delete(&url)
2664 .header("Authorization", self.auth_header())
2665 .build()
2666 .map_err(|e| RepoError::Network {
2667 message: format!("Failed to build request: {}", e),
2668 })?;
2669
2670 let response = self
2671 .http_client
2672 .request_with_retry(request)
2673 .await
2674 .map_err(|e| RepoError::Network {
2675 message: e.to_string(),
2676 })?;
2677
2678 if !response.status().is_success() {
2679 return Err(RepoError::Server {
2680 message: format!("HTTP {}", response.status()),
2681 });
2682 }
2683
2684 Ok(())
2685 }
2686 .await;
2687
2688 self.report_outcome(&result).await;
2689 result
2690 }
2691
2692 async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
2697 let endpoint = endpoints::played_item(&self.capabilities, &self.user_id, item_id);
2698 let url = format!("{}{}", self.server_url, endpoint);
2699
2700 let result = async {
2701 let request = self
2702 .http_client
2703 .client
2704 .post(&url)
2705 .header("Authorization", self.auth_header())
2706 .header("Content-Length", "0")
2707 .build()
2708 .map_err(|e| RepoError::Network {
2709 message: format!("Failed to build request: {}", e),
2710 })?;
2711
2712 let response = self
2713 .http_client
2714 .request_with_retry(request)
2715 .await
2716 .map_err(|e| RepoError::Network {
2717 message: e.to_string(),
2718 })?;
2719
2720 if !response.status().is_success() {
2721 return Err(RepoError::Server {
2722 message: format!("HTTP {}", response.status()),
2723 });
2724 }
2725
2726 Ok(())
2727 }
2728 .await;
2729
2730 self.report_outcome(&result).await;
2731 result
2732 }
2733
2734 async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
2742 let endpoint = endpoints::person(&self.capabilities, &self.user_id, person_id);
2743 let item: JellyfinItem = self.get_json(&endpoint).await?;
2744 Ok(item.into_media_item(self.user_id.clone()))
2745 }
2746
2747 async fn get_items_by_person(
2751 &self,
2752 person_id: &str,
2753 options: Option<GetItemsOptions>,
2754 ) -> Result<SearchResult, RepoError> {
2755 let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
2756
2757 let endpoint = endpoints::items_by_person(
2758 &self.capabilities,
2759 &self.user_id,
2760 person_id,
2761 limit,
2762 options
2763 .as_ref()
2764 .and_then(|o| o.include_item_types.as_deref()),
2765 );
2766
2767 let response: ItemsResponse = self.get_json(&endpoint).await?;
2768 Ok(SearchResult {
2769 items: response
2770 .items
2771 .into_iter()
2772 .map(|item| item.into_media_item(self.user_id.clone()))
2773 .collect(),
2774 total_record_count: response.total_record_count,
2775 })
2776 }
2777
2778 async fn get_similar_items(
2779 &self,
2780 item_id: &str,
2781 limit: Option<usize>,
2782 ) -> Result<SearchResult, RepoError> {
2783 let limit_str = limit.unwrap_or(20);
2784
2785 let endpoint =
2787 endpoints::similar_items(&self.capabilities, item_id, &self.user_id, limit_str);
2788
2789 let response: ItemsResponse = self.get_json(&endpoint).await?;
2790 Ok(SearchResult {
2791 items: response
2792 .items
2793 .into_iter()
2794 .map(|item| item.into_media_item(self.user_id.clone()))
2795 .collect(),
2796 total_record_count: response.total_record_count,
2797 })
2798 }
2799
2800 async fn create_playlist(
2803 &self,
2804 name: &str,
2805 item_ids: &[String],
2806 ) -> Result<PlaylistCreatedResult, RepoError> {
2807 info!(
2808 "[OnlineRepo] Creating playlist '{}' with {} items",
2809 name,
2810 item_ids.len()
2811 );
2812 let body = serde_json::json!({
2813 "Name": name,
2814 "Ids": item_ids,
2815 "MediaType": "Audio",
2816 "UserId": self.user_id,
2817 });
2818 let response: CreatePlaylistResponse = self
2819 .post_json_response(endpoints::playlists(&self.capabilities), &body)
2820 .await?;
2821 Ok(PlaylistCreatedResult { id: response.id })
2822 }
2823
2824 async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
2825 info!("[OnlineRepo] Deleting playlist {}", playlist_id);
2826 let endpoint = endpoints::playlist_as_item(&self.capabilities, playlist_id);
2827 let url = format!("{}{}", self.server_url, endpoint);
2828
2829 let request = self
2830 .http_client
2831 .client
2832 .delete(&url)
2833 .header("Authorization", self.auth_header())
2834 .build()
2835 .map_err(|e| RepoError::Network {
2836 message: format!("Failed to build request: {}", e),
2837 })?;
2838
2839 let response = self
2840 .http_client
2841 .request_with_retry(request)
2842 .await
2843 .map_err(|e| RepoError::Network {
2844 message: e.to_string(),
2845 })?;
2846
2847 if !response.status().is_success() {
2848 return Err(RepoError::Server {
2849 message: format!("HTTP {}", response.status()),
2850 });
2851 }
2852
2853 Ok(())
2854 }
2855
2856 async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
2857 info!(
2858 "[OnlineRepo] Renaming playlist {} to '{}'",
2859 playlist_id, name
2860 );
2861 let endpoint = endpoints::playlist_as_item(&self.capabilities, playlist_id);
2862 self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
2863 .await
2864 }
2865
2866 async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
2867 let endpoint = endpoints::playlist_items(&self.capabilities, playlist_id, &self.user_id);
2868
2869 let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
2870 debug!(
2871 "[OnlineRepo] Got {} playlist items for {}",
2872 response.items.len(),
2873 playlist_id
2874 );
2875
2876 Ok(response
2877 .items
2878 .into_iter()
2879 .map(|pi| PlaylistEntry {
2880 playlist_item_id: pi.playlist_item_id,
2881 item: pi.item.into_media_item(self.user_id.clone()),
2882 })
2883 .collect())
2884 }
2885
2886 async fn add_to_playlist(
2887 &self,
2888 playlist_id: &str,
2889 item_ids: &[String],
2890 ) -> Result<(), RepoError> {
2891 info!(
2892 "[OnlineRepo] Adding {} items to playlist {}",
2893 item_ids.len(),
2894 playlist_id
2895 );
2896 let ids_param = item_ids
2898 .iter()
2899 .map(|id| urlencoding::encode(id).into_owned())
2900 .collect::<Vec<_>>()
2901 .join(",");
2902 let endpoint = endpoints::playlist_items_add(&self.capabilities, playlist_id, &ids_param);
2903 self.post_json(&endpoint, &serde_json::json!({})).await
2904 }
2905
2906 async fn remove_from_playlist(
2907 &self,
2908 playlist_id: &str,
2909 entry_ids: &[String],
2910 ) -> Result<(), RepoError> {
2911 info!(
2912 "[OnlineRepo] Removing {} entries from playlist {}",
2913 entry_ids.len(),
2914 playlist_id
2915 );
2916 let ids_param = entry_ids
2917 .iter()
2918 .map(|id| urlencoding::encode(id).into_owned())
2919 .collect::<Vec<_>>()
2920 .join(",");
2921 let endpoint =
2922 endpoints::playlist_items_remove(&self.capabilities, playlist_id, &ids_param);
2923 let url = format!("{}{}", self.server_url, endpoint);
2924
2925 let request = self
2926 .http_client
2927 .client
2928 .delete(&url)
2929 .header("Authorization", self.auth_header())
2930 .build()
2931 .map_err(|e| RepoError::Network {
2932 message: format!("Failed to build request: {}", e),
2933 })?;
2934
2935 let response = self
2936 .http_client
2937 .request_with_retry(request)
2938 .await
2939 .map_err(|e| RepoError::Network {
2940 message: e.to_string(),
2941 })?;
2942
2943 if !response.status().is_success() {
2944 return Err(RepoError::Server {
2945 message: format!("HTTP {}", response.status()),
2946 });
2947 }
2948
2949 Ok(())
2950 }
2951
2952 async fn move_playlist_item(
2953 &self,
2954 playlist_id: &str,
2955 item_id: &str,
2956 new_index: u32,
2957 ) -> Result<(), RepoError> {
2958 info!(
2959 "[OnlineRepo] Moving item {} in playlist {} to index {}",
2960 item_id, playlist_id, new_index
2961 );
2962 let endpoint =
2963 endpoints::playlist_item_move(&self.capabilities, playlist_id, item_id, new_index);
2964 self.post_json(&endpoint, &serde_json::json!({})).await
2965 }
2966}
2967
2968#[cfg(test)]
2969mod tests {
2970 use super::*;
2971 use crate::domain::MediaKind;
2972 use crate::utils::lock::MutexSafe;
2973 use std::sync::Arc;
2974
2975 fn create_test_repository() -> OnlineRepository {
2976 let http_config = crate::jellyfin::HttpConfig::default();
2977 let http_client =
2978 Arc::new(HttpClient::new(http_config).expect("Failed to create HTTP client for test"));
2979 OnlineRepository::new(
2980 http_client,
2981 "https://test.server.com".to_string(),
2982 "test-user-id".to_string(),
2983 "test-access-token".to_string(),
2984 )
2985 }
2986
2987 #[test]
3007 fn subtitle_url_uses_jellyfins_stream_route() {
3008 let repo = create_test_repository();
3009
3010 assert_eq!(
3011 repo.get_subtitle_url("item123", "source456", 2, "vtt"),
3012 "https://test.server.com/Videos/item123/source456/Subtitles/2/Stream.vtt"
3013 );
3014 }
3015
3016 fn create_test_repository_with_connectivity(
3020 ) -> (OnlineRepository, crate::connectivity::ConnectivityReporter) {
3021 let monitor_http = HttpClient::new(crate::jellyfin::HttpConfig::default())
3022 .expect("Failed to create HTTP client for monitor");
3023 let monitor = crate::connectivity::ConnectivityMonitor::new(monitor_http);
3024 let reporter = monitor.reporter();
3025 let repo = create_test_repository().with_connectivity(reporter.clone());
3026 (repo, reporter)
3027 }
3028
3029 #[tokio::test]
3038 async fn test_report_outcome_classifies_server_answered_as_reachable() {
3039 let (repo, reporter) = create_test_repository_with_connectivity();
3040
3041 for err in [
3043 RepoError::Authentication {
3044 message: "401".into(),
3045 },
3046 RepoError::NotFound {
3047 message: "404".into(),
3048 },
3049 RepoError::Server {
3050 message: "500".into(),
3051 },
3052 ] {
3053 reporter.mark_unreachable_for_test().await;
3054 assert!(!reporter.is_reachable().await, "precondition: offline");
3055
3056 let result: Result<(), RepoError> = Err(err);
3057 repo.report_outcome(&result).await;
3058
3059 assert!(
3060 reporter.is_reachable().await,
3061 "a server that answers should be reported reachable"
3062 );
3063 }
3064
3065 reporter.mark_unreachable_for_test().await;
3067 let ok: Result<(), RepoError> = Ok(());
3068 repo.report_outcome(&ok).await;
3069 assert!(reporter.is_reachable().await, "Ok ⇒ reachable");
3070 }
3071
3072 #[tokio::test]
3075 async fn test_report_outcome_ignores_local_errors() {
3076 let (repo, reporter) = create_test_repository_with_connectivity();
3077
3078 reporter.mark_unreachable_for_test().await;
3081 for err in [
3082 RepoError::Database {
3083 message: "cache".into(),
3084 },
3085 RepoError::Offline,
3086 ] {
3087 let result: Result<(), RepoError> = Err(err);
3088 repo.report_outcome(&result).await;
3089 assert!(
3090 !reporter.is_reachable().await,
3091 "local-side error must not change reachability"
3092 );
3093 }
3094 }
3095
3096 #[tokio::test]
3102 async fn test_get_json_fast_fails_when_offline() {
3103 let (repo, reporter) = create_test_repository_with_connectivity();
3104 reporter.mark_unreachable_for_test().await;
3105 assert!(!reporter.is_reachable().await, "precondition: offline");
3106
3107 let result: Result<serde_json::Value, RepoError> = repo.get_json("/System/Info").await;
3108 assert!(
3109 matches!(result, Err(RepoError::Offline)),
3110 "known-offline get_json should return Offline immediately, got {:?}",
3111 result
3112 );
3113 }
3114
3115 #[tokio::test]
3118 async fn test_report_outcome_network_error_is_debounced() {
3119 let (repo, reporter) = create_test_repository_with_connectivity();
3120 assert!(reporter.is_reachable().await, "starts online");
3121
3122 let result: Result<(), RepoError> = Err(RepoError::Network {
3123 message: "timeout".into(),
3124 });
3125 repo.report_outcome(&result).await;
3126
3127 assert!(
3128 reporter.is_reachable().await,
3129 "a single network failure stays online (debounced)"
3130 );
3131 }
3132
3133 #[tokio::test]
3134 async fn test_get_audio_stream_url_formats_correctly() {
3135 let repo = create_test_repository();
3136 let item_id = "test-track-123";
3137
3138 let result = repo.get_audio_stream_url(item_id).await;
3139
3140 assert!(result.is_ok());
3141 let url = result.unwrap();
3142 assert_eq!(
3143 url,
3144 "https://test.server.com/Audio/test-track-123/stream?UserId=test-user-id&ApiKey=test-access-token&Static=true"
3145 );
3146 }
3147
3148 static QUALITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3154
3155 struct QualityFixture(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
3156
3157 impl QualityFixture {
3158 fn set(quality: StreamingQuality) -> Self {
3159 let guard = QUALITY_LOCK.lock_safe();
3160 set_streaming_quality(quality);
3161 Self(guard)
3162 }
3163 }
3164
3165 impl Drop for QualityFixture {
3166 fn drop(&mut self) {
3167 set_streaming_quality(StreamingQuality::Original);
3168 clear_playback_quality_override();
3172 }
3173 }
3174
3175 #[tokio::test]
3182 async fn test_video_stream_url_applies_bitrate_cap() {
3183 let _fixture = QualityFixture::set(StreamingQuality::Mbps2);
3184 let repo = create_test_repository();
3185
3186 let url = repo
3187 .get_video_stream_url("vid-1", None, None)
3188 .await
3189 .unwrap();
3190
3191 assert!(url.contains("MaxStreamingBitrate=2000000"), "url: {url}");
3192 assert!(url.contains("VideoBitrate=1808000"), "url: {url}");
3195 assert!(url.contains("AudioBitrate=192000"), "url: {url}");
3196 assert!(url.contains("MaxHeight=720"), "url: {url}");
3197 }
3198
3199 #[tokio::test]
3204 async fn test_video_stream_url_uncapped_keeps_legacy_allowance() {
3205 let _fixture = QualityFixture::set(StreamingQuality::Original);
3206 let repo = create_test_repository();
3207
3208 let url = repo
3209 .get_video_stream_url("vid-1", None, None)
3210 .await
3211 .unwrap();
3212
3213 assert!(url.contains("MaxStreamingBitrate=20000000"), "url: {url}");
3214 assert!(url.contains("VideoBitrate=18000000"), "url: {url}");
3215 assert!(url.contains("AudioBitrate=384000"), "url: {url}");
3216 assert!(
3217 !url.contains("MaxHeight"),
3218 "uncapped must not scale the picture down: {url}"
3219 );
3220 }
3221
3222 #[tokio::test]
3227 async fn test_audio_only_stream_url_takes_the_lower_of_cap_and_default() {
3228 {
3229 let _fixture = QualityFixture::set(StreamingQuality::Kbps720);
3230 let repo = create_test_repository();
3231 let url = repo
3232 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
3233 .await
3234 .unwrap();
3235 assert!(url.contains("MaxStreamingBitrate=96000"), "url: {url}");
3236 }
3237
3238 let _fixture = QualityFixture::set(StreamingQuality::Original);
3239 let repo = create_test_repository();
3240 let url = repo
3241 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
3242 .await
3243 .unwrap();
3244 assert!(url.contains("MaxStreamingBitrate=384000"), "url: {url}");
3245 }
3246
3247 #[tokio::test]
3260 async fn test_get_video_stream_url_returns_an_hls_master_playlist() {
3261 let _fixture = QualityFixture::set(StreamingQuality::Original);
3262 let repo = create_test_repository();
3263
3264 let url = repo
3265 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
3266 .await
3267 .unwrap();
3268
3269 assert!(
3270 url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"),
3271 "expected HLS master playlist, got: {url}"
3272 );
3273 assert!(url.contains("VideoCodec=h264"));
3274 assert!(url.contains("MediaSourceId=source-1"));
3275 assert!(url.contains("AudioStreamIndex=1"));
3276 assert!(!url.contains("stream.mp4"));
3277 }
3278
3279 #[tokio::test]
3304 async fn test_video_stream_url_never_carries_start_time_ticks() {
3305 let _fixture = QualityFixture::set(StreamingQuality::Original);
3306 let repo = create_test_repository();
3307
3308 let url = repo
3309 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
3310 .await
3311 .unwrap();
3312
3313 assert!(
3314 !url.contains("StartTimeTicks"),
3315 "an HLS playlist must never carry StartTimeTicks — the server copies it \
3316 onto every segment URI and then rejects each one with 400: {url}"
3317 );
3318 }
3319
3320 #[tokio::test]
3321 async fn test_get_video_stream_url_omits_position_when_absent() {
3322 let _fixture = QualityFixture::set(StreamingQuality::Original);
3323 let repo = create_test_repository();
3324
3325 let url = repo
3326 .get_video_stream_url("vid-1", None, None)
3327 .await
3328 .unwrap();
3329
3330 assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
3331 assert!(!url.contains("StartTimeTicks"));
3332 assert!(!url.contains("MediaSourceId"));
3333 assert!(
3338 !url.contains("AudioStreamIndex"),
3339 "must not pin an audio index when none was chosen: {url}"
3340 );
3341 }
3342
3343 #[tokio::test]
3354 async fn test_video_stream_url_carries_a_play_session_id() {
3355 let _fixture = QualityFixture::set(StreamingQuality::Original);
3356 let repo = create_test_repository();
3357
3358 let url = repo
3359 .get_video_stream_url("vid-1", None, None)
3360 .await
3361 .unwrap();
3362
3363 assert!(
3364 url.contains("PlaySessionId="),
3365 "every transcode must be openable as its own job: {url}"
3366 );
3367 }
3368
3369 #[tokio::test]
3383 async fn test_video_stream_url_asks_for_no_subtitle_stream() {
3384 let _fixture = QualityFixture::set(StreamingQuality::Original);
3385 let repo = create_test_repository();
3386
3387 let url = repo
3388 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
3389 .await
3390 .unwrap();
3391
3392 assert!(
3393 url.contains("SubtitleStreamIndex=-1"),
3394 "the stream URL must ask for no subtitle, not leave the choice open: {url}"
3395 );
3396 }
3397
3398 #[test]
3406 fn test_media_streams_carry_whether_the_app_can_render_them() {
3407 let item: JellyfinItem = serde_json::from_value(serde_json::json!({
3408 "Id": "ep-1",
3409 "Name": "Partings",
3410 "Type": "Episode",
3411 "MediaStreams": [
3412 { "Type": "Video", "Index": 0, "Codec": "hevc", "IsDefault": true },
3413 { "Type": "Audio", "Index": 1, "Codec": "eac3", "IsDefault": true },
3414 { "Type": "Subtitle", "Index": 2, "Codec": "PGSSUB", "IsDefault": true },
3415 { "Type": "Subtitle", "Index": 3, "Codec": "subrip", "IsDefault": false },
3416 { "Type": "Subtitle", "Index": 4, "Codec": null, "IsDefault": false },
3417 ],
3418 }))
3419 .expect("fixture must deserialize");
3420
3421 let streams = item.into_media_item("server-1".to_string()).media_streams;
3422 let streams = streams.expect("the item carries streams");
3423 let deliverable = |index: i32| {
3424 streams
3425 .iter()
3426 .find(|s| s.index == index)
3427 .unwrap_or_else(|| panic!("stream {index} missing"))
3428 .supports_external_delivery
3429 };
3430
3431 assert_eq!(deliverable(2), Some(false));
3433 assert_eq!(deliverable(3), Some(true));
3435 assert_eq!(deliverable(4), Some(false));
3438 assert_eq!(deliverable(0), None);
3441 assert_eq!(deliverable(1), None);
3442 }
3443
3444 #[test]
3450 fn test_each_stream_open_gets_a_fresh_session_and_reports_the_previous() {
3451 let _lock = QUALITY_LOCK.lock_safe();
3452
3453 let (first, _) = begin_video_play_session();
3454 let (second, replaced) = begin_video_play_session();
3455
3456 assert_ne!(first, second, "each open needs its own job identity");
3457 assert_eq!(
3458 replaced,
3459 Some(first),
3460 "the open must hand back the job it superseded so it can be stopped"
3461 );
3462
3463 let replaced_by_adoption = adopt_video_play_session("server-named-session".to_string());
3467 assert_eq!(replaced_by_adoption, Some(second));
3468
3469 let (_, after_adoption) = begin_video_play_session();
3470 assert_eq!(
3471 after_adoption,
3472 Some("server-named-session".to_string()),
3473 "the adopted job must be the one the next open stops"
3474 );
3475 }
3476
3477 #[tokio::test]
3478 async fn test_get_audio_only_stream_url_for_video_carries_track_and_position() {
3479 let repo = create_test_repository();
3484
3485 let url = repo
3486 .get_audio_only_stream_url_for_video("vid-1", Some("source-1"), Some(193.0), Some(2))
3487 .await
3488 .unwrap();
3489
3490 assert!(
3491 url.starts_with("https://test.server.com/Audio/vid-1/universal?"),
3492 "expected audio-only universal endpoint, got: {url}"
3493 );
3494 assert!(
3496 !url.contains("/Videos/"),
3497 "url must not hit the video endpoint: {url}"
3498 );
3499 assert!(
3500 !url.contains("master.m3u8"),
3501 "url must not be a video HLS playlist: {url}"
3502 );
3503 assert!(url.contains("AudioStreamIndex=2"));
3504 assert!(url.contains("MediaSourceId=source-1"));
3505 assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
3507 assert!(url.contains("TranscodingProtocol=http"), "url: {url}");
3510 assert!(url.contains("TranscodingContainer=mp3"), "url: {url}");
3511 assert!(
3512 !url.contains("TranscodingProtocol=hls"),
3513 "url must not be HLS: {url}"
3514 );
3515 assert!(!url.contains("Container=ts"), "url must not be ts: {url}");
3516 }
3517
3518 #[tokio::test]
3519 async fn test_get_audio_only_stream_url_for_video_omits_position_when_absent() {
3520 let repo = create_test_repository();
3522
3523 let url = repo
3524 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
3525 .await
3526 .unwrap();
3527
3528 assert!(url.starts_with("https://test.server.com/Audio/vid-1/universal?"));
3529 assert!(!url.contains("StartTimeTicks"));
3530 assert!(!url.contains("MediaSourceId"));
3531 assert!(
3534 !url.contains("AudioStreamIndex"),
3535 "must not pin an audio index when none was chosen: {url}"
3536 );
3537 }
3538
3539 #[tokio::test]
3540 async fn test_get_audio_stream_url_with_special_characters() {
3541 let repo = create_test_repository();
3542 let item_id = "track-with-special-chars-!@#";
3543
3544 let result = repo.get_audio_stream_url(item_id).await;
3545
3546 assert!(result.is_ok());
3547 let url = result.unwrap();
3548 assert!(url.contains("track-with-special-chars-!@#"));
3549 assert!(url.starts_with("https://test.server.com/Audio/"));
3550 }
3551
3552 #[test]
3553 fn test_image_tags_deserialize_hashmap_format() {
3554 let json = r#"{"Primary":"abc123","Banner":"def456","Backdrop":"ghi789"}"#;
3556 let result: Result<ImageTags, _> = serde_json::from_str(json);
3557
3558 assert!(result.is_ok());
3559 let tags = result.unwrap();
3560 assert_eq!(tags.primary(), Some("abc123".to_string()));
3561 }
3562
3563 #[test]
3564 fn test_image_tags_deserialize_structured_format() {
3565 let json = r#"{"Primary":"xyz789"}"#;
3567 let result: Result<ImageTags, _> = serde_json::from_str(json);
3568
3569 assert!(result.is_ok());
3570 let tags = result.unwrap();
3571 assert_eq!(tags.primary(), Some("xyz789".to_string()));
3572 }
3573
3574 #[test]
3575 fn test_image_tags_deserialize_missing_primary() {
3576 let json = r#"{"Banner":"def456","Backdrop":"ghi789"}"#;
3578 let result: Result<ImageTags, _> = serde_json::from_str(json);
3579
3580 assert!(result.is_ok());
3581 let tags = result.unwrap();
3582 assert_eq!(tags.primary(), None);
3583 }
3584
3585 #[test]
3586 fn test_image_tags_deserialize_empty_map() {
3587 let json = r#"{}"#;
3589 let result: Result<ImageTags, _> = serde_json::from_str(json);
3590
3591 assert!(result.is_ok());
3592 let tags = result.unwrap();
3593 assert_eq!(tags.primary(), None);
3594 }
3595
3596 #[test]
3611 fn test_video_download_url_uses_stream_not_download_endpoint() {
3612 let repo = create_test_repository();
3613 let url = repo.get_video_download_url("item123", "original", None, None);
3614
3615 assert!(
3617 !url.contains("/download"),
3618 "download URL must not use the broken /Videos/{{id}}/download endpoint: {url}"
3619 );
3620 assert!(
3622 url.contains("/Videos/item123/stream.mp4"),
3623 "download URL must target /Videos/{{id}}/stream.mp4: {url}"
3624 );
3625 assert!(url.contains("ApiKey=test-access-token"), "url: {url}");
3626 }
3627
3628 #[test]
3629 fn test_video_download_url_original_is_static_direct_copy() {
3630 let repo = create_test_repository();
3631 let url = repo.get_video_download_url("item123", "original", None, None);
3632
3633 assert!(url.contains("Static=true"), "url: {url}");
3636 assert!(
3637 !url.contains("videoBitRate"),
3638 "original must not transcode: {url}"
3639 );
3640 assert!(
3641 !url.contains("maxHeight"),
3642 "original must not transcode: {url}"
3643 );
3644 }
3645
3646 #[test]
3647 fn test_video_download_url_quality_presets_transcode() {
3648 let repo = create_test_repository();
3649
3650 for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
3651 let url = repo.get_video_download_url("item123", quality, None, None);
3652 assert!(
3653 url.contains("/Videos/item123/stream.mp4"),
3654 "{quality} must use stream.mp4: {url}"
3655 );
3656 assert!(
3657 url.contains("videoBitRate="),
3658 "{quality} must set bitrate: {url}"
3659 );
3660 assert!(
3661 url.contains(&format!("maxHeight={height}")),
3662 "{quality} must cap height at {height}: {url}"
3663 );
3664 assert!(url.contains("videoCodec=h264"), "{quality}: {url}");
3665 assert!(
3667 !url.contains("Static=true"),
3668 "{quality} must not be Static: {url}"
3669 );
3670 }
3671 }
3672
3673 #[test]
3680 fn test_video_download_url_bitrate_params_use_capital_r_spelling() {
3681 let repo = create_test_repository();
3682
3683 for quality in ["high", "medium", "low"] {
3684 let url = repo.get_video_download_url("item123", quality, None, None);
3685
3686 assert!(
3687 url.contains("videoBitRate="),
3688 "{quality} must spell it videoBitRate (capital R): {url}"
3689 );
3690 assert!(
3691 url.contains("audioBitRate="),
3692 "{quality} must spell it audioBitRate (capital R): {url}"
3693 );
3694
3695 assert!(
3698 !url.contains("videoBitrate="),
3699 "{quality} emits the unbindable lowercase-r spelling: {url}"
3700 );
3701 assert!(
3702 !url.contains("audioBitrate="),
3703 "{quality} emits the unbindable lowercase-r spelling: {url}"
3704 );
3705 }
3706 }
3707
3708 #[test]
3714 fn test_video_download_url_transcode_presets_forbid_video_stream_copy() {
3715 let repo = create_test_repository();
3716
3717 for quality in ["high", "medium", "low"] {
3718 let url = repo.get_video_download_url("item123", quality, None, None);
3719 assert!(
3720 url.contains("allowVideoStreamCopy=false"),
3721 "{quality} must forbid video stream copy: {url}"
3722 );
3723 }
3724
3725 let original = repo.get_video_download_url("item123", "original", None, None);
3727 assert!(
3728 !original.contains("allowVideoStreamCopy=false"),
3729 "original must remain a direct copy: {original}"
3730 );
3731 }
3732
3733 #[test]
3750 fn test_video_download_url_original_transcodes_undecodable_audio() {
3751 let repo = create_test_repository();
3752
3753 for codec in ["eac3", "ac3", "dts", "truehd", "EAC3"] {
3754 let url = repo.get_video_download_url("item123", "original", None, Some(codec));
3755 assert!(
3756 !url.contains("Static=true"),
3757 "{codec} cannot be decoded here, so the source must not be copied verbatim: {url}"
3758 );
3759 assert!(
3760 url.contains("audioCodec=aac"),
3761 "{codec} must be re-encoded to aac on the way down: {url}"
3762 );
3763 assert!(
3766 url.contains("allowVideoStreamCopy=true"),
3767 "the video stream must still be copied where possible: {url}"
3768 );
3769 assert!(
3770 !url.contains("videoBitRate") && !url.contains("maxHeight"),
3771 "original must not degrade the picture to fix the audio: {url}"
3772 );
3773 }
3774 }
3775
3776 #[test]
3782 fn test_video_download_url_original_keeps_static_copy_for_playable_audio() {
3783 let repo = create_test_repository();
3784
3785 for codec in ["aac", "mp3", "opus", "vorbis", "flac", "AAC"] {
3786 let url = repo.get_video_download_url("item123", "original", None, Some(codec));
3787 assert!(
3788 url.contains("Static=true"),
3789 "{codec} plays here — the download must stay a direct copy: {url}"
3790 );
3791 assert!(
3792 !url.contains("audioCodec="),
3793 "{codec} needs no transcode: {url}"
3794 );
3795 }
3796
3797 let unknown = repo.get_video_download_url("item123", "original", None, None);
3800 assert!(unknown.contains("Static=true"), "url: {unknown}");
3801 }
3802
3803 #[test]
3808 fn test_video_download_url_presets_ignore_the_audio_policy() {
3809 let repo = create_test_repository();
3810
3811 for quality in ["high", "medium", "low"] {
3812 let with = repo.get_video_download_url("item123", quality, None, Some("eac3"));
3813 let without = repo.get_video_download_url("item123", quality, None, None);
3814 assert_eq!(with, without, "{quality} must not vary with source audio");
3815 assert!(with.contains("audioCodec=aac"), "url: {with}");
3816 }
3817 }
3818
3819 #[test]
3820 fn test_video_download_url_passes_media_source_id() {
3821 let repo = create_test_repository();
3822 let url = repo.get_video_download_url("item123", "original", Some("src-42"), None);
3823 assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
3824 }
3825
3826 #[test]
3827 fn test_jellyfin_item_deserialize_with_image_tags() {
3828 let json = r#"{
3830 "Id": "album123",
3831 "Name": "Test Album",
3832 "Type": "MusicAlbum",
3833 "ImageTags": {"Primary": "tag123"},
3834 "ArtistItems": [
3835 {"Id": "artist1", "Name": "Artist One"},
3836 {"Id": "artist2", "Name": "Artist Two"}
3837 ]
3838 }"#;
3839
3840 let result: Result<JellyfinItem, _> = serde_json::from_str(json);
3841 assert!(result.is_ok());
3842
3843 let item = result.unwrap();
3844 assert_eq!(item.id, "album123");
3845 assert_eq!(item.name, "Test Album");
3846 assert_eq!(item.item_type, "MusicAlbum");
3847 assert!(item.image_tags.is_some());
3848 assert_eq!(
3849 item.image_tags.unwrap().primary(),
3850 Some("tag123".to_string())
3851 );
3852 }
3853
3854 #[test]
3858 fn test_build_favorites_endpoint_scopes_and_filters() {
3859 let movies = build_favorites_endpoint("u1", SearchScope::Movies, None);
3860 assert!(movies.starts_with("/Users/u1/Items?Filters=IsFavorite&Recursive=true"));
3861 assert!(movies.contains("&IncludeItemTypes=Movie"));
3862 assert!(movies.contains("&SortBy=SortName&SortOrder=Ascending"));
3864 assert!(movies.contains("UserData"));
3866
3867 let tv = build_favorites_endpoint("u1", SearchScope::Tv, None);
3869 assert!(tv.contains("&IncludeItemTypes=Series,Episode"));
3870
3871 let music = build_favorites_endpoint("u1", SearchScope::Music, None);
3872 assert!(music.contains("&IncludeItemTypes=MusicAlbum,MusicArtist,Audio,Playlist"));
3873 }
3874
3875 #[test]
3880 fn test_build_favorites_endpoint_all_scope_omits_type_filter() {
3881 let all = build_favorites_endpoint("u1", SearchScope::All, None);
3882 assert!(!all.contains("IncludeItemTypes"));
3883 }
3884
3885 #[test]
3889 fn test_build_favorites_endpoint_honours_paging_and_sort() {
3890 let endpoint = build_favorites_endpoint(
3891 "u1",
3892 SearchScope::All,
3893 Some(&GetItemsOptions {
3894 limit: Some(20),
3895 start_index: Some(40),
3896 sort_by: Some("Random".to_string()),
3897 sort_order: Some("Descending".to_string()),
3898 ..Default::default()
3899 }),
3900 );
3901 assert!(endpoint.contains("&Limit=20"));
3902 assert!(endpoint.contains("&StartIndex=40"));
3903 assert!(endpoint.contains("&SortBy=Random&SortOrder=Descending"));
3904 }
3905
3906 #[test]
3911 fn test_get_items_endpoint_applies_favorites_only() {
3912 let plain = build_get_items_endpoint("u1", "lib-1", None);
3913 assert!(!plain.contains("Filters=IsFavorite"));
3914
3915 let filtered = build_get_items_endpoint(
3916 "u1",
3917 "lib-1",
3918 Some(&GetItemsOptions {
3919 favorites_only: Some(true),
3920 include_item_types: Some(vec!["Movie".to_string()]),
3921 ..Default::default()
3922 }),
3923 );
3924 assert!(filtered.contains("&Filters=IsFavorite"));
3925 assert!(filtered.contains("&IncludeItemTypes=Movie"));
3927 assert!(filtered.contains("ParentId=lib-1"));
3928
3929 let off = build_get_items_endpoint(
3931 "u1",
3932 "lib-1",
3933 Some(&GetItemsOptions {
3934 favorites_only: Some(false),
3935 ..Default::default()
3936 }),
3937 );
3938 assert!(!off.contains("Filters=IsFavorite"));
3939 }
3940
3941 #[test]
3950 fn test_get_items_endpoint_encodes_query_values() {
3951 let endpoint = build_get_items_endpoint(
3952 "u1",
3953 "lib 1&Filters=IsFavorite",
3954 Some(&GetItemsOptions {
3955 include_item_types: Some(vec!["Movie&x=1".to_string()]),
3956 sort_by: Some("Sort Name".to_string()),
3957 sort_order: Some("Ascending&y=2".to_string()),
3958 ..Default::default()
3959 }),
3960 );
3961 assert!(
3962 endpoint.contains("ParentId=lib%201%26Filters%3DIsFavorite"),
3963 "{endpoint}"
3964 );
3965 assert!(
3966 endpoint.contains("&IncludeItemTypes=Movie%26x%3D1"),
3967 "{endpoint}"
3968 );
3969 assert!(endpoint.contains("&SortBy=Sort%20Name"), "{endpoint}");
3970 assert!(
3971 endpoint.contains("&SortOrder=Ascending%26y%3D2"),
3972 "{endpoint}"
3973 );
3974 assert!(!endpoint.contains("&Filters=IsFavorite"), "{endpoint}");
3976 assert!(!endpoint.contains("&x=1"), "{endpoint}");
3977 assert!(!endpoint.contains("&y=2"), "{endpoint}");
3978 }
3979
3980 #[test]
3986 fn test_get_items_endpoint_keeps_list_separators() {
3987 let endpoint = build_get_items_endpoint(
3988 "u1",
3989 "lib-1",
3990 Some(&GetItemsOptions {
3991 sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
3992 include_item_types: Some(vec!["Movie".to_string(), "Series".to_string()]),
3993 ..Default::default()
3994 }),
3995 );
3996 assert!(
3997 endpoint.contains("&SortBy=ParentIndexNumber,IndexNumber,SortName"),
3998 "{endpoint}"
3999 );
4000 assert!(
4001 endpoint.contains("&IncludeItemTypes=Movie,Series"),
4002 "{endpoint}"
4003 );
4004 assert!(endpoint.contains("ParentId=lib-1"), "{endpoint}");
4006 }
4007
4008 #[test]
4021 fn test_get_items_endpoint_orders_channel_folders_by_release_date() {
4022 let podcast = build_get_items_endpoint(
4023 "u1",
4024 "podcast-1",
4025 Some(&GetItemsOptions {
4026 parent_kind: Some(MediaKind::ChannelFolder),
4027 ..Default::default()
4028 }),
4029 );
4030 assert!(
4031 podcast.contains("&SortBy=PremiereDate&SortOrder=Descending"),
4032 "{podcast}"
4033 );
4034
4035 let season = build_get_items_endpoint(
4037 "u1",
4038 "season-1",
4039 Some(&GetItemsOptions {
4040 parent_kind: Some(MediaKind::Season),
4041 ..Default::default()
4042 }),
4043 );
4044 assert!(
4045 season.contains("&SortBy=SortName&SortOrder=Ascending"),
4046 "{season}"
4047 );
4048
4049 let explicit = build_get_items_endpoint(
4051 "u1",
4052 "podcast-1",
4053 Some(&GetItemsOptions {
4054 parent_kind: Some(MediaKind::ChannelFolder),
4055 sort_by: Some("SortName".to_string()),
4056 sort_order: Some("Ascending".to_string()),
4057 ..Default::default()
4058 }),
4059 );
4060 assert!(
4061 explicit.contains("&SortBy=SortName&SortOrder=Ascending"),
4062 "{explicit}"
4063 );
4064 assert!(!explicit.contains("SortBy=PremiereDate"), "{explicit}");
4065
4066 let unspecified = build_get_items_endpoint("u1", "lib-1", None);
4069 assert!(!unspecified.contains("SortBy="), "{unspecified}");
4070 }
4071
4072 #[test]
4079 fn test_latest_items_endpoint_groups_children_into_containers() {
4080 let endpoint = build_latest_items_endpoint("u1", "lib-1", Some(16));
4081
4082 assert!(
4083 endpoint.contains("GroupItems=true"),
4084 "latest items must be grouped so an album counts once, got: {}",
4085 endpoint
4086 );
4087 assert!(endpoint.contains("ParentId=lib-1"));
4088 assert!(endpoint.contains("Limit=16"));
4089 }
4090
4091 fn item_from_json(json: &str) -> MediaItem {
4094 let parsed: JellyfinItem = serde_json::from_str(json).expect("fixture must parse");
4095 parsed.into_media_item("srv".to_string())
4096 }
4097
4098 fn track(id: &str, name: &str, album_id: Option<&str>) -> MediaItem {
4099 let album = match album_id {
4100 Some(a) => format!(r#""AlbumId": "{a}", "Album": "Kind of Blue","#),
4101 None => String::new(),
4102 };
4103 item_from_json(&format!(
4104 r#"{{
4105 "Id": "{id}",
4106 "Name": "{name}",
4107 "Type": "Audio",
4108 {album}
4109 "ImageTags": {{"Primary": "art-{id}"}},
4110 "AlbumArtist": "Miles Davis",
4111 "Artists": ["Miles Davis"],
4112 "IndexNumber": 1,
4113 "RunTimeTicks": 1000
4114 }}"#
4115 ))
4116 }
4117
4118 #[test]
4125 fn test_collapse_tracks_into_albums_shows_one_card_per_album() {
4126 let movie = item_from_json(
4127 r#"{"Id": "mov-1", "Name": "Heat", "Type": "Movie", "ImageTags": {"Primary": "art-mov"}}"#,
4128 );
4129 let items = vec![
4130 track("trk-1", "So What", Some("alb-1")),
4131 track("trk-2", "Blue in Green", Some("alb-1")),
4132 movie,
4133 track("trk-3", "Flamenco Sketches", Some("alb-1")),
4134 ];
4135
4136 let collapsed = collapse_tracks_into_albums(items);
4137
4138 assert_eq!(
4139 collapsed.len(),
4140 2,
4141 "three tracks of one album plus a movie must read as two cards, got: {:?}",
4142 collapsed.iter().map(|i| &i.name).collect::<Vec<_>>()
4143 );
4144
4145 let album = &collapsed[0];
4146 assert_eq!(album.id, "alb-1", "the card must open the album");
4147 assert_eq!(album.name, "Kind of Blue");
4148 assert_eq!(album.item_type, "MusicAlbum");
4149 assert_eq!(album.kind, crate::domain::MediaKind::Album);
4150 assert!(album.is_folder);
4151 assert_eq!(album.album_artist.as_deref(), Some("Miles Davis"));
4152 assert!(album.image_id.is_some(), "album card needs artwork");
4153 assert!(album.index_number.is_none());
4155 assert!(album.album_id.is_none());
4156 assert!(album.runtime_ticks.is_none());
4157
4158 assert_eq!(collapsed[1].id, "mov-1");
4160 }
4161
4162 #[test]
4167 fn test_collapse_prefers_the_album_row_the_server_returned() {
4168 let album = item_from_json(
4169 r#"{"Id": "alb-1", "Name": "Kind of Blue", "Type": "MusicAlbum", "IsFolder": true,
4170 "Overview": "1959", "ImageTags": {"Primary": "art-alb"}}"#,
4171 );
4172 let items = vec![
4173 album,
4174 track("trk-1", "So What", Some("alb-1")),
4175 track("trk-2", "Blue in Green", Some("alb-1")),
4176 ];
4177
4178 let collapsed = collapse_tracks_into_albums(items);
4179
4180 assert_eq!(collapsed.len(), 1, "one album, one card");
4181 assert_eq!(collapsed[0].id, "alb-1");
4182 assert_eq!(
4183 collapsed[0].overview.as_deref(),
4184 Some("1959"),
4185 "the server's own album row must survive, not a track-built stand-in"
4186 );
4187 }
4188
4189 #[test]
4194 fn test_collapse_leaves_a_standalone_track_alone() {
4195 let items = vec![track("trk-1", "Field Recording", None)];
4196
4197 let collapsed = collapse_tracks_into_albums(items);
4198
4199 assert_eq!(collapsed.len(), 1);
4200 assert_eq!(collapsed[0].id, "trk-1");
4201 assert_eq!(collapsed[0].item_type, "Audio");
4202 }
4203
4204 #[test]
4210 fn test_latest_items_over_fetches_before_collapsing() {
4211 assert!(
4212 latest_items_fetch_limit(16) > 16,
4213 "must ask for more rows than the row shows"
4214 );
4215 let endpoint =
4216 build_latest_items_endpoint("u1", "lib-1", Some(latest_items_fetch_limit(16)));
4217 assert!(endpoint.contains(&format!("Limit={}", latest_items_fetch_limit(16))));
4218 }
4219
4220 #[test]
4229 fn test_build_next_up_endpoint_excludes_resumable() {
4230 let endpoint = build_next_up_endpoint("u1", None, Some(12));
4231
4232 assert!(
4233 endpoint.contains("EnableResumable=false"),
4234 "next up must exclude in-progress episodes, got: {}",
4235 endpoint
4236 );
4237 assert!(endpoint.contains("UserId=u1"));
4238 assert!(endpoint.contains("Limit=12"));
4239 assert!(
4240 !endpoint.contains("SeriesId"),
4241 "no series filter when none was requested, got: {}",
4242 endpoint
4243 );
4244 }
4245
4246 #[test]
4250 fn test_build_next_up_endpoint_scopes_to_series() {
4251 let endpoint = build_next_up_endpoint("u1", Some("series-a"), None);
4252
4253 assert!(endpoint.contains("SeriesId=series-a"));
4254 assert!(endpoint.contains("EnableResumable=false"));
4255 assert!(
4256 endpoint.contains("Limit=16"),
4257 "default limit, got: {}",
4258 endpoint
4259 );
4260 }
4261
4262 #[test]
4269 fn test_jellyfin_item_maps_user_data_favorite() {
4270 let json = r#"{
4271 "Id": "movie123",
4272 "Name": "Test Movie",
4273 "Type": "Movie",
4274 "UserData": {
4275 "PlaybackPositionTicks": 6000000000,
4276 "Played": false,
4277 "IsFavorite": true,
4278 "PlayCount": 2,
4279 "LastPlayedDate": "2026-08-01T12:00:00Z"
4280 }
4281 }"#;
4282
4283 let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
4284 let media = item.into_media_item("server1".to_string());
4285
4286 let user_data = media.user_data.expect("user data should be mapped");
4287 assert_eq!(user_data.is_favorite, Some(true));
4288 assert_eq!(user_data.is_played, Some(false));
4289 assert_eq!(user_data.play_count, Some(2));
4290 assert_eq!(user_data.playback_position_ticks, Some(6_000_000_000));
4291 assert_eq!(user_data.playback_position_ms, Some(600_000));
4293 }
4294
4295 #[test]
4300 fn test_jellyfin_item_without_user_data_maps_to_none() {
4301 let json = r#"{"Id": "x", "Name": "No User Data", "Type": "Movie"}"#;
4302
4303 let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
4304 let media = item.into_media_item("server1".to_string());
4305
4306 assert!(media.user_data.is_none());
4307 }
4308
4309 #[test]
4310 fn test_jellyfin_item_deserialize_with_artist_items() {
4311 let json = r#"{
4313 "Id": "track123",
4314 "Name": "Test Track",
4315 "Type": "Audio",
4316 "ArtistItems": [
4317 {"Id": "artist1", "Name": "Bob Dylan"},
4318 {"Id": "artist2", "Name": "Johnny Cash"}
4319 ]
4320 }"#;
4321
4322 let result: Result<JellyfinItem, _> = serde_json::from_str(json);
4323 assert!(result.is_ok());
4324
4325 let item = result.unwrap();
4326 let artist_items = item.artist_items.expect("Expected artist items");
4327 assert_eq!(artist_items.len(), 2);
4328 assert_eq!(artist_items[0].id, "artist1");
4329 assert_eq!(artist_items[0].name, "Bob Dylan");
4330 assert_eq!(artist_items[1].id, "artist2");
4331 assert_eq!(artist_items[1].name, "Johnny Cash");
4332 }
4333
4334 #[test]
4335 fn test_jellyfin_item_to_media_item_conversion() {
4336 let json = r#"{
4338 "Id": "album456",
4339 "Name": "Love and Theft",
4340 "Type": "MusicAlbum",
4341 "ImageTags": {"Primary": "7ebab4f6a80cd09d"},
4342 "Artists": ["Bob Dylan"],
4343 "ArtistItems": [{"Id": "0b2a6e969a27f22aba97f9f0e69fa849", "Name": "Bob Dylan"}],
4344 "RunTimeTicks": 33900137190
4345 }"#;
4346
4347 let jellyfin_item: JellyfinItem = serde_json::from_str(json).expect("Failed to parse");
4348 let media_item = jellyfin_item.into_media_item("test-server-id".to_string());
4349
4350 assert_eq!(media_item.id, "album456");
4351 assert_eq!(media_item.name, "Love and Theft");
4352 assert_eq!(media_item.item_type, "MusicAlbum");
4353 assert_eq!(
4354 media_item.primary_image_tag,
4355 Some("7ebab4f6a80cd09d".to_string())
4356 );
4357 assert_eq!(media_item.server_id, "test-server-id");
4358 }
4359
4360 #[test]
4361 fn test_items_response_deserialize() {
4362 let json = r#"{
4364 "Items": [
4365 {
4366 "Id": "item1",
4367 "Name": "Item One",
4368 "Type": "MusicAlbum",
4369 "ImageTags": {"Primary": "tag1"}
4370 },
4371 {
4372 "Id": "item2",
4373 "Name": "Item Two",
4374 "Type": "Audio",
4375 "ImageTags": {"Primary": "tag2"}
4376 }
4377 ],
4378 "TotalRecordCount": 2
4379 }"#;
4380
4381 let result: Result<ItemsResponse, _> = serde_json::from_str(json);
4382 assert!(result.is_ok());
4383
4384 let response = result.unwrap();
4385 assert_eq!(response.total_record_count, 2);
4386 assert_eq!(response.items.len(), 2);
4387 assert_eq!(response.items[0].id, "item1");
4388 assert_eq!(response.items[1].id, "item2");
4389 }
4390
4391 #[test]
4392 fn test_search_term_is_url_encoded() {
4393 assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
4397 assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
4398 }
4399
4400 #[test]
4401 fn test_jray_context_deserializes_actors() {
4402 let json = r#"{
4404 "actors": [
4405 { "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
4406 ]
4407 }"#;
4408 let ctx: JRayContext = serde_json::from_str(json).expect("should parse");
4409 assert_eq!(ctx.actors.len(), 1);
4410 assert_eq!(ctx.actors[0].name, "Tom Hanks");
4411 assert_eq!(ctx.actors[0].jellyfin_id, "abc123-guid");
4412 }
4413
4414 #[test]
4415 fn test_jray_context_ignores_unknown_keys_and_missing_ids() {
4416 let json = r#"{
4419 "actors": [ { "name": "Extra" } ],
4420 "locations": ["Beach"],
4421 "trivia": "filmed in 1994"
4422 }"#;
4423 let ctx: JRayContext = serde_json::from_str(json).expect("should tolerate extra keys");
4424 assert_eq!(ctx.actors.len(), 1);
4425 assert_eq!(ctx.actors[0].name, "Extra");
4426 assert_eq!(ctx.actors[0].imdb_id, "");
4427 assert_eq!(ctx.actors[0].jellyfin_id, "");
4428 }
4429
4430 fn source_fixture() -> NegotiatedSource {
4445 NegotiatedSource {
4446 id: "source-1".to_string(),
4447 supports_direct_play: true,
4448 supports_direct_stream: true,
4449 supports_transcoding: true,
4450 transcoding_url: None,
4451 bitrate: Some(6_652_961),
4452 media_streams: Vec::new(),
4453 }
4454 }
4455
4456 #[test]
4462 fn test_a_supported_source_direct_plays() {
4463 let source = source_fixture();
4464 assert_eq!(
4465 decide_playback_kind(&source, false, false),
4466 PlaybackKind::DirectPlay
4467 );
4468 }
4469
4470 #[test]
4475 fn test_a_remuxable_source_direct_streams() {
4476 let source = NegotiatedSource {
4477 supports_direct_play: false,
4478 supports_direct_stream: true,
4479 ..source_fixture()
4480 };
4481 let kind = decide_playback_kind(&source, false, false);
4482 assert_eq!(kind, PlaybackKind::DirectStream);
4483 assert!(
4484 !kind.needs_transcoding(),
4485 "a remux costs no encoder time and must not be reported as transcoding"
4486 );
4487 }
4488
4489 #[test]
4494 fn test_an_unsupported_source_transcodes() {
4495 let source = NegotiatedSource {
4496 supports_direct_play: false,
4497 supports_direct_stream: false,
4498 ..source_fixture()
4499 };
4500 assert_eq!(
4501 decide_playback_kind(&source, false, false),
4502 PlaybackKind::Transcode
4503 );
4504 }
4505
4506 #[test]
4513 fn test_undecodable_audio_overrides_the_servers_direct_play_offer() {
4514 let source = source_fixture();
4515 assert!(source.supports_direct_play, "the server said yes");
4516 assert_eq!(
4517 decide_playback_kind(&source, true, false),
4518 PlaybackKind::Transcode,
4519 "silent direct play is worse than a transcode"
4520 );
4521 }
4522
4523 #[test]
4529 fn test_pinning_an_audio_track_forces_a_transcode() {
4530 let source = source_fixture();
4531 assert_eq!(
4532 decide_playback_kind(&source, false, true),
4533 PlaybackKind::Transcode
4534 );
4535 }
4536
4537 #[test]
4545 fn test_a_ceiling_below_the_source_bitrate_transcodes() {
4546 let source = NegotiatedSource {
4548 supports_direct_play: false,
4549 supports_direct_stream: false,
4550 bitrate: Some(6_652_961),
4551 ..source_fixture()
4552 };
4553 assert_eq!(
4554 decide_playback_kind(&source, false, false),
4555 PlaybackKind::Transcode
4556 );
4557
4558 let options =
4560 crate::repository::stream_selection::quality_options_for_source(Some(6_652_961));
4561 let two_mbps = options
4562 .iter()
4563 .find(|o| o.quality == StreamingQuality::Mbps2)
4564 .expect("2 Mbps is on the ladder");
4565 assert!(!two_mbps.exceeds_source);
4566 }
4567
4568 #[test]
4573 fn test_direct_play_is_preferred_over_direct_stream() {
4574 let source = source_fixture();
4575 assert!(source.supports_direct_play && source.supports_direct_stream);
4576 assert_eq!(
4577 decide_playback_kind(&source, false, false),
4578 PlaybackKind::DirectPlay
4579 );
4580 }
4581
4582 #[test]
4593 fn test_a_playback_override_does_not_disturb_the_device_default() {
4594 let _guard = QUALITY_LOCK.lock_safe();
4595 set_streaming_quality(StreamingQuality::Mbps10);
4596 clear_playback_quality_override();
4597 assert_eq!(effective_streaming_quality(), StreamingQuality::Mbps10);
4598
4599 set_playback_quality_override(StreamingQuality::Kbps720);
4600 assert_eq!(
4601 effective_streaming_quality(),
4602 StreamingQuality::Kbps720,
4603 "the override governs the stream being opened now"
4604 );
4605 assert_eq!(
4606 streaming_quality(),
4607 StreamingQuality::Mbps10,
4608 "but the durable default the Settings screen shows is untouched"
4609 );
4610
4611 clear_playback_quality_override();
4612 assert_eq!(
4613 effective_streaming_quality(),
4614 StreamingQuality::Mbps10,
4615 "and dropping the override returns to it"
4616 );
4617 set_streaming_quality(StreamingQuality::Original);
4618 }
4619
4620 #[test]
4626 fn test_the_override_is_droppable_so_it_cannot_outlive_its_playback() {
4627 let _guard = QUALITY_LOCK.lock_safe();
4628 set_streaming_quality(StreamingQuality::Original);
4629 set_playback_quality_override(StreamingQuality::Mbps1);
4630 assert_eq!(playback_quality_override(), Some(StreamingQuality::Mbps1));
4631
4632 clear_playback_quality_override();
4633 assert_eq!(playback_quality_override(), None);
4634 assert_eq!(effective_streaming_quality(), StreamingQuality::Original);
4635 }
4636}