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 quality {
2524 "high" => {
2525 params.push("videoBitRate=8000000".to_string());
2526 params.push("maxHeight=1080".to_string());
2527 params.push("audioBitRate=384000".to_string());
2528 params.push("videoCodec=h264".to_string());
2529 params.push("audioCodec=aac".to_string());
2530 params.push("allowVideoStreamCopy=false".to_string());
2531 }
2532 "medium" => {
2533 params.push("videoBitRate=4000000".to_string());
2534 params.push("maxHeight=720".to_string());
2535 params.push("audioBitRate=256000".to_string());
2536 params.push("videoCodec=h264".to_string());
2537 params.push("audioCodec=aac".to_string());
2538 params.push("allowVideoStreamCopy=false".to_string());
2539 }
2540 "low" => {
2541 params.push("videoBitRate=1500000".to_string());
2542 params.push("maxHeight=480".to_string());
2543 params.push("audioBitRate=128000".to_string());
2544 params.push("videoCodec=h264".to_string());
2545 params.push("audioCodec=aac".to_string());
2546 params.push("allowVideoStreamCopy=false".to_string());
2547 }
2548 _ => match source_audio_codec {
2570 Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
2571 params.push("videoCodec=h264".to_string());
2572 params.push("allowVideoStreamCopy=true".to_string());
2573 params.push("audioCodec=aac".to_string());
2574 params.push("audioBitRate=384000".to_string());
2575 }
2576 _ => params.push("Static=true".to_string()),
2580 },
2581 }
2582
2583 if let Some(source_id) = media_source_id {
2585 params.push(format!("mediaSourceId={}", source_id));
2586 }
2587
2588 url.push('?');
2589 url.push_str(¶ms.join("&"));
2590
2591 url
2592 }
2593
2594 async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
2595 let endpoint = endpoints::favorite_item(&self.capabilities, &self.user_id, item_id);
2596 self.post_json(&endpoint, &serde_json::json!({})).await
2597 }
2598
2599 async fn get_favorites(
2601 &self,
2602 scope: SearchScope,
2603 options: Option<GetItemsOptions>,
2604 ) -> Result<SearchResult, RepoError> {
2605 let endpoint =
2606 endpoints::favorites(&self.capabilities, &self.user_id, scope, options.as_ref());
2607 let response: ItemsResponse = self.get_json(&endpoint).await?;
2608
2609 Ok(SearchResult {
2610 items: response
2611 .items
2612 .into_iter()
2613 .map(|item| item.into_media_item(self.user_id.clone()))
2614 .collect(),
2615 total_record_count: response.total_record_count,
2616 })
2617 }
2618
2619 async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
2626 let endpoint = endpoints::favorite_item(&self.capabilities, &self.user_id, item_id);
2627 let url = format!("{}{}", self.server_url, endpoint);
2628
2629 let result = async {
2630 let request = self
2631 .http_client
2632 .client
2633 .delete(&url)
2634 .header("Authorization", self.auth_header())
2635 .build()
2636 .map_err(|e| RepoError::Network {
2637 message: format!("Failed to build request: {}", e),
2638 })?;
2639
2640 let response = self
2641 .http_client
2642 .request_with_retry(request)
2643 .await
2644 .map_err(|e| RepoError::Network {
2645 message: e.to_string(),
2646 })?;
2647
2648 if !response.status().is_success() {
2649 return Err(RepoError::Server {
2650 message: format!("HTTP {}", response.status()),
2651 });
2652 }
2653
2654 Ok(())
2655 }
2656 .await;
2657
2658 self.report_outcome(&result).await;
2659 result
2660 }
2661
2662 async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
2668 let endpoint = endpoints::played_item(&self.capabilities, &self.user_id, item_id);
2669 let url = format!("{}{}", self.server_url, endpoint);
2670
2671 let result = async {
2672 let request = self
2673 .http_client
2674 .client
2675 .delete(&url)
2676 .header("Authorization", self.auth_header())
2677 .build()
2678 .map_err(|e| RepoError::Network {
2679 message: format!("Failed to build request: {}", e),
2680 })?;
2681
2682 let response = self
2683 .http_client
2684 .request_with_retry(request)
2685 .await
2686 .map_err(|e| RepoError::Network {
2687 message: e.to_string(),
2688 })?;
2689
2690 if !response.status().is_success() {
2691 return Err(RepoError::Server {
2692 message: format!("HTTP {}", response.status()),
2693 });
2694 }
2695
2696 Ok(())
2697 }
2698 .await;
2699
2700 self.report_outcome(&result).await;
2701 result
2702 }
2703
2704 async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
2709 let endpoint = endpoints::played_item(&self.capabilities, &self.user_id, item_id);
2710 let url = format!("{}{}", self.server_url, endpoint);
2711
2712 let result = async {
2713 let request = self
2714 .http_client
2715 .client
2716 .post(&url)
2717 .header("Authorization", self.auth_header())
2718 .header("Content-Length", "0")
2719 .build()
2720 .map_err(|e| RepoError::Network {
2721 message: format!("Failed to build request: {}", e),
2722 })?;
2723
2724 let response = self
2725 .http_client
2726 .request_with_retry(request)
2727 .await
2728 .map_err(|e| RepoError::Network {
2729 message: e.to_string(),
2730 })?;
2731
2732 if !response.status().is_success() {
2733 return Err(RepoError::Server {
2734 message: format!("HTTP {}", response.status()),
2735 });
2736 }
2737
2738 Ok(())
2739 }
2740 .await;
2741
2742 self.report_outcome(&result).await;
2743 result
2744 }
2745
2746 async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
2754 let endpoint = endpoints::person(&self.capabilities, &self.user_id, person_id);
2755 let item: JellyfinItem = self.get_json(&endpoint).await?;
2756 Ok(item.into_media_item(self.user_id.clone()))
2757 }
2758
2759 async fn get_items_by_person(
2763 &self,
2764 person_id: &str,
2765 options: Option<GetItemsOptions>,
2766 ) -> Result<SearchResult, RepoError> {
2767 let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
2768
2769 let endpoint = endpoints::items_by_person(
2770 &self.capabilities,
2771 &self.user_id,
2772 person_id,
2773 limit,
2774 options
2775 .as_ref()
2776 .and_then(|o| o.include_item_types.as_deref()),
2777 );
2778
2779 let response: ItemsResponse = self.get_json(&endpoint).await?;
2780 Ok(SearchResult {
2781 items: response
2782 .items
2783 .into_iter()
2784 .map(|item| item.into_media_item(self.user_id.clone()))
2785 .collect(),
2786 total_record_count: response.total_record_count,
2787 })
2788 }
2789
2790 async fn get_similar_items(
2791 &self,
2792 item_id: &str,
2793 limit: Option<usize>,
2794 ) -> Result<SearchResult, RepoError> {
2795 let limit_str = limit.unwrap_or(20);
2796
2797 let endpoint =
2799 endpoints::similar_items(&self.capabilities, item_id, &self.user_id, limit_str);
2800
2801 let response: ItemsResponse = self.get_json(&endpoint).await?;
2802 Ok(SearchResult {
2803 items: response
2804 .items
2805 .into_iter()
2806 .map(|item| item.into_media_item(self.user_id.clone()))
2807 .collect(),
2808 total_record_count: response.total_record_count,
2809 })
2810 }
2811
2812 async fn create_playlist(
2815 &self,
2816 name: &str,
2817 item_ids: &[String],
2818 ) -> Result<PlaylistCreatedResult, RepoError> {
2819 info!(
2820 "[OnlineRepo] Creating playlist '{}' with {} items",
2821 name,
2822 item_ids.len()
2823 );
2824 let body = serde_json::json!({
2825 "Name": name,
2826 "Ids": item_ids,
2827 "MediaType": "Audio",
2828 "UserId": self.user_id,
2829 });
2830 let response: CreatePlaylistResponse = self
2831 .post_json_response(endpoints::playlists(&self.capabilities), &body)
2832 .await?;
2833 Ok(PlaylistCreatedResult { id: response.id })
2834 }
2835
2836 async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
2837 info!("[OnlineRepo] Deleting playlist {}", playlist_id);
2838 let endpoint = endpoints::playlist_as_item(&self.capabilities, playlist_id);
2839 let url = format!("{}{}", self.server_url, endpoint);
2840
2841 let request = self
2842 .http_client
2843 .client
2844 .delete(&url)
2845 .header("Authorization", self.auth_header())
2846 .build()
2847 .map_err(|e| RepoError::Network {
2848 message: format!("Failed to build request: {}", e),
2849 })?;
2850
2851 let response = self
2852 .http_client
2853 .request_with_retry(request)
2854 .await
2855 .map_err(|e| RepoError::Network {
2856 message: e.to_string(),
2857 })?;
2858
2859 if !response.status().is_success() {
2860 return Err(RepoError::Server {
2861 message: format!("HTTP {}", response.status()),
2862 });
2863 }
2864
2865 Ok(())
2866 }
2867
2868 async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
2869 info!(
2870 "[OnlineRepo] Renaming playlist {} to '{}'",
2871 playlist_id, name
2872 );
2873 let endpoint = endpoints::playlist_as_item(&self.capabilities, playlist_id);
2874 self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
2875 .await
2876 }
2877
2878 async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
2879 let endpoint = endpoints::playlist_items(&self.capabilities, playlist_id, &self.user_id);
2880
2881 let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
2882 debug!(
2883 "[OnlineRepo] Got {} playlist items for {}",
2884 response.items.len(),
2885 playlist_id
2886 );
2887
2888 Ok(response
2889 .items
2890 .into_iter()
2891 .map(|pi| PlaylistEntry {
2892 playlist_item_id: pi.playlist_item_id,
2893 item: pi.item.into_media_item(self.user_id.clone()),
2894 })
2895 .collect())
2896 }
2897
2898 async fn add_to_playlist(
2899 &self,
2900 playlist_id: &str,
2901 item_ids: &[String],
2902 ) -> Result<(), RepoError> {
2903 info!(
2904 "[OnlineRepo] Adding {} items to playlist {}",
2905 item_ids.len(),
2906 playlist_id
2907 );
2908 let ids_param = item_ids
2910 .iter()
2911 .map(|id| urlencoding::encode(id).into_owned())
2912 .collect::<Vec<_>>()
2913 .join(",");
2914 let endpoint = endpoints::playlist_items_add(&self.capabilities, playlist_id, &ids_param);
2915 self.post_json(&endpoint, &serde_json::json!({})).await
2916 }
2917
2918 async fn remove_from_playlist(
2919 &self,
2920 playlist_id: &str,
2921 entry_ids: &[String],
2922 ) -> Result<(), RepoError> {
2923 info!(
2924 "[OnlineRepo] Removing {} entries from playlist {}",
2925 entry_ids.len(),
2926 playlist_id
2927 );
2928 let ids_param = entry_ids
2929 .iter()
2930 .map(|id| urlencoding::encode(id).into_owned())
2931 .collect::<Vec<_>>()
2932 .join(",");
2933 let endpoint =
2934 endpoints::playlist_items_remove(&self.capabilities, playlist_id, &ids_param);
2935 let url = format!("{}{}", self.server_url, endpoint);
2936
2937 let request = self
2938 .http_client
2939 .client
2940 .delete(&url)
2941 .header("Authorization", self.auth_header())
2942 .build()
2943 .map_err(|e| RepoError::Network {
2944 message: format!("Failed to build request: {}", e),
2945 })?;
2946
2947 let response = self
2948 .http_client
2949 .request_with_retry(request)
2950 .await
2951 .map_err(|e| RepoError::Network {
2952 message: e.to_string(),
2953 })?;
2954
2955 if !response.status().is_success() {
2956 return Err(RepoError::Server {
2957 message: format!("HTTP {}", response.status()),
2958 });
2959 }
2960
2961 Ok(())
2962 }
2963
2964 async fn move_playlist_item(
2965 &self,
2966 playlist_id: &str,
2967 item_id: &str,
2968 new_index: u32,
2969 ) -> Result<(), RepoError> {
2970 info!(
2971 "[OnlineRepo] Moving item {} in playlist {} to index {}",
2972 item_id, playlist_id, new_index
2973 );
2974 let endpoint =
2975 endpoints::playlist_item_move(&self.capabilities, playlist_id, item_id, new_index);
2976 self.post_json(&endpoint, &serde_json::json!({})).await
2977 }
2978}
2979
2980#[cfg(test)]
2981mod tests {
2982 use super::*;
2983 use crate::domain::MediaKind;
2984 use crate::utils::lock::MutexSafe;
2985 use std::sync::Arc;
2986
2987 fn create_test_repository() -> OnlineRepository {
2988 let http_config = crate::jellyfin::HttpConfig::default();
2989 let http_client =
2990 Arc::new(HttpClient::new(http_config).expect("Failed to create HTTP client for test"));
2991 OnlineRepository::new(
2992 http_client,
2993 "https://test.server.com".to_string(),
2994 "test-user-id".to_string(),
2995 "test-access-token".to_string(),
2996 )
2997 }
2998
2999 #[test]
3019 fn subtitle_url_uses_jellyfins_stream_route() {
3020 let repo = create_test_repository();
3021
3022 assert_eq!(
3023 repo.get_subtitle_url("item123", "source456", 2, "vtt"),
3024 "https://test.server.com/Videos/item123/source456/Subtitles/2/Stream.vtt"
3025 );
3026 }
3027
3028 fn create_test_repository_with_connectivity(
3032 ) -> (OnlineRepository, crate::connectivity::ConnectivityReporter) {
3033 let monitor_http = HttpClient::new(crate::jellyfin::HttpConfig::default())
3034 .expect("Failed to create HTTP client for monitor");
3035 let monitor = crate::connectivity::ConnectivityMonitor::new(monitor_http);
3036 let reporter = monitor.reporter();
3037 let repo = create_test_repository().with_connectivity(reporter.clone());
3038 (repo, reporter)
3039 }
3040
3041 #[tokio::test]
3050 async fn test_report_outcome_classifies_server_answered_as_reachable() {
3051 let (repo, reporter) = create_test_repository_with_connectivity();
3052
3053 for err in [
3055 RepoError::Authentication {
3056 message: "401".into(),
3057 },
3058 RepoError::NotFound {
3059 message: "404".into(),
3060 },
3061 RepoError::Server {
3062 message: "500".into(),
3063 },
3064 ] {
3065 reporter.mark_unreachable_for_test().await;
3066 assert!(!reporter.is_reachable().await, "precondition: offline");
3067
3068 let result: Result<(), RepoError> = Err(err);
3069 repo.report_outcome(&result).await;
3070
3071 assert!(
3072 reporter.is_reachable().await,
3073 "a server that answers should be reported reachable"
3074 );
3075 }
3076
3077 reporter.mark_unreachable_for_test().await;
3079 let ok: Result<(), RepoError> = Ok(());
3080 repo.report_outcome(&ok).await;
3081 assert!(reporter.is_reachable().await, "Ok ⇒ reachable");
3082 }
3083
3084 #[tokio::test]
3087 async fn test_report_outcome_ignores_local_errors() {
3088 let (repo, reporter) = create_test_repository_with_connectivity();
3089
3090 reporter.mark_unreachable_for_test().await;
3093 for err in [
3094 RepoError::Database {
3095 message: "cache".into(),
3096 },
3097 RepoError::Offline,
3098 ] {
3099 let result: Result<(), RepoError> = Err(err);
3100 repo.report_outcome(&result).await;
3101 assert!(
3102 !reporter.is_reachable().await,
3103 "local-side error must not change reachability"
3104 );
3105 }
3106 }
3107
3108 #[tokio::test]
3114 async fn test_get_json_fast_fails_when_offline() {
3115 let (repo, reporter) = create_test_repository_with_connectivity();
3116 reporter.mark_unreachable_for_test().await;
3117 assert!(!reporter.is_reachable().await, "precondition: offline");
3118
3119 let result: Result<serde_json::Value, RepoError> = repo.get_json("/System/Info").await;
3120 assert!(
3121 matches!(result, Err(RepoError::Offline)),
3122 "known-offline get_json should return Offline immediately, got {:?}",
3123 result
3124 );
3125 }
3126
3127 #[tokio::test]
3130 async fn test_report_outcome_network_error_is_debounced() {
3131 let (repo, reporter) = create_test_repository_with_connectivity();
3132 assert!(reporter.is_reachable().await, "starts online");
3133
3134 let result: Result<(), RepoError> = Err(RepoError::Network {
3135 message: "timeout".into(),
3136 });
3137 repo.report_outcome(&result).await;
3138
3139 assert!(
3140 reporter.is_reachable().await,
3141 "a single network failure stays online (debounced)"
3142 );
3143 }
3144
3145 #[tokio::test]
3146 async fn test_get_audio_stream_url_formats_correctly() {
3147 let repo = create_test_repository();
3148 let item_id = "test-track-123";
3149
3150 let result = repo.get_audio_stream_url(item_id).await;
3151
3152 assert!(result.is_ok());
3153 let url = result.unwrap();
3154 assert_eq!(
3155 url,
3156 "https://test.server.com/Audio/test-track-123/stream?UserId=test-user-id&ApiKey=test-access-token&Static=true"
3157 );
3158 }
3159
3160 static QUALITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3166
3167 struct QualityFixture(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
3168
3169 impl QualityFixture {
3170 fn set(quality: StreamingQuality) -> Self {
3171 let guard = QUALITY_LOCK.lock_safe();
3172 set_streaming_quality(quality);
3173 Self(guard)
3174 }
3175 }
3176
3177 impl Drop for QualityFixture {
3178 fn drop(&mut self) {
3179 set_streaming_quality(StreamingQuality::Original);
3180 clear_playback_quality_override();
3184 }
3185 }
3186
3187 #[tokio::test]
3194 async fn test_video_stream_url_applies_bitrate_cap() {
3195 let _fixture = QualityFixture::set(StreamingQuality::Mbps2);
3196 let repo = create_test_repository();
3197
3198 let url = repo
3199 .get_video_stream_url("vid-1", None, None)
3200 .await
3201 .unwrap();
3202
3203 assert!(url.contains("MaxStreamingBitrate=2000000"), "url: {url}");
3204 assert!(url.contains("VideoBitrate=1808000"), "url: {url}");
3207 assert!(url.contains("AudioBitrate=192000"), "url: {url}");
3208 assert!(url.contains("MaxHeight=720"), "url: {url}");
3209 }
3210
3211 #[tokio::test]
3216 async fn test_video_stream_url_uncapped_keeps_legacy_allowance() {
3217 let _fixture = QualityFixture::set(StreamingQuality::Original);
3218 let repo = create_test_repository();
3219
3220 let url = repo
3221 .get_video_stream_url("vid-1", None, None)
3222 .await
3223 .unwrap();
3224
3225 assert!(url.contains("MaxStreamingBitrate=20000000"), "url: {url}");
3226 assert!(url.contains("VideoBitrate=18000000"), "url: {url}");
3227 assert!(url.contains("AudioBitrate=384000"), "url: {url}");
3228 assert!(
3229 !url.contains("MaxHeight"),
3230 "uncapped must not scale the picture down: {url}"
3231 );
3232 }
3233
3234 #[tokio::test]
3239 async fn test_audio_only_stream_url_takes_the_lower_of_cap_and_default() {
3240 {
3241 let _fixture = QualityFixture::set(StreamingQuality::Kbps720);
3242 let repo = create_test_repository();
3243 let url = repo
3244 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
3245 .await
3246 .unwrap();
3247 assert!(url.contains("MaxStreamingBitrate=96000"), "url: {url}");
3248 }
3249
3250 let _fixture = QualityFixture::set(StreamingQuality::Original);
3251 let repo = create_test_repository();
3252 let url = repo
3253 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
3254 .await
3255 .unwrap();
3256 assert!(url.contains("MaxStreamingBitrate=384000"), "url: {url}");
3257 }
3258
3259 #[tokio::test]
3272 async fn test_get_video_stream_url_returns_an_hls_master_playlist() {
3273 let _fixture = QualityFixture::set(StreamingQuality::Original);
3274 let repo = create_test_repository();
3275
3276 let url = repo
3277 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
3278 .await
3279 .unwrap();
3280
3281 assert!(
3282 url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"),
3283 "expected HLS master playlist, got: {url}"
3284 );
3285 assert!(url.contains("VideoCodec=h264"));
3286 assert!(url.contains("MediaSourceId=source-1"));
3287 assert!(url.contains("AudioStreamIndex=1"));
3288 assert!(!url.contains("stream.mp4"));
3289 }
3290
3291 #[tokio::test]
3316 async fn test_video_stream_url_never_carries_start_time_ticks() {
3317 let _fixture = QualityFixture::set(StreamingQuality::Original);
3318 let repo = create_test_repository();
3319
3320 let url = repo
3321 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
3322 .await
3323 .unwrap();
3324
3325 assert!(
3326 !url.contains("StartTimeTicks"),
3327 "an HLS playlist must never carry StartTimeTicks — the server copies it \
3328 onto every segment URI and then rejects each one with 400: {url}"
3329 );
3330 }
3331
3332 #[tokio::test]
3333 async fn test_get_video_stream_url_omits_position_when_absent() {
3334 let _fixture = QualityFixture::set(StreamingQuality::Original);
3335 let repo = create_test_repository();
3336
3337 let url = repo
3338 .get_video_stream_url("vid-1", None, None)
3339 .await
3340 .unwrap();
3341
3342 assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
3343 assert!(!url.contains("StartTimeTicks"));
3344 assert!(!url.contains("MediaSourceId"));
3345 assert!(
3350 !url.contains("AudioStreamIndex"),
3351 "must not pin an audio index when none was chosen: {url}"
3352 );
3353 }
3354
3355 #[tokio::test]
3366 async fn test_video_stream_url_carries_a_play_session_id() {
3367 let _fixture = QualityFixture::set(StreamingQuality::Original);
3368 let repo = create_test_repository();
3369
3370 let url = repo
3371 .get_video_stream_url("vid-1", None, None)
3372 .await
3373 .unwrap();
3374
3375 assert!(
3376 url.contains("PlaySessionId="),
3377 "every transcode must be openable as its own job: {url}"
3378 );
3379 }
3380
3381 #[tokio::test]
3395 async fn test_video_stream_url_asks_for_no_subtitle_stream() {
3396 let _fixture = QualityFixture::set(StreamingQuality::Original);
3397 let repo = create_test_repository();
3398
3399 let url = repo
3400 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
3401 .await
3402 .unwrap();
3403
3404 assert!(
3405 url.contains("SubtitleStreamIndex=-1"),
3406 "the stream URL must ask for no subtitle, not leave the choice open: {url}"
3407 );
3408 }
3409
3410 #[test]
3418 fn test_media_streams_carry_whether_the_app_can_render_them() {
3419 let item: JellyfinItem = serde_json::from_value(serde_json::json!({
3420 "Id": "ep-1",
3421 "Name": "Partings",
3422 "Type": "Episode",
3423 "MediaStreams": [
3424 { "Type": "Video", "Index": 0, "Codec": "hevc", "IsDefault": true },
3425 { "Type": "Audio", "Index": 1, "Codec": "eac3", "IsDefault": true },
3426 { "Type": "Subtitle", "Index": 2, "Codec": "PGSSUB", "IsDefault": true },
3427 { "Type": "Subtitle", "Index": 3, "Codec": "subrip", "IsDefault": false },
3428 { "Type": "Subtitle", "Index": 4, "Codec": null, "IsDefault": false },
3429 ],
3430 }))
3431 .expect("fixture must deserialize");
3432
3433 let streams = item.into_media_item("server-1".to_string()).media_streams;
3434 let streams = streams.expect("the item carries streams");
3435 let deliverable = |index: i32| {
3436 streams
3437 .iter()
3438 .find(|s| s.index == index)
3439 .unwrap_or_else(|| panic!("stream {index} missing"))
3440 .supports_external_delivery
3441 };
3442
3443 assert_eq!(deliverable(2), Some(false));
3445 assert_eq!(deliverable(3), Some(true));
3447 assert_eq!(deliverable(4), Some(false));
3450 assert_eq!(deliverable(0), None);
3453 assert_eq!(deliverable(1), None);
3454 }
3455
3456 #[test]
3462 fn test_each_stream_open_gets_a_fresh_session_and_reports_the_previous() {
3463 let _lock = QUALITY_LOCK.lock_safe();
3464
3465 let (first, _) = begin_video_play_session();
3466 let (second, replaced) = begin_video_play_session();
3467
3468 assert_ne!(first, second, "each open needs its own job identity");
3469 assert_eq!(
3470 replaced,
3471 Some(first),
3472 "the open must hand back the job it superseded so it can be stopped"
3473 );
3474
3475 let replaced_by_adoption = adopt_video_play_session("server-named-session".to_string());
3479 assert_eq!(replaced_by_adoption, Some(second));
3480
3481 let (_, after_adoption) = begin_video_play_session();
3482 assert_eq!(
3483 after_adoption,
3484 Some("server-named-session".to_string()),
3485 "the adopted job must be the one the next open stops"
3486 );
3487 }
3488
3489 #[tokio::test]
3490 async fn test_get_audio_only_stream_url_for_video_carries_track_and_position() {
3491 let repo = create_test_repository();
3496
3497 let url = repo
3498 .get_audio_only_stream_url_for_video("vid-1", Some("source-1"), Some(193.0), Some(2))
3499 .await
3500 .unwrap();
3501
3502 assert!(
3503 url.starts_with("https://test.server.com/Audio/vid-1/universal?"),
3504 "expected audio-only universal endpoint, got: {url}"
3505 );
3506 assert!(
3508 !url.contains("/Videos/"),
3509 "url must not hit the video endpoint: {url}"
3510 );
3511 assert!(
3512 !url.contains("master.m3u8"),
3513 "url must not be a video HLS playlist: {url}"
3514 );
3515 assert!(url.contains("AudioStreamIndex=2"));
3516 assert!(url.contains("MediaSourceId=source-1"));
3517 assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
3519 assert!(url.contains("TranscodingProtocol=http"), "url: {url}");
3522 assert!(url.contains("TranscodingContainer=mp3"), "url: {url}");
3523 assert!(
3524 !url.contains("TranscodingProtocol=hls"),
3525 "url must not be HLS: {url}"
3526 );
3527 assert!(!url.contains("Container=ts"), "url must not be ts: {url}");
3528 }
3529
3530 #[tokio::test]
3531 async fn test_get_audio_only_stream_url_for_video_omits_position_when_absent() {
3532 let repo = create_test_repository();
3534
3535 let url = repo
3536 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
3537 .await
3538 .unwrap();
3539
3540 assert!(url.starts_with("https://test.server.com/Audio/vid-1/universal?"));
3541 assert!(!url.contains("StartTimeTicks"));
3542 assert!(!url.contains("MediaSourceId"));
3543 assert!(
3546 !url.contains("AudioStreamIndex"),
3547 "must not pin an audio index when none was chosen: {url}"
3548 );
3549 }
3550
3551 #[tokio::test]
3552 async fn test_get_audio_stream_url_with_special_characters() {
3553 let repo = create_test_repository();
3554 let item_id = "track-with-special-chars-!@#";
3555
3556 let result = repo.get_audio_stream_url(item_id).await;
3557
3558 assert!(result.is_ok());
3559 let url = result.unwrap();
3560 assert!(url.contains("track-with-special-chars-!@#"));
3561 assert!(url.starts_with("https://test.server.com/Audio/"));
3562 }
3563
3564 #[test]
3565 fn test_image_tags_deserialize_hashmap_format() {
3566 let json = r#"{"Primary":"abc123","Banner":"def456","Backdrop":"ghi789"}"#;
3568 let result: Result<ImageTags, _> = serde_json::from_str(json);
3569
3570 assert!(result.is_ok());
3571 let tags = result.unwrap();
3572 assert_eq!(tags.primary(), Some("abc123".to_string()));
3573 }
3574
3575 #[test]
3576 fn test_image_tags_deserialize_structured_format() {
3577 let json = r#"{"Primary":"xyz789"}"#;
3579 let result: Result<ImageTags, _> = serde_json::from_str(json);
3580
3581 assert!(result.is_ok());
3582 let tags = result.unwrap();
3583 assert_eq!(tags.primary(), Some("xyz789".to_string()));
3584 }
3585
3586 #[test]
3587 fn test_image_tags_deserialize_missing_primary() {
3588 let json = r#"{"Banner":"def456","Backdrop":"ghi789"}"#;
3590 let result: Result<ImageTags, _> = serde_json::from_str(json);
3591
3592 assert!(result.is_ok());
3593 let tags = result.unwrap();
3594 assert_eq!(tags.primary(), None);
3595 }
3596
3597 #[test]
3598 fn test_image_tags_deserialize_empty_map() {
3599 let json = r#"{}"#;
3601 let result: Result<ImageTags, _> = serde_json::from_str(json);
3602
3603 assert!(result.is_ok());
3604 let tags = result.unwrap();
3605 assert_eq!(tags.primary(), None);
3606 }
3607
3608 #[test]
3623 fn test_video_download_url_uses_stream_not_download_endpoint() {
3624 let repo = create_test_repository();
3625 let url = repo.get_video_download_url("item123", "original", None, None);
3626
3627 assert!(
3629 !url.contains("/download"),
3630 "download URL must not use the broken /Videos/{{id}}/download endpoint: {url}"
3631 );
3632 assert!(
3634 url.contains("/Videos/item123/stream.mp4"),
3635 "download URL must target /Videos/{{id}}/stream.mp4: {url}"
3636 );
3637 assert!(url.contains("ApiKey=test-access-token"), "url: {url}");
3638 }
3639
3640 #[test]
3641 fn test_video_download_url_original_is_static_direct_copy() {
3642 let repo = create_test_repository();
3643 let url = repo.get_video_download_url("item123", "original", None, None);
3644
3645 assert!(url.contains("Static=true"), "url: {url}");
3648 assert!(
3649 !url.contains("videoBitRate"),
3650 "original must not transcode: {url}"
3651 );
3652 assert!(
3653 !url.contains("maxHeight"),
3654 "original must not transcode: {url}"
3655 );
3656 }
3657
3658 #[test]
3659 fn test_video_download_url_quality_presets_transcode() {
3660 let repo = create_test_repository();
3661
3662 for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
3663 let url = repo.get_video_download_url("item123", quality, None, None);
3664 assert!(
3665 url.contains("/Videos/item123/stream.mp4"),
3666 "{quality} must use stream.mp4: {url}"
3667 );
3668 assert!(
3669 url.contains("videoBitRate="),
3670 "{quality} must set bitrate: {url}"
3671 );
3672 assert!(
3673 url.contains(&format!("maxHeight={height}")),
3674 "{quality} must cap height at {height}: {url}"
3675 );
3676 assert!(url.contains("videoCodec=h264"), "{quality}: {url}");
3677 assert!(
3679 !url.contains("Static=true"),
3680 "{quality} must not be Static: {url}"
3681 );
3682 }
3683 }
3684
3685 #[test]
3692 fn test_video_download_url_bitrate_params_use_capital_r_spelling() {
3693 let repo = create_test_repository();
3694
3695 for quality in ["high", "medium", "low"] {
3696 let url = repo.get_video_download_url("item123", quality, None, None);
3697
3698 assert!(
3699 url.contains("videoBitRate="),
3700 "{quality} must spell it videoBitRate (capital R): {url}"
3701 );
3702 assert!(
3703 url.contains("audioBitRate="),
3704 "{quality} must spell it audioBitRate (capital R): {url}"
3705 );
3706
3707 assert!(
3710 !url.contains("videoBitrate="),
3711 "{quality} emits the unbindable lowercase-r spelling: {url}"
3712 );
3713 assert!(
3714 !url.contains("audioBitrate="),
3715 "{quality} emits the unbindable lowercase-r spelling: {url}"
3716 );
3717 }
3718 }
3719
3720 #[test]
3726 fn test_video_download_url_transcode_presets_forbid_video_stream_copy() {
3727 let repo = create_test_repository();
3728
3729 for quality in ["high", "medium", "low"] {
3730 let url = repo.get_video_download_url("item123", quality, None, None);
3731 assert!(
3732 url.contains("allowVideoStreamCopy=false"),
3733 "{quality} must forbid video stream copy: {url}"
3734 );
3735 }
3736
3737 let original = repo.get_video_download_url("item123", "original", None, None);
3739 assert!(
3740 !original.contains("allowVideoStreamCopy=false"),
3741 "original must remain a direct copy: {original}"
3742 );
3743 }
3744
3745 #[test]
3758 fn test_video_download_url_original_transcodes_undecodable_audio() {
3759 let repo = create_test_repository();
3760
3761 for codec in ["eac3", "ac3", "dts", "truehd", "EAC3"] {
3762 let url = repo.get_video_download_url("item123", "original", None, Some(codec));
3763 assert!(
3764 !url.contains("Static=true"),
3765 "{codec} cannot be decoded here, so the source must not be copied verbatim: {url}"
3766 );
3767 assert!(
3768 url.contains("audioCodec=aac"),
3769 "{codec} must be re-encoded to aac on the way down: {url}"
3770 );
3771 assert!(
3774 url.contains("allowVideoStreamCopy=true"),
3775 "the video stream must still be copied where possible: {url}"
3776 );
3777 assert!(
3778 !url.contains("videoBitRate") && !url.contains("maxHeight"),
3779 "original must not degrade the picture to fix the audio: {url}"
3780 );
3781 }
3782 }
3783
3784 #[test]
3790 fn test_video_download_url_original_keeps_static_copy_for_playable_audio() {
3791 let repo = create_test_repository();
3792
3793 for codec in ["aac", "mp3", "opus", "vorbis", "flac", "AAC"] {
3794 let url = repo.get_video_download_url("item123", "original", None, Some(codec));
3795 assert!(
3796 url.contains("Static=true"),
3797 "{codec} plays here — the download must stay a direct copy: {url}"
3798 );
3799 assert!(
3800 !url.contains("audioCodec="),
3801 "{codec} needs no transcode: {url}"
3802 );
3803 }
3804
3805 let unknown = repo.get_video_download_url("item123", "original", None, None);
3808 assert!(unknown.contains("Static=true"), "url: {unknown}");
3809 }
3810
3811 #[test]
3816 fn test_video_download_url_presets_ignore_the_audio_policy() {
3817 let repo = create_test_repository();
3818
3819 for quality in ["high", "medium", "low"] {
3820 let with = repo.get_video_download_url("item123", quality, None, Some("eac3"));
3821 let without = repo.get_video_download_url("item123", quality, None, None);
3822 assert_eq!(with, without, "{quality} must not vary with source audio");
3823 assert!(with.contains("audioCodec=aac"), "url: {with}");
3824 }
3825 }
3826
3827 #[test]
3828 fn test_video_download_url_passes_media_source_id() {
3829 let repo = create_test_repository();
3830 let url = repo.get_video_download_url("item123", "original", Some("src-42"), None);
3831 assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
3832 }
3833
3834 #[test]
3835 fn test_jellyfin_item_deserialize_with_image_tags() {
3836 let json = r#"{
3838 "Id": "album123",
3839 "Name": "Test Album",
3840 "Type": "MusicAlbum",
3841 "ImageTags": {"Primary": "tag123"},
3842 "ArtistItems": [
3843 {"Id": "artist1", "Name": "Artist One"},
3844 {"Id": "artist2", "Name": "Artist Two"}
3845 ]
3846 }"#;
3847
3848 let result: Result<JellyfinItem, _> = serde_json::from_str(json);
3849 assert!(result.is_ok());
3850
3851 let item = result.unwrap();
3852 assert_eq!(item.id, "album123");
3853 assert_eq!(item.name, "Test Album");
3854 assert_eq!(item.item_type, "MusicAlbum");
3855 assert!(item.image_tags.is_some());
3856 assert_eq!(
3857 item.image_tags.unwrap().primary(),
3858 Some("tag123".to_string())
3859 );
3860 }
3861
3862 #[test]
3866 fn test_build_favorites_endpoint_scopes_and_filters() {
3867 let movies = build_favorites_endpoint("u1", SearchScope::Movies, None);
3868 assert!(movies.starts_with("/Users/u1/Items?Filters=IsFavorite&Recursive=true"));
3869 assert!(movies.contains("&IncludeItemTypes=Movie"));
3870 assert!(movies.contains("&SortBy=SortName&SortOrder=Ascending"));
3872 assert!(movies.contains("UserData"));
3874
3875 let tv = build_favorites_endpoint("u1", SearchScope::Tv, None);
3877 assert!(tv.contains("&IncludeItemTypes=Series,Episode"));
3878
3879 let music = build_favorites_endpoint("u1", SearchScope::Music, None);
3880 assert!(music.contains("&IncludeItemTypes=MusicAlbum,MusicArtist,Audio,Playlist"));
3881 }
3882
3883 #[test]
3888 fn test_build_favorites_endpoint_all_scope_omits_type_filter() {
3889 let all = build_favorites_endpoint("u1", SearchScope::All, None);
3890 assert!(!all.contains("IncludeItemTypes"));
3891 }
3892
3893 #[test]
3897 fn test_build_favorites_endpoint_honours_paging_and_sort() {
3898 let endpoint = build_favorites_endpoint(
3899 "u1",
3900 SearchScope::All,
3901 Some(&GetItemsOptions {
3902 limit: Some(20),
3903 start_index: Some(40),
3904 sort_by: Some("Random".to_string()),
3905 sort_order: Some("Descending".to_string()),
3906 ..Default::default()
3907 }),
3908 );
3909 assert!(endpoint.contains("&Limit=20"));
3910 assert!(endpoint.contains("&StartIndex=40"));
3911 assert!(endpoint.contains("&SortBy=Random&SortOrder=Descending"));
3912 }
3913
3914 #[test]
3919 fn test_get_items_endpoint_applies_favorites_only() {
3920 let plain = build_get_items_endpoint("u1", "lib-1", None);
3921 assert!(!plain.contains("Filters=IsFavorite"));
3922
3923 let filtered = build_get_items_endpoint(
3924 "u1",
3925 "lib-1",
3926 Some(&GetItemsOptions {
3927 favorites_only: Some(true),
3928 include_item_types: Some(vec!["Movie".to_string()]),
3929 ..Default::default()
3930 }),
3931 );
3932 assert!(filtered.contains("&Filters=IsFavorite"));
3933 assert!(filtered.contains("&IncludeItemTypes=Movie"));
3935 assert!(filtered.contains("ParentId=lib-1"));
3936
3937 let off = build_get_items_endpoint(
3939 "u1",
3940 "lib-1",
3941 Some(&GetItemsOptions {
3942 favorites_only: Some(false),
3943 ..Default::default()
3944 }),
3945 );
3946 assert!(!off.contains("Filters=IsFavorite"));
3947 }
3948
3949 #[test]
3958 fn test_get_items_endpoint_encodes_query_values() {
3959 let endpoint = build_get_items_endpoint(
3960 "u1",
3961 "lib 1&Filters=IsFavorite",
3962 Some(&GetItemsOptions {
3963 include_item_types: Some(vec!["Movie&x=1".to_string()]),
3964 sort_by: Some("Sort Name".to_string()),
3965 sort_order: Some("Ascending&y=2".to_string()),
3966 ..Default::default()
3967 }),
3968 );
3969 assert!(
3970 endpoint.contains("ParentId=lib%201%26Filters%3DIsFavorite"),
3971 "{endpoint}"
3972 );
3973 assert!(
3974 endpoint.contains("&IncludeItemTypes=Movie%26x%3D1"),
3975 "{endpoint}"
3976 );
3977 assert!(endpoint.contains("&SortBy=Sort%20Name"), "{endpoint}");
3978 assert!(
3979 endpoint.contains("&SortOrder=Ascending%26y%3D2"),
3980 "{endpoint}"
3981 );
3982 assert!(!endpoint.contains("&Filters=IsFavorite"), "{endpoint}");
3984 assert!(!endpoint.contains("&x=1"), "{endpoint}");
3985 assert!(!endpoint.contains("&y=2"), "{endpoint}");
3986 }
3987
3988 #[test]
3994 fn test_get_items_endpoint_keeps_list_separators() {
3995 let endpoint = build_get_items_endpoint(
3996 "u1",
3997 "lib-1",
3998 Some(&GetItemsOptions {
3999 sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
4000 include_item_types: Some(vec!["Movie".to_string(), "Series".to_string()]),
4001 ..Default::default()
4002 }),
4003 );
4004 assert!(
4005 endpoint.contains("&SortBy=ParentIndexNumber,IndexNumber,SortName"),
4006 "{endpoint}"
4007 );
4008 assert!(
4009 endpoint.contains("&IncludeItemTypes=Movie,Series"),
4010 "{endpoint}"
4011 );
4012 assert!(endpoint.contains("ParentId=lib-1"), "{endpoint}");
4014 }
4015
4016 #[test]
4029 fn test_get_items_endpoint_orders_channel_folders_by_release_date() {
4030 let podcast = build_get_items_endpoint(
4031 "u1",
4032 "podcast-1",
4033 Some(&GetItemsOptions {
4034 parent_kind: Some(MediaKind::ChannelFolder),
4035 ..Default::default()
4036 }),
4037 );
4038 assert!(
4039 podcast.contains("&SortBy=PremiereDate&SortOrder=Descending"),
4040 "{podcast}"
4041 );
4042
4043 let season = build_get_items_endpoint(
4045 "u1",
4046 "season-1",
4047 Some(&GetItemsOptions {
4048 parent_kind: Some(MediaKind::Season),
4049 ..Default::default()
4050 }),
4051 );
4052 assert!(
4053 season.contains("&SortBy=SortName&SortOrder=Ascending"),
4054 "{season}"
4055 );
4056
4057 let explicit = build_get_items_endpoint(
4059 "u1",
4060 "podcast-1",
4061 Some(&GetItemsOptions {
4062 parent_kind: Some(MediaKind::ChannelFolder),
4063 sort_by: Some("SortName".to_string()),
4064 sort_order: Some("Ascending".to_string()),
4065 ..Default::default()
4066 }),
4067 );
4068 assert!(
4069 explicit.contains("&SortBy=SortName&SortOrder=Ascending"),
4070 "{explicit}"
4071 );
4072 assert!(!explicit.contains("SortBy=PremiereDate"), "{explicit}");
4073
4074 let unspecified = build_get_items_endpoint("u1", "lib-1", None);
4077 assert!(!unspecified.contains("SortBy="), "{unspecified}");
4078 }
4079
4080 #[test]
4087 fn test_latest_items_endpoint_groups_children_into_containers() {
4088 let endpoint = build_latest_items_endpoint("u1", "lib-1", Some(16));
4089
4090 assert!(
4091 endpoint.contains("GroupItems=true"),
4092 "latest items must be grouped so an album counts once, got: {}",
4093 endpoint
4094 );
4095 assert!(endpoint.contains("ParentId=lib-1"));
4096 assert!(endpoint.contains("Limit=16"));
4097 }
4098
4099 fn item_from_json(json: &str) -> MediaItem {
4102 let parsed: JellyfinItem = serde_json::from_str(json).expect("fixture must parse");
4103 parsed.into_media_item("srv".to_string())
4104 }
4105
4106 fn track(id: &str, name: &str, album_id: Option<&str>) -> MediaItem {
4107 let album = match album_id {
4108 Some(a) => format!(r#""AlbumId": "{a}", "Album": "Kind of Blue","#),
4109 None => String::new(),
4110 };
4111 item_from_json(&format!(
4112 r#"{{
4113 "Id": "{id}",
4114 "Name": "{name}",
4115 "Type": "Audio",
4116 {album}
4117 "ImageTags": {{"Primary": "art-{id}"}},
4118 "AlbumArtist": "Miles Davis",
4119 "Artists": ["Miles Davis"],
4120 "IndexNumber": 1,
4121 "RunTimeTicks": 1000
4122 }}"#
4123 ))
4124 }
4125
4126 #[test]
4133 fn test_collapse_tracks_into_albums_shows_one_card_per_album() {
4134 let movie = item_from_json(
4135 r#"{"Id": "mov-1", "Name": "Heat", "Type": "Movie", "ImageTags": {"Primary": "art-mov"}}"#,
4136 );
4137 let items = vec![
4138 track("trk-1", "So What", Some("alb-1")),
4139 track("trk-2", "Blue in Green", Some("alb-1")),
4140 movie,
4141 track("trk-3", "Flamenco Sketches", Some("alb-1")),
4142 ];
4143
4144 let collapsed = collapse_tracks_into_albums(items);
4145
4146 assert_eq!(
4147 collapsed.len(),
4148 2,
4149 "three tracks of one album plus a movie must read as two cards, got: {:?}",
4150 collapsed.iter().map(|i| &i.name).collect::<Vec<_>>()
4151 );
4152
4153 let album = &collapsed[0];
4154 assert_eq!(album.id, "alb-1", "the card must open the album");
4155 assert_eq!(album.name, "Kind of Blue");
4156 assert_eq!(album.item_type, "MusicAlbum");
4157 assert_eq!(album.kind, crate::domain::MediaKind::Album);
4158 assert!(album.is_folder);
4159 assert_eq!(album.album_artist.as_deref(), Some("Miles Davis"));
4160 assert!(album.image_id.is_some(), "album card needs artwork");
4161 assert!(album.index_number.is_none());
4163 assert!(album.album_id.is_none());
4164 assert!(album.runtime_ticks.is_none());
4165
4166 assert_eq!(collapsed[1].id, "mov-1");
4168 }
4169
4170 #[test]
4175 fn test_collapse_prefers_the_album_row_the_server_returned() {
4176 let album = item_from_json(
4177 r#"{"Id": "alb-1", "Name": "Kind of Blue", "Type": "MusicAlbum", "IsFolder": true,
4178 "Overview": "1959", "ImageTags": {"Primary": "art-alb"}}"#,
4179 );
4180 let items = vec![
4181 album,
4182 track("trk-1", "So What", Some("alb-1")),
4183 track("trk-2", "Blue in Green", Some("alb-1")),
4184 ];
4185
4186 let collapsed = collapse_tracks_into_albums(items);
4187
4188 assert_eq!(collapsed.len(), 1, "one album, one card");
4189 assert_eq!(collapsed[0].id, "alb-1");
4190 assert_eq!(
4191 collapsed[0].overview.as_deref(),
4192 Some("1959"),
4193 "the server's own album row must survive, not a track-built stand-in"
4194 );
4195 }
4196
4197 #[test]
4202 fn test_collapse_leaves_a_standalone_track_alone() {
4203 let items = vec![track("trk-1", "Field Recording", None)];
4204
4205 let collapsed = collapse_tracks_into_albums(items);
4206
4207 assert_eq!(collapsed.len(), 1);
4208 assert_eq!(collapsed[0].id, "trk-1");
4209 assert_eq!(collapsed[0].item_type, "Audio");
4210 }
4211
4212 #[test]
4218 fn test_latest_items_over_fetches_before_collapsing() {
4219 assert!(
4220 latest_items_fetch_limit(16) > 16,
4221 "must ask for more rows than the row shows"
4222 );
4223 let endpoint =
4224 build_latest_items_endpoint("u1", "lib-1", Some(latest_items_fetch_limit(16)));
4225 assert!(endpoint.contains(&format!("Limit={}", latest_items_fetch_limit(16))));
4226 }
4227
4228 #[test]
4237 fn test_build_next_up_endpoint_excludes_resumable() {
4238 let endpoint = build_next_up_endpoint("u1", None, Some(12));
4239
4240 assert!(
4241 endpoint.contains("EnableResumable=false"),
4242 "next up must exclude in-progress episodes, got: {}",
4243 endpoint
4244 );
4245 assert!(endpoint.contains("UserId=u1"));
4246 assert!(endpoint.contains("Limit=12"));
4247 assert!(
4248 !endpoint.contains("SeriesId"),
4249 "no series filter when none was requested, got: {}",
4250 endpoint
4251 );
4252 }
4253
4254 #[test]
4258 fn test_build_next_up_endpoint_scopes_to_series() {
4259 let endpoint = build_next_up_endpoint("u1", Some("series-a"), None);
4260
4261 assert!(endpoint.contains("SeriesId=series-a"));
4262 assert!(endpoint.contains("EnableResumable=false"));
4263 assert!(
4264 endpoint.contains("Limit=16"),
4265 "default limit, got: {}",
4266 endpoint
4267 );
4268 }
4269
4270 #[test]
4277 fn test_jellyfin_item_maps_user_data_favorite() {
4278 let json = r#"{
4279 "Id": "movie123",
4280 "Name": "Test Movie",
4281 "Type": "Movie",
4282 "UserData": {
4283 "PlaybackPositionTicks": 6000000000,
4284 "Played": false,
4285 "IsFavorite": true,
4286 "PlayCount": 2,
4287 "LastPlayedDate": "2026-08-01T12:00:00Z"
4288 }
4289 }"#;
4290
4291 let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
4292 let media = item.into_media_item("server1".to_string());
4293
4294 let user_data = media.user_data.expect("user data should be mapped");
4295 assert_eq!(user_data.is_favorite, Some(true));
4296 assert_eq!(user_data.is_played, Some(false));
4297 assert_eq!(user_data.play_count, Some(2));
4298 assert_eq!(user_data.playback_position_ticks, Some(6_000_000_000));
4299 assert_eq!(user_data.playback_position_ms, Some(600_000));
4301 }
4302
4303 #[test]
4308 fn test_jellyfin_item_without_user_data_maps_to_none() {
4309 let json = r#"{"Id": "x", "Name": "No User Data", "Type": "Movie"}"#;
4310
4311 let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
4312 let media = item.into_media_item("server1".to_string());
4313
4314 assert!(media.user_data.is_none());
4315 }
4316
4317 #[test]
4318 fn test_jellyfin_item_deserialize_with_artist_items() {
4319 let json = r#"{
4321 "Id": "track123",
4322 "Name": "Test Track",
4323 "Type": "Audio",
4324 "ArtistItems": [
4325 {"Id": "artist1", "Name": "Bob Dylan"},
4326 {"Id": "artist2", "Name": "Johnny Cash"}
4327 ]
4328 }"#;
4329
4330 let result: Result<JellyfinItem, _> = serde_json::from_str(json);
4331 assert!(result.is_ok());
4332
4333 let item = result.unwrap();
4334 let artist_items = item.artist_items.expect("Expected artist items");
4335 assert_eq!(artist_items.len(), 2);
4336 assert_eq!(artist_items[0].id, "artist1");
4337 assert_eq!(artist_items[0].name, "Bob Dylan");
4338 assert_eq!(artist_items[1].id, "artist2");
4339 assert_eq!(artist_items[1].name, "Johnny Cash");
4340 }
4341
4342 #[test]
4343 fn test_jellyfin_item_to_media_item_conversion() {
4344 let json = r#"{
4346 "Id": "album456",
4347 "Name": "Love and Theft",
4348 "Type": "MusicAlbum",
4349 "ImageTags": {"Primary": "7ebab4f6a80cd09d"},
4350 "Artists": ["Bob Dylan"],
4351 "ArtistItems": [{"Id": "0b2a6e969a27f22aba97f9f0e69fa849", "Name": "Bob Dylan"}],
4352 "RunTimeTicks": 33900137190
4353 }"#;
4354
4355 let jellyfin_item: JellyfinItem = serde_json::from_str(json).expect("Failed to parse");
4356 let media_item = jellyfin_item.into_media_item("test-server-id".to_string());
4357
4358 assert_eq!(media_item.id, "album456");
4359 assert_eq!(media_item.name, "Love and Theft");
4360 assert_eq!(media_item.item_type, "MusicAlbum");
4361 assert_eq!(
4362 media_item.primary_image_tag,
4363 Some("7ebab4f6a80cd09d".to_string())
4364 );
4365 assert_eq!(media_item.server_id, "test-server-id");
4366 }
4367
4368 #[test]
4369 fn test_items_response_deserialize() {
4370 let json = r#"{
4372 "Items": [
4373 {
4374 "Id": "item1",
4375 "Name": "Item One",
4376 "Type": "MusicAlbum",
4377 "ImageTags": {"Primary": "tag1"}
4378 },
4379 {
4380 "Id": "item2",
4381 "Name": "Item Two",
4382 "Type": "Audio",
4383 "ImageTags": {"Primary": "tag2"}
4384 }
4385 ],
4386 "TotalRecordCount": 2
4387 }"#;
4388
4389 let result: Result<ItemsResponse, _> = serde_json::from_str(json);
4390 assert!(result.is_ok());
4391
4392 let response = result.unwrap();
4393 assert_eq!(response.total_record_count, 2);
4394 assert_eq!(response.items.len(), 2);
4395 assert_eq!(response.items[0].id, "item1");
4396 assert_eq!(response.items[1].id, "item2");
4397 }
4398
4399 #[test]
4400 fn test_search_term_is_url_encoded() {
4401 assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
4405 assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
4406 }
4407
4408 #[test]
4409 fn test_jray_context_deserializes_actors() {
4410 let json = r#"{
4412 "actors": [
4413 { "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
4414 ]
4415 }"#;
4416 let ctx: JRayContext = serde_json::from_str(json).expect("should parse");
4417 assert_eq!(ctx.actors.len(), 1);
4418 assert_eq!(ctx.actors[0].name, "Tom Hanks");
4419 assert_eq!(ctx.actors[0].jellyfin_id, "abc123-guid");
4420 }
4421
4422 #[test]
4423 fn test_jray_context_ignores_unknown_keys_and_missing_ids() {
4424 let json = r#"{
4427 "actors": [ { "name": "Extra" } ],
4428 "locations": ["Beach"],
4429 "trivia": "filmed in 1994"
4430 }"#;
4431 let ctx: JRayContext = serde_json::from_str(json).expect("should tolerate extra keys");
4432 assert_eq!(ctx.actors.len(), 1);
4433 assert_eq!(ctx.actors[0].name, "Extra");
4434 assert_eq!(ctx.actors[0].imdb_id, "");
4435 assert_eq!(ctx.actors[0].jellyfin_id, "");
4436 }
4437
4438 fn source_fixture() -> NegotiatedSource {
4453 NegotiatedSource {
4454 id: "source-1".to_string(),
4455 supports_direct_play: true,
4456 supports_direct_stream: true,
4457 supports_transcoding: true,
4458 transcoding_url: None,
4459 bitrate: Some(6_652_961),
4460 media_streams: Vec::new(),
4461 }
4462 }
4463
4464 #[test]
4470 fn test_a_supported_source_direct_plays() {
4471 let source = source_fixture();
4472 assert_eq!(
4473 decide_playback_kind(&source, false, false),
4474 PlaybackKind::DirectPlay
4475 );
4476 }
4477
4478 #[test]
4483 fn test_a_remuxable_source_direct_streams() {
4484 let source = NegotiatedSource {
4485 supports_direct_play: false,
4486 supports_direct_stream: true,
4487 ..source_fixture()
4488 };
4489 let kind = decide_playback_kind(&source, false, false);
4490 assert_eq!(kind, PlaybackKind::DirectStream);
4491 assert!(
4492 !kind.needs_transcoding(),
4493 "a remux costs no encoder time and must not be reported as transcoding"
4494 );
4495 }
4496
4497 #[test]
4502 fn test_an_unsupported_source_transcodes() {
4503 let source = NegotiatedSource {
4504 supports_direct_play: false,
4505 supports_direct_stream: false,
4506 ..source_fixture()
4507 };
4508 assert_eq!(
4509 decide_playback_kind(&source, false, false),
4510 PlaybackKind::Transcode
4511 );
4512 }
4513
4514 #[test]
4521 fn test_undecodable_audio_overrides_the_servers_direct_play_offer() {
4522 let source = source_fixture();
4523 assert!(source.supports_direct_play, "the server said yes");
4524 assert_eq!(
4525 decide_playback_kind(&source, true, false),
4526 PlaybackKind::Transcode,
4527 "silent direct play is worse than a transcode"
4528 );
4529 }
4530
4531 #[test]
4537 fn test_pinning_an_audio_track_forces_a_transcode() {
4538 let source = source_fixture();
4539 assert_eq!(
4540 decide_playback_kind(&source, false, true),
4541 PlaybackKind::Transcode
4542 );
4543 }
4544
4545 #[test]
4553 fn test_a_ceiling_below_the_source_bitrate_transcodes() {
4554 let source = NegotiatedSource {
4556 supports_direct_play: false,
4557 supports_direct_stream: false,
4558 bitrate: Some(6_652_961),
4559 ..source_fixture()
4560 };
4561 assert_eq!(
4562 decide_playback_kind(&source, false, false),
4563 PlaybackKind::Transcode
4564 );
4565
4566 let options =
4568 crate::repository::stream_selection::quality_options_for_source(Some(6_652_961));
4569 let two_mbps = options
4570 .iter()
4571 .find(|o| o.quality == StreamingQuality::Mbps2)
4572 .expect("2 Mbps is on the ladder");
4573 assert!(!two_mbps.exceeds_source);
4574 }
4575
4576 #[test]
4581 fn test_direct_play_is_preferred_over_direct_stream() {
4582 let source = source_fixture();
4583 assert!(source.supports_direct_play && source.supports_direct_stream);
4584 assert_eq!(
4585 decide_playback_kind(&source, false, false),
4586 PlaybackKind::DirectPlay
4587 );
4588 }
4589
4590 #[test]
4601 fn test_a_playback_override_does_not_disturb_the_device_default() {
4602 let _guard = QUALITY_LOCK.lock_safe();
4603 set_streaming_quality(StreamingQuality::Mbps10);
4604 clear_playback_quality_override();
4605 assert_eq!(effective_streaming_quality(), StreamingQuality::Mbps10);
4606
4607 set_playback_quality_override(StreamingQuality::Kbps720);
4608 assert_eq!(
4609 effective_streaming_quality(),
4610 StreamingQuality::Kbps720,
4611 "the override governs the stream being opened now"
4612 );
4613 assert_eq!(
4614 streaming_quality(),
4615 StreamingQuality::Mbps10,
4616 "but the durable default the Settings screen shows is untouched"
4617 );
4618
4619 clear_playback_quality_override();
4620 assert_eq!(
4621 effective_streaming_quality(),
4622 StreamingQuality::Mbps10,
4623 "and dropping the override returns to it"
4624 );
4625 set_streaming_quality(StreamingQuality::Original);
4626 }
4627
4628 #[test]
4634 fn test_the_override_is_droppable_so_it_cannot_outlive_its_playback() {
4635 let _guard = QUALITY_LOCK.lock_safe();
4636 set_streaming_quality(StreamingQuality::Original);
4637 set_playback_quality_override(StreamingQuality::Mbps1);
4638 assert_eq!(playback_quality_override(), Some(StreamingQuality::Mbps1));
4639
4640 clear_playback_quality_override();
4641 assert_eq!(playback_quality_override(), None);
4642 assert_eq!(effective_streaming_quality(), StreamingQuality::Original);
4643 }
4644}