1use async_trait::async_trait;
4use log::{debug, error, info, warn};
5use serde::{Deserialize, Serialize};
6use std::sync::{Arc, RwLock};
7
8use super::stream_selection::{
9 quality_options_for_source, PlaybackKind, Rendition, StreamSelection, Transport,
10};
11use super::{types::*, MediaRepository};
12use crate::connectivity::ConnectivityReporter;
13use crate::jellyfin::HttpClient;
14use crate::settings::StreamingQuality;
15use crate::utils::lock::RwLockSafe;
16
17static STREAMING_QUALITY: RwLock<StreamingQuality> = RwLock::new(StreamingQuality::Original);
32
33static PLAYBACK_QUALITY_OVERRIDE: RwLock<Option<StreamingQuality>> = RwLock::new(None);
48
49pub fn set_streaming_quality(quality: StreamingQuality) {
58 *STREAMING_QUALITY.write_safe() = quality;
59}
60
61pub fn streaming_quality() -> StreamingQuality {
69 *STREAMING_QUALITY.read_safe()
70}
71
72pub fn set_playback_quality_override(quality: StreamingQuality) {
76 *PLAYBACK_QUALITY_OVERRIDE.write_safe() = Some(quality);
77}
78
79pub fn clear_playback_quality_override() {
87 *PLAYBACK_QUALITY_OVERRIDE.write_safe() = None;
88}
89
90pub fn playback_quality_override() -> Option<StreamingQuality> {
94 *PLAYBACK_QUALITY_OVERRIDE.read_safe()
95}
96
97pub fn effective_streaming_quality() -> StreamingQuality {
107 playback_quality_override().unwrap_or_else(streaming_quality)
108}
109
110const DEVICE_ID: &str = "jellytau-tauri";
114
115static VIDEO_PLAY_SESSION: RwLock<Option<String>> = RwLock::new(None);
124
125pub fn begin_video_play_session() -> (String, Option<String>) {
138 let new_session = uuid::Uuid::new_v4().to_string();
139 let mut current = VIDEO_PLAY_SESSION.write_safe();
140 let previous = current.replace(new_session.clone());
141 (new_session, previous)
142}
143
144pub fn adopt_video_play_session(session_id: String) -> Option<String> {
154 VIDEO_PLAY_SESSION.write_safe().replace(session_id)
155}
156
157#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
163pub struct JRayActor {
164 pub name: String,
165 #[serde(default)]
166 pub imdb_id: String,
167 #[serde(default)]
168 pub tmdb_id: String,
169 #[serde(default)]
170 pub jellyfin_id: String,
171}
172
173#[derive(Debug, Clone, Deserialize)]
176struct JRayContext {
177 #[serde(default)]
178 actors: Vec<JRayActor>,
179}
180
181pub struct OnlineRepository {
183 http_client: Arc<HttpClient>,
184 server_url: String,
185 user_id: String,
186 access_token: String,
187 connectivity: Option<ConnectivityReporter>,
191}
192
193impl OnlineRepository {
194 pub fn user_id(&self) -> &str {
197 &self.user_id
198 }
199
200 pub fn new(
201 http_client: Arc<HttpClient>,
202 server_url: String,
203 user_id: String,
204 access_token: String,
205 ) -> Self {
206 Self {
207 http_client,
208 server_url,
209 user_id,
210 access_token,
211 connectivity: None,
212 }
213 }
214
215 pub fn with_connectivity(mut self, reporter: ConnectivityReporter) -> Self {
218 self.connectivity = Some(reporter);
219 self
220 }
221
222 async fn report_outcome<T>(&self, result: &Result<T, RepoError>) {
231 let Some(reporter) = &self.connectivity else {
232 return;
233 };
234
235 match result {
236 Ok(_)
237 | Err(RepoError::Authentication { .. })
238 | Err(RepoError::NotFound { .. })
239 | Err(RepoError::Server { .. }) => {
240 reporter.report_success().await;
241 }
242 Err(RepoError::Network { message }) => {
243 reporter.report_network_failure(Some(message.clone())).await;
244 }
245 Err(RepoError::Database { .. }) | Err(RepoError::Offline) => {
246 }
249 }
250 }
251
252 fn auth_header(&self) -> String {
254 HttpClient::build_auth_header(Some(&self.access_token), "jellytau-device")
255 }
256
257 pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
260 let request = self
261 .http_client
262 .client
263 .get(url)
264 .header("X-Emby-Authorization", self.auth_header())
265 .build()
266 .map_err(|e| format!("Failed to build request: {}", e))?;
267
268 let response = self
269 .http_client
270 .request_with_retry(request)
271 .await
272 .map_err(|e| format!("Download failed: {}", e))?;
273
274 if !response.status().is_success() {
275 let status = response.status();
276 let body = response.text().await.unwrap_or_default();
277 let body_preview = if body.len() > 200 {
278 &body[..200]
279 } else {
280 &body
281 };
282 return Err(format!("HTTP {} ({})", status, body_preview.trim()));
283 }
284
285 response
286 .bytes()
287 .await
288 .map(|b| b.to_vec())
289 .map_err(|e| format!("Failed to read bytes: {}", e))
290 }
291
292 pub async fn get_jray_actors(
297 &self,
298 item_id: &str,
299 t: f64,
300 ) -> Result<Vec<JRayActor>, RepoError> {
301 let endpoint = format!(
302 "/Plugins/JRay/Items/{}/jray?t={}",
303 urlencoding::encode(item_id),
304 t
305 );
306 match self.get_json::<JRayContext>(&endpoint).await {
307 Ok(context) => Ok(context.actors),
308 Err(RepoError::NotFound { .. }) => Ok(Vec::new()),
310 Err(e) => Err(e),
311 }
312 }
313
314 async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
316 if let Some(reporter) = &self.connectivity {
322 if !reporter.is_reachable().await {
323 return Err(RepoError::Offline);
324 }
325 }
326
327 let result = self.get_json_inner(endpoint).await;
328 self.report_outcome(&result).await;
329 result
330 }
331
332 async fn get_json_inner<T: for<'de> Deserialize<'de>>(
333 &self,
334 endpoint: &str,
335 ) -> Result<T, RepoError> {
336 let url = format!("{}{}", self.server_url, endpoint);
337
338 let request = self
339 .http_client
340 .client
341 .get(&url)
342 .header("X-Emby-Authorization", self.auth_header())
343 .build()
344 .map_err(|e| RepoError::Network {
345 message: format!("Failed to build request: {}", e),
346 })?;
347
348 let response = self
349 .http_client
350 .request_with_retry(request)
351 .await
352 .map_err(|e| RepoError::Network {
353 message: e.to_string(),
354 })?;
355
356 if !response.status().is_success() {
357 let status = response.status();
358 if status.as_u16() == 401 || status.as_u16() == 403 {
359 return Err(RepoError::Authentication {
360 message: format!("HTTP {}", status),
361 });
362 } else if status.as_u16() == 404 {
363 return Err(RepoError::NotFound {
364 message: "Resource not found".to_string(),
365 });
366 } else {
367 return Err(RepoError::Server {
368 message: format!("HTTP {}", status),
369 });
370 }
371 }
372
373 let text = response.text().await.map_err(|e| RepoError::Server {
375 message: format!("Failed to read response: {}", e),
376 })?;
377
378 serde_json::from_str(&text).map_err(|e| {
380 error!(
381 "[OnlineRepo] Failed to deserialize {} response: {}",
382 endpoint, e
383 );
384 error!(
385 "[OnlineRepo] Response body (first 1000 chars): {}",
386 if text.len() > 1000 {
387 &text[..1000]
388 } else {
389 &text
390 }
391 );
392 RepoError::Server {
393 message: format!("Failed to parse response: {}", e),
394 }
395 })
396 }
397
398 async fn post_json<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
400 let result = self.post_json_inner(endpoint, body).await;
401 self.report_outcome(&result).await;
402 result
403 }
404
405 async fn post_json_inner<T: Serialize>(
406 &self,
407 endpoint: &str,
408 body: &T,
409 ) -> Result<(), RepoError> {
410 let url = format!("{}{}", self.server_url, endpoint);
411
412 let request = self
413 .http_client
414 .client
415 .post(&url)
416 .header("Content-Type", "application/json")
417 .header("X-Emby-Authorization", self.auth_header())
418 .json(body)
419 .build()
420 .map_err(|e| RepoError::Network {
421 message: format!("Failed to build request: {}", e),
422 })?;
423
424 let response = self
425 .http_client
426 .request_with_retry(request)
427 .await
428 .map_err(|e| RepoError::Network {
429 message: e.to_string(),
430 })?;
431
432 if !response.status().is_success() {
433 let status = response.status();
434 if status.as_u16() == 401 || status.as_u16() == 403 {
435 return Err(RepoError::Authentication {
436 message: format!("HTTP {}", status),
437 });
438 } else {
439 return Err(RepoError::Server {
440 message: format!("HTTP {}", status),
441 });
442 }
443 }
444
445 Ok(())
446 }
447
448 async fn post_json_response<T: Serialize, R: for<'de> Deserialize<'de>>(
450 &self,
451 endpoint: &str,
452 body: &T,
453 ) -> Result<R, RepoError> {
454 let result = self.post_json_response_inner(endpoint, body).await;
455 self.report_outcome(&result).await;
456 result
457 }
458
459 async fn post_json_response_inner<T: Serialize, R: for<'de> Deserialize<'de>>(
460 &self,
461 endpoint: &str,
462 body: &T,
463 ) -> Result<R, RepoError> {
464 let url = format!("{}{}", self.server_url, endpoint);
465
466 if let Ok(json) = serde_json::to_string_pretty(body) {
468 debug!("[HTTP] POST {}", endpoint);
469 debug!("[HTTP] Request body:\n{}", json);
470 }
471
472 let request = self
473 .http_client
474 .client
475 .post(&url)
476 .header("Content-Type", "application/json")
477 .header("X-Emby-Authorization", self.auth_header())
478 .json(body)
479 .build()
480 .map_err(|e| RepoError::Network {
481 message: format!("Failed to build request: {}", e),
482 })?;
483
484 let response = self
485 .http_client
486 .request_with_retry(request)
487 .await
488 .map_err(|e| RepoError::Network {
489 message: e.to_string(),
490 })?;
491
492 if !response.status().is_success() {
493 let status = response.status();
494
495 let error_body = response
497 .text()
498 .await
499 .unwrap_or_else(|_| "Failed to read error body".to_string());
500 error!("[HTTP] Error response ({}): {}", status, error_body);
501
502 if status.as_u16() == 401 || status.as_u16() == 403 {
503 return Err(RepoError::Authentication {
504 message: format!("HTTP {}: {}", status, error_body),
505 });
506 } else if status.as_u16() == 404 {
507 return Err(RepoError::NotFound {
508 message: format!("Resource not found: {}", error_body),
509 });
510 } else {
511 return Err(RepoError::Server {
512 message: format!("HTTP {}: {}", status, error_body),
513 });
514 }
515 }
516
517 response.json().await.map_err(|e| RepoError::Server {
518 message: format!("Failed to parse response: {}", e),
519 })
520 }
521
522 async fn stop_transcode(&self, play_session_id: &str) {
532 let url = format!(
533 "{}/Videos/ActiveEncodings?deviceId={}&playSessionId={}",
534 self.server_url, DEVICE_ID, play_session_id
535 );
536
537 let request = self
538 .http_client
539 .client
540 .delete(&url)
541 .header("X-Emby-Authorization", self.auth_header())
542 .send();
543
544 match request.await {
545 Ok(response) if response.status().is_success() => {
546 debug!("[Transcode] Stopped previous encoding {}", play_session_id);
547 }
548 Ok(response) => {
549 debug!(
550 "[Transcode] Server declined to stop encoding {}: HTTP {}",
551 play_session_id,
552 response.status()
553 );
554 }
555 Err(e) => {
556 debug!(
557 "[Transcode] Could not stop encoding {}: {}",
558 play_session_id, e
559 );
560 }
561 }
562 }
563
564 pub async fn get_video_stream_url(
593 &self,
594 item_id: &str,
595 media_source_id: Option<&str>,
596 audio_stream_index: Option<i32>,
597 ) -> Result<String, RepoError> {
598 let quality = effective_streaming_quality();
599 let max_bitrate = quality.max_bitrate().unwrap_or(20_000_000);
603 let video_bitrate = quality.video_bitrate().unwrap_or(18_000_000);
604
605 let (play_session_id, superseded) = begin_video_play_session();
610 if let Some(previous) = superseded {
611 self.stop_transcode(&previous).await;
612 }
613
614 let (renderer_video_codecs, _) = super::device_profile::renderer_codecs();
632 let mut params = vec![
633 ("api_key", self.access_token.clone()),
634 ("DeviceId", DEVICE_ID.to_string()),
635 ("PlaySessionId", play_session_id),
636 ("VideoCodec", renderer_video_codecs),
637 ("AudioCodec", "aac".to_string()),
638 ("MaxStreamingBitrate", max_bitrate.to_string()),
639 ("VideoBitrate", video_bitrate.to_string()),
640 ("AudioBitrate", quality.audio_bitrate().to_string()),
641 (
642 "TranscodingMaxAudioChannels",
643 super::device_profile::max_audio_channels().to_string(),
644 ),
645 ("SegmentContainer", "ts".to_string()),
646 ("TranscodingContainer", "ts".to_string()),
647 ("TranscodingProtocol", "hls".to_string()),
648 (
657 "SubtitleStreamIndex",
658 super::device_profile::playback_subtitle_stream_index().to_string(),
659 ),
660 ];
661
662 if let Some(height) = quality.max_height() {
665 params.push(("MaxHeight", height.to_string()));
666 }
667
668 if let Some(index) = audio_stream_index {
675 params.push(("AudioStreamIndex", index.to_string()));
676 }
677
678 if let Some(source_id) = media_source_id {
679 params.push(("MediaSourceId", source_id.to_string()));
680 }
681
682 let query = params
684 .iter()
685 .map(|(k, v)| format!("{}={}", k, v))
686 .collect::<Vec<_>>()
687 .join("&");
688
689 let url = format!(
690 "{}/Videos/{}/master.m3u8?{}",
691 self.server_url, item_id, query
692 );
693
694 Ok(url)
695 }
696
697 pub async fn build_audio_only_stream_url_for_video(
719 &self,
720 item_id: &str,
721 media_source_id: Option<&str>,
722 start_time_seconds: Option<f64>,
723 audio_stream_index: Option<i32>,
724 ) -> Result<String, RepoError> {
725 let mut params = vec![
726 ("UserId", self.user_id.clone()),
727 ("api_key", self.access_token.clone()),
728 ("DeviceId", DEVICE_ID.to_string()),
729 ("Container", "mp3".to_string()),
731 ("AudioCodec", "mp3".to_string()),
732 ("TranscodingContainer", "mp3".to_string()),
733 ("TranscodingProtocol", "http".to_string()),
734 (
739 "MaxStreamingBitrate",
740 effective_streaming_quality()
741 .audio_bitrate()
742 .min(384_000)
743 .to_string(),
744 ),
745 ];
746
747 if let Some(index) = audio_stream_index {
750 params.push(("AudioStreamIndex", index.to_string()));
751 }
752
753 if let Some(source_id) = media_source_id {
754 params.push(("MediaSourceId", source_id.to_string()));
755 }
756
757 if let Some(seconds) = start_time_seconds {
758 let ticks = (seconds * 10_000_000.0) as i64;
759 params.push(("StartTimeTicks", ticks.to_string()));
760 }
761
762 let query = params
763 .iter()
764 .map(|(k, v)| format!("{}={}", k, v))
765 .collect::<Vec<_>>()
766 .join("&");
767
768 let url = format!("{}/Audio/{}/universal?{}", self.server_url, item_id, query);
769
770 Ok(url)
771 }
772
773 async fn negotiate_playback(
783 &self,
784 item_id: &str,
785 ) -> Result<(NegotiatedSource, String), RepoError> {
786 let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
787
788 let (video_codecs, audio_codecs) = super::device_profile::renderer_codecs();
793
794 let video_audio_codecs = super::device_profile::video_audio_codecs(&audio_codecs);
800
801 info!("[DeviceProfile] Using video codecs: {}", video_codecs);
802 info!("[DeviceProfile] Using audio codecs: {}", audio_codecs);
803 info!(
804 "[DeviceProfile] Audio codecs for video direct play: {}",
805 video_audio_codecs
806 );
807
808 let max_audio_channels = super::device_profile::max_audio_channels().to_string();
812 info!("[DeviceProfile] Max audio channels: {}", max_audio_channels);
813
814 let quality = effective_streaming_quality();
823 let negotiated_bitrate = quality.max_bitrate().unwrap_or(999_999_999) as i64;
824 if let Some(cap) = quality.max_bitrate() {
825 info!(
826 "[DeviceProfile] Streaming quality cap active: {} ({} bps)",
827 quality.label(),
828 cap
829 );
830 }
831
832 let device_profile = DeviceProfile {
834 name: "JellyTau Native Player".to_string(),
835 max_streaming_bitrate: negotiated_bitrate,
836 max_static_bitrate: negotiated_bitrate,
837 max_audio_channels: max_audio_channels.clone(),
838 direct_play_profiles: vec![
839 DirectPlayProfile {
840 profile_type: "Video".to_string(),
841 container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp".to_string(),
842 video_codec: Some(video_codecs.clone()),
843 audio_codec: video_audio_codecs.clone(),
845 },
846 DirectPlayProfile {
847 profile_type: "Audio".to_string(),
848 container: "mp3,aac,flac,alac,wav,ogg,wma,opus".to_string(),
849 video_codec: None,
850 audio_codec: audio_codecs.clone(),
854 },
855 ],
856 transcoding_profiles: vec![
857 TranscodingProfile {
858 profile_type: "Video".to_string(),
859 context: "Streaming".to_string(),
860 protocol: "hls".to_string(),
861 container: "ts".to_string(),
862 video_codec: Some(
875 if video_codecs.contains("hevc") {
876 "h264,hevc"
877 } else {
878 "h264"
879 }
880 .to_string(),
881 ),
882 audio_codec: "aac,mp3".to_string(),
883 max_audio_channels: max_audio_channels.clone(),
884 },
885 TranscodingProfile {
886 profile_type: "Audio".to_string(),
887 context: "Streaming".to_string(),
888 protocol: "http".to_string(),
889 container: "mp3".to_string(),
890 video_codec: None,
891 audio_codec: "mp3".to_string(),
892 max_audio_channels: max_audio_channels.clone(),
893 },
894 ],
895 subtitle_profiles: super::device_profile::subtitle_profiles()
896 .into_iter()
897 .map(|(format, method)| SubtitleProfile {
898 format: format.to_string(),
899 method: method.to_string(),
900 })
901 .collect(),
902 };
903
904 let request_body = PlaybackInfoRequest {
906 user_id: self.user_id.clone(),
907 audio_stream_index: None, subtitle_stream_index: Some(super::device_profile::playback_subtitle_stream_index()),
916 start_time_ticks: 0,
917 is_playback: true,
918 auto_open_live_stream: true,
919 max_streaming_bitrate: quality.max_bitrate().unwrap_or(20_000_000) as i64,
921 device_profile: Some(device_profile), };
923
924 let response: PlaybackInfoResponse =
925 self.post_json_response(&endpoint, &request_body).await?;
926 let source = response
927 .media_sources
928 .into_iter()
929 .next()
930 .ok_or(RepoError::NotFound {
931 message: "No media sources available".to_string(),
932 })?;
933
934 Ok((source, response.play_session_id))
935 }
936
937 pub async fn get_stream_selection(
953 &self,
954 item_id: &str,
955 media_source_id: Option<&str>,
956 audio_stream_index: Option<i32>,
957 ) -> Result<StreamSelection, RepoError> {
958 let (source, play_session_id) = self.negotiate_playback(item_id).await?;
959
960 let quality = effective_streaming_quality();
961 let source_bitrate = source.bitrate.and_then(|b| u64::try_from(b).ok());
962 let available = quality_options_for_source(source_bitrate);
963
964 let audio_streams: Vec<(Option<&str>, bool)> = source
970 .media_streams
971 .iter()
972 .filter(|stream| stream.stream_type == "Audio")
973 .map(|stream| (stream.codec.as_deref(), stream.is_default))
974 .collect();
975 let audio_forces_transcode = super::device_profile::audio_forces_transcode(&audio_streams);
976
977 let track_pinned = audio_stream_index.is_some();
981
982 let effective_source_id = media_source_id.unwrap_or(&source.id).to_string();
983
984 let decided = decide_playback_kind(&source, audio_forces_transcode, track_pinned);
985
986 let selection = match decided {
987 PlaybackKind::Transcode => {
988 let url = if let Some(transcoding_url) = source
989 .transcoding_url
990 .as_deref()
991 .filter(|_| !track_pinned && audio_stream_index.is_none())
992 {
993 if let Some(previous) = adopt_video_play_session(play_session_id.clone()) {
997 self.stop_transcode(&previous).await;
998 }
999 format!(
1003 "{}{}",
1004 self.server_url,
1005 super::device_profile::without_server_chosen_subtitle(transcoding_url)
1006 )
1007 } else {
1008 self.get_video_stream_url(
1012 item_id,
1013 Some(&effective_source_id),
1014 audio_stream_index,
1015 )
1016 .await?
1017 };
1018
1019 StreamSelection {
1020 url,
1021 transport: Transport::Hls,
1026 playback_kind: PlaybackKind::Transcode,
1027 rendition: Some(Rendition {
1028 quality,
1029 max_bitrate: quality.max_bitrate(),
1030 max_height: quality.max_height(),
1031 video_codec: Some("h264".to_string()),
1032 audio_codec: Some("aac".to_string()),
1033 }),
1034 available,
1035 media_source_id: Some(effective_source_id),
1036 play_session_id: Some(play_session_id),
1037 needs_transcoding: PlaybackKind::Transcode.needs_transcoding(),
1038 }
1039 }
1040 kind @ (PlaybackKind::DirectPlay | PlaybackKind::DirectStream) => {
1041 let url = format!(
1046 "{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId={}&api_key={}&userId={}",
1047 self.server_url,
1048 item_id,
1049 effective_source_id,
1050 DEVICE_ID,
1051 self.access_token,
1052 self.user_id
1053 );
1054
1055 StreamSelection {
1056 url,
1057 transport: Transport::Progressive,
1061 playback_kind: kind,
1062 rendition: None,
1066 available,
1067 media_source_id: Some(effective_source_id),
1068 play_session_id: Some(play_session_id),
1069 needs_transcoding: kind.needs_transcoding(),
1070 }
1071 }
1072 };
1073
1074 info!(
1075 "[StreamSelection] {} → {:?} over {:?} (source bitrate {:?}, ceiling {})",
1076 item_id,
1077 selection.playback_kind,
1078 selection.transport,
1079 source_bitrate,
1080 quality.label(),
1081 );
1082
1083 Ok(selection)
1084 }
1085}
1086
1087#[derive(Debug, Deserialize)]
1089#[serde(rename_all = "PascalCase")]
1090struct ItemsResponse {
1091 items: Vec<JellyfinItem>,
1092 total_record_count: usize,
1093}
1094
1095#[derive(Debug, Deserialize)]
1097#[serde(rename_all = "PascalCase")]
1098struct CreatePlaylistResponse {
1099 id: String,
1100}
1101
1102#[derive(Debug, Deserialize)]
1104#[serde(rename_all = "PascalCase")]
1105#[allow(dead_code)]
1106struct PlaylistItemsResponse {
1107 items: Vec<JellyfinPlaylistItem>,
1108 total_record_count: usize,
1109}
1110
1111#[derive(Debug, Deserialize)]
1113#[serde(rename_all = "PascalCase")]
1114struct JellyfinPlaylistItem {
1115 playlist_item_id: String,
1116 #[serde(flatten)]
1117 item: JellyfinItem,
1118}
1119
1120#[derive(Debug, Deserialize)]
1121#[serde(rename_all = "PascalCase")]
1122struct JellyfinItem {
1123 id: String,
1124 name: String,
1125 #[serde(rename = "Type")]
1126 item_type: String,
1127 #[serde(default)]
1128 is_folder: bool,
1129 parent_id: Option<String>,
1130 overview: Option<String>,
1131 genres: Option<Vec<String>>,
1132 production_year: Option<i32>,
1133 premiere_date: Option<String>,
1134 community_rating: Option<f64>,
1135 official_rating: Option<String>,
1136 run_time_ticks: Option<i64>,
1137 image_tags: Option<ImageTags>,
1138 backdrop_image_tags: Option<Vec<String>>,
1139 parent_backdrop_image_tags: Option<Vec<String>>,
1140 album_id: Option<String>,
1141 album: Option<String>,
1142 album_artist: Option<String>,
1143 artists: Option<Vec<String>>,
1144 artist_items: Option<Vec<crate::repository::types::ArtistItem>>,
1145 index_number: Option<i32>,
1146 parent_index_number: Option<i32>,
1147 series_id: Option<String>,
1148 series_name: Option<String>,
1149 season_id: Option<String>,
1150 season_name: Option<String>,
1151 media_streams: Option<Vec<JellyfinMediaStream>>,
1152 media_sources: Option<Vec<JellyfinMediaSource>>,
1153 people: Option<Vec<crate::repository::types::Person>>,
1154 user_data: Option<JellyfinUserData>,
1155}
1156
1157#[derive(Debug, Deserialize, Clone)]
1169#[serde(rename_all = "PascalCase")]
1170struct JellyfinUserData {
1171 playback_position_ticks: Option<i64>,
1172 #[serde(rename = "Played")]
1173 is_played: Option<bool>,
1174 is_favorite: Option<bool>,
1175 play_count: Option<i32>,
1176 last_played_date: Option<String>,
1177}
1178
1179impl From<JellyfinUserData> for UserData {
1180 fn from(jf: JellyfinUserData) -> Self {
1181 UserData {
1182 playback_position_ticks: jf.playback_position_ticks,
1183 playback_position_ms: jf.playback_position_ticks.map(crate::domain::ticks_to_ms),
1184 is_played: jf.is_played,
1185 is_favorite: jf.is_favorite,
1186 play_count: jf.play_count,
1187 last_played_date: jf.last_played_date,
1188 playback_context_type: None,
1189 playback_context_id: None,
1190 }
1191 }
1192}
1193
1194fn build_get_items_endpoint(
1201 user_id: &str,
1202 parent_id: &str,
1203 options: Option<&GetItemsOptions>,
1204) -> String {
1205 let mut endpoint = format!(
1212 "/Users/{}/Items?ParentId={}",
1213 user_id,
1214 urlencoding::encode(parent_id)
1215 );
1216
1217 if let Some(opts) = options {
1218 if let Some(limit) = opts.limit {
1219 endpoint.push_str(&format!("&Limit={}", limit));
1220 }
1221 if let Some(start_index) = opts.start_index {
1222 endpoint.push_str(&format!("&StartIndex={}", start_index));
1223 }
1224 if let Some(types) = &opts.include_item_types {
1225 let encoded: Vec<String> = types
1228 .iter()
1229 .map(|t| urlencoding::encode(t).into_owned())
1230 .collect();
1231 endpoint.push_str(&format!("&IncludeItemTypes={}", encoded.join(",")));
1232 }
1233 let default_sort = default_listing_sort(opts.parent_kind);
1239 let sort_by = opts
1240 .sort_by
1241 .as_deref()
1242 .or(default_sort.map(|(field, _)| field));
1243 let sort_order = opts
1244 .sort_order
1245 .as_deref()
1246 .or(default_sort.map(|(_, order)| order));
1247
1248 if let Some(sort_by) = sort_by {
1249 let encoded: Vec<String> = sort_by
1252 .split(',')
1253 .map(|field| urlencoding::encode(field).into_owned())
1254 .collect();
1255 endpoint.push_str(&format!("&SortBy={}", encoded.join(",")));
1256 }
1257 if let Some(sort_order) = sort_order {
1258 endpoint.push_str(&format!("&SortOrder={}", urlencoding::encode(sort_order)));
1259 }
1260 if let Some(recursive) = opts.recursive {
1261 endpoint.push_str(&format!("&Recursive={}", recursive));
1262 }
1263 if let Some(genres) = &opts.genres {
1264 if !genres.is_empty() {
1265 let encoded: Vec<String> = genres
1267 .iter()
1268 .map(|g| urlencoding::encode(g).into_owned())
1269 .collect();
1270 endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
1271 }
1272 }
1273 if opts.favorites_only == Some(true) {
1275 endpoint.push_str("&Filters=IsFavorite");
1276 }
1277 }
1278
1279 endpoint
1283 .push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
1284 endpoint
1285}
1286
1287fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
1301 format!(
1302 "/Users/{}/Items/Latest?ParentId={}&Limit={}&GroupItems=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1303 user_id,
1304 parent_id,
1305 limit.unwrap_or(16)
1306 )
1307}
1308
1309fn latest_items_fetch_limit(limit: usize) -> usize {
1317 limit.saturating_mul(3)
1318}
1319
1320fn collapse_tracks_into_albums(items: Vec<MediaItem>) -> Vec<MediaItem> {
1337 use std::collections::HashSet;
1338
1339 let server_albums: HashSet<String> = items
1342 .iter()
1343 .filter(|i| i.kind == crate::domain::MediaKind::Album)
1344 .map(|i| i.id.clone())
1345 .collect();
1346
1347 let mut seen_albums: HashSet<String> = HashSet::new();
1348 let mut collapsed = Vec::with_capacity(items.len());
1349
1350 for item in items {
1351 let album_id = match (&item.kind, &item.album_id) {
1352 (crate::domain::MediaKind::Track, Some(id)) => id.clone(),
1353 _ => {
1354 collapsed.push(item);
1355 continue;
1356 }
1357 };
1358
1359 if server_albums.contains(&album_id) || !seen_albums.insert(album_id.clone()) {
1360 continue;
1361 }
1362 collapsed.push(album_from_track(&item, album_id));
1363 }
1364
1365 collapsed
1366}
1367
1368fn album_from_track(track: &MediaItem, album_id: String) -> MediaItem {
1376 MediaItem {
1377 id: album_id,
1378 name: track
1379 .album_name
1380 .clone()
1381 .unwrap_or_else(|| "Unknown Album".to_string()),
1382 item_type: "MusicAlbum".to_string(),
1383 kind: crate::domain::MediaKind::Album,
1384 is_folder: true,
1385 server_id: track.server_id.clone(),
1386 parent_id: None,
1387 library_id: track.library_id.clone(),
1388 overview: None,
1389 genres: track.genres.clone(),
1390 production_year: track.production_year,
1391 premiere_date: track.premiere_date.clone(),
1392 community_rating: None,
1393 official_rating: None,
1394 runtime_ticks: None,
1397 duration_ms: None,
1398 primary_image_tag: track.primary_image_tag.clone(),
1399 image_id: track.image_id.clone(),
1400 backdrop_image_tags: track.backdrop_image_tags.clone(),
1401 parent_backdrop_image_tags: track.parent_backdrop_image_tags.clone(),
1402 album_id: None,
1403 album_name: None,
1404 album_artist: track.album_artist.clone(),
1405 artists: track.artists.clone(),
1406 artist_items: track.artist_items.clone(),
1407 index_number: None,
1408 parent_index_number: None,
1409 series_id: None,
1410 series_name: None,
1411 season_id: None,
1412 season_name: None,
1413 user_data: None,
1414 media_streams: None,
1415 media_sources: None,
1416 people: None,
1417 }
1418}
1419
1420fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
1434 let mut endpoint = format!(
1435 "/Shows/NextUp?UserId={}&Limit={}&EnableResumable=false&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1436 user_id,
1437 limit.unwrap_or(16)
1438 );
1439
1440 if let Some(sid) = series_id {
1441 endpoint.push_str(&format!("&SeriesId={}", sid));
1442 }
1443
1444 endpoint
1445}
1446
1447fn build_favorites_endpoint(
1457 user_id: &str,
1458 scope: SearchScope,
1459 options: Option<&GetItemsOptions>,
1460) -> String {
1461 let mut endpoint = format!("/Users/{}/Items?Filters=IsFavorite&Recursive=true", user_id);
1462
1463 if let Some(types) = scope.item_types() {
1464 endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
1465 }
1466
1467 let sort_by = options
1470 .and_then(|o| o.sort_by.as_deref())
1471 .unwrap_or("SortName");
1472 let sort_order = options
1473 .and_then(|o| o.sort_order.as_deref())
1474 .unwrap_or("Ascending");
1475 endpoint.push_str(&format!("&SortBy={}&SortOrder={}", sort_by, sort_order));
1476
1477 if let Some(limit) = options.and_then(|o| o.limit) {
1478 endpoint.push_str(&format!("&Limit={}", limit));
1479 }
1480 if let Some(start_index) = options.and_then(|o| o.start_index) {
1481 endpoint.push_str(&format!("&StartIndex={}", start_index));
1482 }
1483
1484 endpoint
1485 .push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
1486 endpoint
1487}
1488
1489#[derive(Debug, Deserialize)]
1492#[serde(untagged)]
1493enum ImageTags {
1494 Map(std::collections::HashMap<String, String>),
1496 Structured {
1498 #[serde(rename = "Primary")]
1499 primary: Option<String>,
1500 },
1501}
1502
1503impl ImageTags {
1504 fn primary(&self) -> Option<String> {
1505 match self {
1506 ImageTags::Map(map) => map.get("Primary").cloned(),
1507 ImageTags::Structured { primary } => primary.clone(),
1508 }
1509 }
1510}
1511
1512#[derive(Debug, Deserialize, Clone)]
1513#[serde(rename_all = "PascalCase")]
1514struct JellyfinMediaStream {
1515 #[serde(rename = "Type")]
1516 stream_type: String,
1517 codec: Option<String>,
1518 language: Option<String>,
1519 display_title: Option<String>,
1520 index: i32,
1521 is_default: bool,
1522 #[serde(default)]
1523 is_forced: bool,
1524}
1525
1526#[derive(Debug, Deserialize, Clone)]
1527#[serde(rename_all = "PascalCase")]
1528struct JellyfinMediaSource {
1529 id: String,
1530 name: String,
1531 container: Option<String>,
1532 size: Option<i64>,
1533 bitrate: Option<i32>,
1534 supports_direct_play: bool,
1535 supports_direct_stream: bool,
1536 supports_transcoding: bool,
1537 direct_stream_url: Option<String>,
1538}
1539
1540impl JellyfinItem {
1541 fn into_media_item(self, server_id: String) -> MediaItem {
1542 let primary_tag = self.image_tags.as_ref().and_then(|tags| tags.primary());
1544 let backdrop_tags = self.backdrop_image_tags;
1545
1546 let kind = crate::domain::kind_from_jellyfin(&self.item_type, self.is_folder);
1547
1548 MediaItem {
1549 id: self.id,
1550 name: self.name,
1551 item_type: self.item_type,
1552 kind,
1553 is_folder: self.is_folder,
1554 server_id,
1555 parent_id: self.parent_id,
1556 library_id: None, overview: self.overview,
1558 genres: self.genres,
1559 production_year: self.production_year,
1560 premiere_date: self.premiere_date,
1561 community_rating: self.community_rating,
1562 official_rating: self.official_rating,
1563 runtime_ticks: self.run_time_ticks,
1564 duration_ms: self.run_time_ticks.map(crate::domain::ticks_to_ms),
1565 primary_image_tag: primary_tag.clone(),
1566 image_id: primary_tag,
1567 backdrop_image_tags: backdrop_tags,
1568 parent_backdrop_image_tags: self.parent_backdrop_image_tags,
1569 album_id: self.album_id,
1570 album_name: self.album,
1571 album_artist: self.album_artist,
1572 artists: self.artists,
1573 artist_items: self.artist_items,
1574 index_number: self.index_number,
1575 parent_index_number: self.parent_index_number,
1576 series_id: self.series_id,
1577 series_name: self.series_name,
1578 season_id: self.season_id,
1579 season_name: self.season_name,
1580 user_data: self.user_data.map(UserData::from),
1583 media_streams: self.media_streams.map(|streams| {
1584 streams
1585 .into_iter()
1586 .map(|s| {
1587 let kind = crate::domain::stream_kind_from_jellyfin(&s.stream_type);
1588 let supports_external_delivery =
1592 (kind == crate::domain::StreamKind::Subtitle).then(|| {
1593 super::device_profile::subtitle_supports_external_delivery(
1594 s.codec.as_deref(),
1595 )
1596 });
1597 crate::repository::types::MediaStream {
1598 kind,
1599 stream_type: s.stream_type,
1600 codec: s.codec,
1601 language: s.language,
1602 display_title: s.display_title,
1603 index: s.index,
1604 is_default: s.is_default,
1605 is_forced: s.is_forced,
1606 supports_external_delivery,
1607 }
1608 })
1609 .collect()
1610 }),
1611 media_sources: self.media_sources.map(|sources| {
1612 sources
1613 .into_iter()
1614 .map(|s| crate::repository::types::MediaSource {
1615 id: s.id,
1616 name: s.name,
1617 container: s.container,
1618 size: s.size,
1619 bitrate: s.bitrate,
1620 supports_direct_play: s.supports_direct_play,
1621 supports_direct_stream: s.supports_direct_stream,
1622 supports_transcoding: s.supports_transcoding,
1623 direct_stream_url: s.direct_stream_url,
1624 })
1625 .collect()
1626 }),
1627 people: self.people,
1628 }
1629 }
1630}
1631
1632#[derive(Debug, Serialize)]
1645#[serde(rename_all = "PascalCase")]
1646struct PlaybackInfoRequest {
1647 user_id: String,
1648 #[serde(skip_serializing_if = "Option::is_none")]
1652 audio_stream_index: Option<i32>,
1653 #[serde(skip_serializing_if = "Option::is_none")]
1654 subtitle_stream_index: Option<i32>,
1655 start_time_ticks: i64,
1656 is_playback: bool,
1657 auto_open_live_stream: bool,
1658 max_streaming_bitrate: i64,
1659 #[serde(skip_serializing_if = "Option::is_none")]
1660 device_profile: Option<DeviceProfile>,
1661}
1662
1663#[derive(Debug, Serialize)]
1664#[serde(rename_all = "PascalCase")]
1665struct DeviceProfile {
1666 name: String,
1667 max_streaming_bitrate: i64,
1668 max_static_bitrate: i64,
1669 max_audio_channels: String,
1673 direct_play_profiles: Vec<DirectPlayProfile>,
1674 transcoding_profiles: Vec<TranscodingProfile>,
1675 subtitle_profiles: Vec<SubtitleProfile>,
1676}
1677
1678#[derive(Debug, Serialize)]
1679#[serde(rename_all = "PascalCase")]
1680struct DirectPlayProfile {
1681 #[serde(rename = "Type")]
1682 profile_type: String,
1683 container: String,
1684 #[serde(skip_serializing_if = "Option::is_none")]
1685 video_codec: Option<String>,
1686 audio_codec: String,
1687}
1688
1689#[derive(Debug, Serialize)]
1690#[serde(rename_all = "PascalCase")]
1691struct TranscodingProfile {
1692 #[serde(rename = "Type")]
1693 profile_type: String,
1694 context: String,
1695 protocol: String,
1696 container: String,
1697 #[serde(skip_serializing_if = "Option::is_none")]
1698 video_codec: Option<String>,
1699 audio_codec: String,
1700 max_audio_channels: String,
1701}
1702
1703#[derive(Debug, Serialize)]
1704#[serde(rename_all = "PascalCase")]
1705struct SubtitleProfile {
1706 format: String,
1707 method: String,
1708}
1709
1710#[derive(Debug, Deserialize)]
1711#[serde(rename_all = "PascalCase")]
1712struct PlaybackInfoResponse {
1713 media_sources: Vec<NegotiatedSource>,
1714 play_session_id: String,
1715}
1716
1717#[derive(Debug, Deserialize)]
1718#[serde(rename_all = "PascalCase")]
1719pub struct NegotiatedSource {
1720 pub id: String,
1721 pub supports_direct_play: bool,
1722 #[serde(default)]
1728 pub supports_direct_stream: bool,
1729 pub supports_transcoding: bool,
1730 pub transcoding_url: Option<String>,
1731 #[serde(default)]
1738 pub bitrate: Option<i64>,
1739 #[serde(default)]
1740 pub media_streams: Vec<NegotiatedStream>,
1741}
1742
1743#[derive(Debug, Deserialize)]
1744#[serde(rename_all = "PascalCase")]
1745pub struct NegotiatedStream {
1746 #[serde(rename = "Type")]
1747 stream_type: String,
1748 #[serde(default)]
1749 index: i32,
1750 #[serde(default)]
1751 codec: Option<String>,
1752 #[serde(default)]
1754 is_default: bool,
1755}
1756
1757pub fn decide_playback_kind(
1771 source: &NegotiatedSource,
1772 audio_forces_transcode: bool,
1773 audio_track_pinned: bool,
1774) -> PlaybackKind {
1775 if audio_forces_transcode {
1776 warn!(
1777 "[StreamSelection] Server offered direct play for audio this renderer cannot decode — forcing a transcode"
1778 );
1779 return PlaybackKind::Transcode;
1780 }
1781 if audio_track_pinned {
1782 return PlaybackKind::Transcode;
1785 }
1786 if source.supports_direct_play {
1787 PlaybackKind::DirectPlay
1788 } else if source.supports_direct_stream {
1789 PlaybackKind::DirectStream
1790 } else {
1791 PlaybackKind::Transcode
1792 }
1793}
1794
1795#[async_trait]
1796impl MediaRepository for OnlineRepository {
1797 async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
1798 #[derive(Debug, Deserialize)]
1799 #[serde(rename_all = "PascalCase")]
1800 struct LibrariesResponse {
1801 items: Vec<JellyfinLibrary>,
1802 }
1803
1804 #[derive(Debug, Deserialize)]
1805 #[serde(rename_all = "PascalCase")]
1806 struct JellyfinLibrary {
1807 id: String,
1808 name: String,
1809 collection_type: Option<String>,
1810 image_tags: Option<ImageTags>,
1811 }
1812
1813 let endpoint = format!("/Users/{}/Views", self.user_id);
1814 let response: LibrariesResponse = self.get_json(&endpoint).await?;
1815
1816 Ok(response
1817 .items
1818 .into_iter()
1819 .map(|lib| {
1820 Library::new(
1821 lib.id,
1822 lib.name,
1823 lib.collection_type.unwrap_or_else(|| "unknown".to_string()),
1824 lib.image_tags.and_then(|tags| tags.primary()),
1825 )
1826 })
1827 .collect())
1828 }
1829
1830 async fn get_items(
1831 &self,
1832 parent_id: &str,
1833 options: Option<GetItemsOptions>,
1834 ) -> Result<SearchResult, RepoError> {
1835 let endpoint = build_get_items_endpoint(&self.user_id, parent_id, options.as_ref());
1836
1837 let response: ItemsResponse = self.get_json(&endpoint).await?;
1838
1839 Ok(SearchResult {
1840 items: response
1841 .items
1842 .into_iter()
1843 .map(|item| item.into_media_item(self.user_id.clone()))
1844 .collect(),
1845 total_record_count: response.total_record_count,
1846 })
1847 }
1848
1849 async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
1861 let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, urlencoding::encode(item_id));
1862
1863 let item: JellyfinItem = self.get_json(&endpoint).await?;
1864 let media_item = item.into_media_item(self.user_id.clone());
1865
1866 Ok(media_item)
1867 }
1868
1869 async fn get_latest_items(
1877 &self,
1878 parent_id: &str,
1879 limit: Option<usize>,
1880 ) -> Result<Vec<MediaItem>, RepoError> {
1881 let limit_val = limit.unwrap_or(16);
1882 let endpoint = build_latest_items_endpoint(
1883 &self.user_id,
1884 parent_id,
1885 Some(latest_items_fetch_limit(limit_val)),
1886 );
1887
1888 let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
1889 let items = items
1890 .into_iter()
1891 .map(|item| item.into_media_item(self.user_id.clone()))
1892 .collect();
1893
1894 let mut collapsed = collapse_tracks_into_albums(items);
1895 collapsed.truncate(limit_val);
1896 Ok(collapsed)
1897 }
1898
1899 async fn get_resume_items(
1908 &self,
1909 parent_id: Option<&str>,
1910 limit: Option<usize>,
1911 ) -> Result<Vec<MediaItem>, RepoError> {
1912 let limit_str = limit.unwrap_or(16);
1913 let mut endpoint = format!(
1914 "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1915 self.user_id, limit_str
1916 );
1917
1918 if let Some(pid) = parent_id {
1919 endpoint.push_str(&format!("&ParentId={}", pid));
1920 }
1921
1922 let response: ItemsResponse = self.get_json(&endpoint).await?;
1923 Ok(response
1924 .items
1925 .into_iter()
1926 .map(|item| item.into_media_item(self.user_id.clone()))
1927 .collect())
1928 }
1929
1930 async fn get_next_up_episodes(
1935 &self,
1936 series_id: Option<&str>,
1937 limit: Option<usize>,
1938 ) -> Result<Vec<MediaItem>, RepoError> {
1939 let endpoint = build_next_up_endpoint(&self.user_id, series_id, limit);
1940
1941 let response: ItemsResponse = self.get_json(&endpoint).await?;
1942 Ok(response
1943 .items
1944 .into_iter()
1945 .map(|item| item.into_media_item(self.user_id.clone()))
1946 .collect())
1947 }
1948
1949 async fn get_recently_played_audio(
1950 &self,
1951 limit: Option<usize>,
1952 ) -> Result<Vec<MediaItem>, RepoError> {
1953 let limit_val = limit.unwrap_or(12);
1954 let fetch_limit = limit_val * 3;
1956 let endpoint = format!(
1957 "/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1958 self.user_id, fetch_limit
1959 );
1960
1961 let response: ItemsResponse = self.get_json(&endpoint).await?;
1962 let items: Vec<MediaItem> = response
1963 .items
1964 .into_iter()
1965 .map(|item| item.into_media_item(self.user_id.clone()))
1966 .collect();
1967
1968 debug!("[get_recently_played_audio] Fetched {} items", items.len());
1969 for item in &items {
1970 debug!("[get_recently_played_audio] Item: name={}, type={}, album_id={:?}, album_name={:?}",
1971 item.name, item.item_type, item.album_id, item.album_name);
1972 }
1973
1974 use std::collections::BTreeMap;
1976 let mut album_map: BTreeMap<String, Vec<MediaItem>> = BTreeMap::new();
1977 let mut ungrouped = Vec::new();
1978
1979 for item in items {
1980 let group_key = item.album_id.clone().or_else(|| item.album_name.clone());
1982
1983 if let Some(key) = group_key {
1984 debug!(
1985 "[get_recently_played_audio] Grouping item '{}' into album '{}'",
1986 item.name, key
1987 );
1988 album_map.entry(key).or_default().push(item);
1989 } else {
1990 debug!(
1991 "[get_recently_played_audio] No album_id or album_name for item: '{}'",
1992 item.name
1993 );
1994 ungrouped.push(item);
1995 }
1996 }
1997
1998 let mut result: Vec<MediaItem> = album_map
2000 .into_iter()
2001 .map(|(album_id, tracks)| {
2002 let first_track = &tracks[0];
2003 let most_recent = tracks
2004 .iter()
2005 .max_by(|a, b| {
2006 let date_a = a
2007 .user_data
2008 .as_ref()
2009 .and_then(|ud| ud.last_played_date.as_deref())
2010 .unwrap_or("");
2011 let date_b = b
2012 .user_data
2013 .as_ref()
2014 .and_then(|ud| ud.last_played_date.as_deref())
2015 .unwrap_or("");
2016 date_b.cmp(date_a)
2017 })
2018 .unwrap_or(first_track);
2019
2020 MediaItem {
2021 id: album_id,
2022 name: first_track
2023 .album_name
2024 .clone()
2025 .unwrap_or_else(|| "Unknown Album".to_string()),
2026 item_type: "MusicAlbum".to_string(),
2027 kind: crate::domain::MediaKind::Album,
2028 is_folder: true,
2029 server_id: first_track.server_id.clone(),
2030 parent_id: None,
2031 library_id: None,
2032 overview: None,
2033 genres: None,
2034 production_year: None,
2035 premiere_date: None,
2036 community_rating: None,
2037 official_rating: None,
2038 runtime_ticks: None,
2039 duration_ms: None,
2040 primary_image_tag: first_track.primary_image_tag.clone(),
2041 image_id: first_track.primary_image_tag.clone(),
2042 backdrop_image_tags: None,
2043 parent_backdrop_image_tags: None,
2044 album_id: None,
2045 album_name: None,
2046 album_artist: None,
2047 artists: first_track.artists.clone(),
2048 artist_items: first_track.artist_items.clone(),
2049 index_number: None,
2050 parent_index_number: None,
2051 series_id: None,
2052 series_name: None,
2053 season_id: None,
2054 season_name: None,
2055 user_data: most_recent.user_data.clone(),
2056 media_streams: None,
2057 media_sources: None,
2058 people: None,
2059 }
2060 })
2061 .collect();
2062
2063 result.extend(ungrouped);
2065
2066 let final_result: Vec<MediaItem> = result.into_iter().take(limit_val).collect();
2068 debug!(
2069 "[get_recently_played_audio] Returning {} items after grouping",
2070 final_result.len()
2071 );
2072 for item in &final_result {
2073 debug!(
2074 "[get_recently_played_audio] Return: name={}, type={}",
2075 item.name, item.item_type
2076 );
2077 }
2078 Ok(final_result)
2079 }
2080
2081 async fn get_rediscover_albums(
2082 &self,
2083 parent_id: Option<&str>,
2084 limit: Option<usize>,
2085 ) -> Result<Vec<MediaItem>, RepoError> {
2086 let limit_val = limit.unwrap_or(12);
2087 let mut endpoint = format!(
2091 "/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
2092 self.user_id, limit_val
2093 );
2094
2095 if let Some(pid) = parent_id {
2096 endpoint.push_str(&format!("&ParentId={}", pid));
2097 }
2098
2099 let response: ItemsResponse = self.get_json(&endpoint).await?;
2100 Ok(response
2101 .items
2102 .into_iter()
2103 .map(|item| item.into_media_item(self.user_id.clone()))
2104 .collect())
2105 }
2106
2107 async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
2113 let limit_str = limit.unwrap_or(16);
2114 let endpoint = format!(
2115 "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
2116 self.user_id, limit_str
2117 );
2118
2119 let response: ItemsResponse = self.get_json(&endpoint).await?;
2120 Ok(response
2121 .items
2122 .into_iter()
2123 .map(|item| item.into_media_item(self.user_id.clone()))
2124 .collect())
2125 }
2126
2127 async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
2128 let mut endpoint = format!(
2131 "/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
2132 self.user_id
2133 );
2134
2135 if let Some(pid) = parent_id {
2136 endpoint.push_str(&format!("&ParentId={}", pid));
2137 }
2138
2139 #[derive(Debug, Deserialize)]
2140 #[serde(rename_all = "PascalCase")]
2141 struct GenresResponse {
2142 items: Vec<JellyfinGenre>,
2143 }
2144
2145 #[derive(Debug, Deserialize)]
2146 #[serde(rename_all = "PascalCase")]
2147 struct JellyfinGenre {
2148 id: String,
2149 name: String,
2150 album_count: Option<u32>,
2156 child_count: Option<u32>,
2157 }
2158
2159 let response: GenresResponse = self.get_json(&endpoint).await?;
2160 let genres: Vec<Genre> = response
2161 .items
2162 .into_iter()
2163 .map(|g| Genre {
2164 id: g.id,
2165 name: g.name,
2166 album_count: g.album_count.or(g.child_count),
2167 })
2168 .collect();
2169
2170 let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
2171 log::warn!(
2174 "get_genres: {} genres, {} carry counts. sample: {:?}",
2175 genres.len(),
2176 with_counts,
2177 genres
2178 .iter()
2179 .take(8)
2180 .map(|g| (g.name.as_str(), g.album_count))
2181 .collect::<Vec<_>>()
2182 );
2183
2184 Ok(genres)
2185 }
2186
2187 async fn search(
2196 &self,
2197 query: &str,
2198 options: Option<SearchOptions>,
2199 ) -> Result<SearchResult, RepoError> {
2200 let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50);
2201 let mut endpoint = format!(
2205 "/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
2206 self.user_id,
2207 urlencoding::encode(query),
2208 limit
2209 );
2210
2211 if let Some(opts) = options {
2212 if let Some(types) = opts.include_item_types {
2213 let encoded_types = types
2214 .iter()
2215 .map(|t| urlencoding::encode(t).into_owned())
2216 .collect::<Vec<_>>()
2217 .join(",");
2218 endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
2219 }
2220 }
2221
2222 endpoint.push_str(
2225 "&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData",
2226 );
2227
2228 let response: ItemsResponse = self.get_json(&endpoint).await?;
2229 Ok(SearchResult {
2230 items: response
2231 .items
2232 .into_iter()
2233 .map(|item| item.into_media_item(self.user_id.clone()))
2234 .collect(),
2235 total_record_count: response.total_record_count,
2236 })
2237 }
2238
2239 async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
2240 let (source, play_session_id) = self.negotiate_playback(item_id).await?;
2241
2242 info!(
2244 "PlaybackInfo MediaSource has {} streams",
2245 source.media_streams.len()
2246 );
2247 for stream in &source.media_streams {
2248 info!(
2249 " Stream type={}, index={}, codec={:?}",
2250 stream.stream_type, stream.index, stream.codec
2251 );
2252 }
2253
2254 for stream in &source.media_streams {
2259 if stream.stream_type == "Subtitle" {
2260 if let Some(codec) = stream.codec.as_deref() {
2261 if super::device_profile::subtitle_forces_burn_in(codec) {
2262 info!(
2263 " 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)",
2264 stream.index, codec
2265 );
2266 }
2267 }
2268 }
2269 }
2270
2271 let audio_streams: Vec<(Option<&str>, bool)> = source
2277 .media_streams
2278 .iter()
2279 .filter(|stream| stream.stream_type == "Audio")
2280 .map(|stream| (stream.codec.as_deref(), stream.is_default))
2281 .collect();
2282 let audio_forces_transcode = super::device_profile::audio_forces_transcode(&audio_streams);
2283
2284 let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
2286 if let Some(previous) = adopt_video_play_session(play_session_id.clone()) {
2290 self.stop_transcode(&previous).await;
2291 }
2292 format!(
2298 "{}{}",
2299 self.server_url,
2300 super::device_profile::without_server_chosen_subtitle(transcoding_url)
2301 )
2302 } else if audio_forces_transcode {
2303 warn!(
2304 "[PlaybackInfo] Server offered direct play for audio the webview cannot decode ({:?}) — forcing an HLS transcode",
2305 audio_streams.first().and_then(|(codec, _)| *codec)
2306 );
2307 self.get_video_stream_url(item_id, Some(&source.id), None)
2308 .await?
2309 } else {
2310 format!(
2314 "{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&userId={}",
2315 self.server_url,
2316 item_id,
2317 source.id,
2318 self.access_token,
2319 self.user_id
2320 )
2321 };
2322
2323 info!("Final stream URL: {}", stream_url);
2324
2325 Ok(PlaybackInfo {
2326 media_source_id: source.id.clone(),
2327 play_session_id,
2328 stream_url,
2329 direct_play: source.supports_direct_play && !audio_forces_transcode,
2330 needs_transcoding: audio_forces_transcode
2331 || (!source.supports_direct_play && source.supports_transcoding),
2332 })
2333 }
2334
2335 async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
2336 let url = format!(
2338 "{}/Audio/{}/stream?UserId={}&api_key={}&Static=true",
2339 self.server_url, item_id, self.user_id, self.access_token
2340 );
2341 Ok(url)
2342 }
2343
2344 async fn get_audio_only_stream_url_for_video(
2345 &self,
2346 item_id: &str,
2347 media_source_id: Option<&str>,
2348 start_time_seconds: Option<f64>,
2349 audio_stream_index: Option<i32>,
2350 ) -> Result<String, RepoError> {
2351 self.build_audio_only_stream_url_for_video(
2352 item_id,
2353 media_source_id,
2354 start_time_seconds,
2355 audio_stream_index,
2356 )
2357 .await
2358 }
2359
2360 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
2361 let endpoint = format!(
2364 "/LiveTv/Channels?UserId={}&Fields=PrimaryImageAspectRatio,Overview&EnableImageTypes=Primary",
2365 self.user_id
2366 );
2367 let response: ItemsResponse = self.get_json(&endpoint).await?;
2368 Ok(response
2369 .items
2370 .into_iter()
2371 .map(|item| item.into_media_item(self.server_url.clone()))
2372 .collect())
2373 }
2374
2375 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
2376 let endpoint = format!("/Channels?UserId={}", self.user_id);
2379 let response: ItemsResponse = self.get_json(&endpoint).await?;
2380 let total = response.total_record_count;
2381 let items = response
2382 .items
2383 .into_iter()
2384 .map(|item| item.into_media_item(self.server_url.clone()))
2385 .collect();
2386 Ok(SearchResult {
2387 items,
2388 total_record_count: total,
2389 })
2390 }
2391
2392 async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
2393 #[derive(Debug, Serialize)]
2397 #[serde(rename_all = "PascalCase")]
2398 struct OpenLiveStreamRequest {
2399 user_id: String,
2400 #[serde(rename = "AutoOpenLiveStream")]
2401 auto_open_live_stream: bool,
2402 is_playback: bool,
2403 max_streaming_bitrate: u64,
2404 subtitle_stream_index: i32,
2411 }
2412
2413 #[derive(Debug, Deserialize)]
2414 #[serde(rename_all = "PascalCase")]
2415 struct OpenLiveStreamResponse {
2416 #[serde(default)]
2417 media_sources: Vec<LiveMediaSource>,
2418 play_session_id: Option<String>,
2419 }
2420
2421 #[derive(Debug, Deserialize)]
2422 #[serde(rename_all = "PascalCase")]
2423 struct LiveMediaSource {
2424 id: String,
2425 transcoding_url: Option<String>,
2426 live_stream_id: Option<String>,
2427 }
2428
2429 let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
2430 let request = OpenLiveStreamRequest {
2431 user_id: self.user_id.clone(),
2432 auto_open_live_stream: true,
2433 is_playback: true,
2434 max_streaming_bitrate: effective_streaming_quality()
2438 .max_bitrate()
2439 .unwrap_or(20_000_000),
2440 subtitle_stream_index: super::device_profile::playback_subtitle_stream_index(),
2441 };
2442
2443 let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?;
2444
2445 let source = response
2446 .media_sources
2447 .into_iter()
2448 .next()
2449 .ok_or(RepoError::NotFound {
2450 message: "No live media source returned".to_string(),
2451 })?;
2452
2453 let stream_url = match source.transcoding_url {
2456 Some(url) => format!(
2459 "{}{}",
2460 self.server_url,
2461 super::device_profile::without_server_chosen_subtitle(&url)
2462 ),
2463 None => format!(
2464 "{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts&SubtitleStreamIndex={}",
2465 self.server_url,
2466 item_id,
2467 self.access_token,
2468 source.id,
2469 source.live_stream_id.clone().unwrap_or_default(),
2470 super::device_profile::playback_subtitle_stream_index(),
2471 ),
2472 };
2473
2474 Ok(LiveStreamInfo {
2475 stream_url,
2476 play_session_id: response.play_session_id,
2477 live_stream_id: source.live_stream_id,
2478 media_source_id: Some(source.id),
2479 transport: Transport::Hls,
2482 })
2483 }
2484
2485 async fn report_playback_start(
2486 &self,
2487 item_id: &str,
2488 position_ticks: i64,
2489 ) -> Result<(), RepoError> {
2490 #[derive(Serialize)]
2491 #[serde(rename_all = "PascalCase")]
2492 struct PlaybackStartRequest {
2493 item_id: String,
2494 position_ticks: i64,
2495 play_command: String,
2496 is_paused: bool,
2497 }
2498
2499 let request = PlaybackStartRequest {
2500 item_id: item_id.to_string(),
2501 position_ticks,
2502 play_command: "PlayNow".to_string(),
2503 is_paused: false,
2504 };
2505
2506 self.post_json("/Sessions/Playing", &request).await
2507 }
2508
2509 async fn report_playback_progress(
2510 &self,
2511 item_id: &str,
2512 position_ticks: i64,
2513 ) -> Result<(), RepoError> {
2514 #[derive(Serialize)]
2515 #[serde(rename_all = "PascalCase")]
2516 struct PlaybackProgressRequest {
2517 item_id: String,
2518 position_ticks: i64,
2519 is_paused: bool,
2520 }
2521
2522 let request = PlaybackProgressRequest {
2523 item_id: item_id.to_string(),
2524 position_ticks,
2525 is_paused: false,
2526 };
2527
2528 self.post_json("/Sessions/Playing/Progress", &request).await
2529 }
2530
2531 async fn report_playback_stopped(
2532 &self,
2533 item_id: &str,
2534 position_ticks: i64,
2535 ) -> Result<(), RepoError> {
2536 #[derive(Serialize)]
2537 #[serde(rename_all = "PascalCase")]
2538 struct PlaybackStoppedRequest {
2539 item_id: String,
2540 position_ticks: i64,
2541 }
2542
2543 let request = PlaybackStoppedRequest {
2544 item_id: item_id.to_string(),
2545 position_ticks,
2546 };
2547
2548 self.post_json("/Sessions/Playing/Stopped", &request).await
2549 }
2550
2551 fn get_image_url(
2552 &self,
2553 item_id: &str,
2554 image_type: ImageType,
2555 options: Option<ImageOptions>,
2556 ) -> String {
2557 let mut url = format!(
2558 "{}/Items/{}/Images/{}",
2559 self.server_url,
2560 item_id,
2561 image_type.as_str()
2562 );
2563
2564 let mut params: Vec<String> = Vec::new();
2568
2569 if let Some(opts) = options {
2570 if let Some(width) = opts.max_width {
2571 params.push(format!("maxWidth={}", width));
2572 }
2573 if let Some(height) = opts.max_height {
2574 params.push(format!("maxHeight={}", height));
2575 }
2576 if let Some(quality) = opts.quality {
2577 params.push(format!("quality={}", quality));
2578 }
2579 if let Some(tag) = opts.tag {
2580 params.push(format!("tag={}", tag));
2581 }
2582 }
2583
2584 if !params.is_empty() {
2585 url.push('?');
2586 url.push_str(¶ms.join("&"));
2587 }
2588
2589 url
2590 }
2591
2592 fn get_subtitle_url(
2593 &self,
2594 item_id: &str,
2595 media_source_id: &str,
2596 stream_index: i32,
2597 format: &str,
2598 ) -> String {
2599 format!(
2607 "{}/Videos/{}/{}/Subtitles/{}/Stream.{}",
2608 self.server_url, item_id, media_source_id, stream_index, format
2609 )
2610 }
2611
2612 fn get_video_download_url(
2614 &self,
2615 item_id: &str,
2616 quality: &str,
2617 media_source_id: Option<&str>,
2618 source_audio_codec: Option<&str>,
2619 ) -> String {
2620 let mut url = format!("{}/Videos/{}/stream.mp4", self.server_url, item_id);
2626 let mut params = vec![format!("api_key={}", self.access_token)];
2627
2628 match quality {
2645 "high" => {
2646 params.push("videoBitRate=8000000".to_string());
2647 params.push("maxHeight=1080".to_string());
2648 params.push("audioBitRate=384000".to_string());
2649 params.push("videoCodec=h264".to_string());
2650 params.push("audioCodec=aac".to_string());
2651 params.push("allowVideoStreamCopy=false".to_string());
2652 }
2653 "medium" => {
2654 params.push("videoBitRate=4000000".to_string());
2655 params.push("maxHeight=720".to_string());
2656 params.push("audioBitRate=256000".to_string());
2657 params.push("videoCodec=h264".to_string());
2658 params.push("audioCodec=aac".to_string());
2659 params.push("allowVideoStreamCopy=false".to_string());
2660 }
2661 "low" => {
2662 params.push("videoBitRate=1500000".to_string());
2663 params.push("maxHeight=480".to_string());
2664 params.push("audioBitRate=128000".to_string());
2665 params.push("videoCodec=h264".to_string());
2666 params.push("audioCodec=aac".to_string());
2667 params.push("allowVideoStreamCopy=false".to_string());
2668 }
2669 _ => match source_audio_codec {
2691 Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
2692 params.push("videoCodec=h264".to_string());
2693 params.push("allowVideoStreamCopy=true".to_string());
2694 params.push("audioCodec=aac".to_string());
2695 params.push("audioBitRate=384000".to_string());
2696 }
2697 _ => params.push("Static=true".to_string()),
2701 },
2702 }
2703
2704 if let Some(source_id) = media_source_id {
2706 params.push(format!("mediaSourceId={}", source_id));
2707 }
2708
2709 url.push('?');
2710 url.push_str(¶ms.join("&"));
2711
2712 url
2713 }
2714
2715 async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
2716 let endpoint = format!(
2717 "/Users/{}/FavoriteItems/{}",
2718 self.user_id,
2719 urlencoding::encode(item_id)
2720 );
2721 self.post_json(&endpoint, &serde_json::json!({})).await
2722 }
2723
2724 async fn get_favorites(
2726 &self,
2727 scope: SearchScope,
2728 options: Option<GetItemsOptions>,
2729 ) -> Result<SearchResult, RepoError> {
2730 let endpoint = build_favorites_endpoint(&self.user_id, scope, options.as_ref());
2731 let response: ItemsResponse = self.get_json(&endpoint).await?;
2732
2733 Ok(SearchResult {
2734 items: response
2735 .items
2736 .into_iter()
2737 .map(|item| item.into_media_item(self.user_id.clone()))
2738 .collect(),
2739 total_record_count: response.total_record_count,
2740 })
2741 }
2742
2743 async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
2750 let endpoint = format!(
2751 "/Users/{}/FavoriteItems/{}",
2752 self.user_id,
2753 urlencoding::encode(item_id)
2754 );
2755 let url = format!("{}{}", self.server_url, endpoint);
2756
2757 let result = async {
2758 let request = self
2759 .http_client
2760 .client
2761 .delete(&url)
2762 .header("X-Emby-Authorization", self.auth_header())
2763 .build()
2764 .map_err(|e| RepoError::Network {
2765 message: format!("Failed to build request: {}", e),
2766 })?;
2767
2768 let response = self
2769 .http_client
2770 .request_with_retry(request)
2771 .await
2772 .map_err(|e| RepoError::Network {
2773 message: e.to_string(),
2774 })?;
2775
2776 if !response.status().is_success() {
2777 return Err(RepoError::Server {
2778 message: format!("HTTP {}", response.status()),
2779 });
2780 }
2781
2782 Ok(())
2783 }
2784 .await;
2785
2786 self.report_outcome(&result).await;
2787 result
2788 }
2789
2790 async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
2796 let endpoint = format!(
2797 "/Users/{}/PlayedItems/{}",
2798 self.user_id,
2799 urlencoding::encode(item_id)
2800 );
2801 let url = format!("{}{}", self.server_url, endpoint);
2802
2803 let result = async {
2804 let request = self
2805 .http_client
2806 .client
2807 .delete(&url)
2808 .header("X-Emby-Authorization", self.auth_header())
2809 .build()
2810 .map_err(|e| RepoError::Network {
2811 message: format!("Failed to build request: {}", e),
2812 })?;
2813
2814 let response = self
2815 .http_client
2816 .request_with_retry(request)
2817 .await
2818 .map_err(|e| RepoError::Network {
2819 message: e.to_string(),
2820 })?;
2821
2822 if !response.status().is_success() {
2823 return Err(RepoError::Server {
2824 message: format!("HTTP {}", response.status()),
2825 });
2826 }
2827
2828 Ok(())
2829 }
2830 .await;
2831
2832 self.report_outcome(&result).await;
2833 result
2834 }
2835
2836 async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
2841 let endpoint = format!(
2842 "/Users/{}/PlayedItems/{}",
2843 self.user_id,
2844 urlencoding::encode(item_id)
2845 );
2846 let url = format!("{}{}", self.server_url, endpoint);
2847
2848 let result = async {
2849 let request = self
2850 .http_client
2851 .client
2852 .post(&url)
2853 .header("X-Emby-Authorization", self.auth_header())
2854 .header("Content-Length", "0")
2855 .build()
2856 .map_err(|e| RepoError::Network {
2857 message: format!("Failed to build request: {}", e),
2858 })?;
2859
2860 let response = self
2861 .http_client
2862 .request_with_retry(request)
2863 .await
2864 .map_err(|e| RepoError::Network {
2865 message: e.to_string(),
2866 })?;
2867
2868 if !response.status().is_success() {
2869 return Err(RepoError::Server {
2870 message: format!("HTTP {}", response.status()),
2871 });
2872 }
2873
2874 Ok(())
2875 }
2876 .await;
2877
2878 self.report_outcome(&result).await;
2879 result
2880 }
2881
2882 async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
2890 let endpoint = format!(
2891 "/Users/{}/Items/{}",
2892 self.user_id,
2893 urlencoding::encode(person_id)
2894 );
2895 let item: JellyfinItem = self.get_json(&endpoint).await?;
2896 Ok(item.into_media_item(self.user_id.clone()))
2897 }
2898
2899 async fn get_items_by_person(
2903 &self,
2904 person_id: &str,
2905 options: Option<GetItemsOptions>,
2906 ) -> Result<SearchResult, RepoError> {
2907 let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
2908
2909 let mut endpoint = format!(
2910 "/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
2911 self.user_id, person_id, limit
2912 );
2913
2914 if let Some(ref opts) = options {
2916 if let Some(ref include_types) = opts.include_item_types {
2917 if !include_types.is_empty() {
2918 let types_param = include_types.join(",");
2919 endpoint.push_str(&format!("&IncludeItemTypes={}", types_param));
2920 }
2921 }
2922 }
2923
2924 let response: ItemsResponse = self.get_json(&endpoint).await?;
2925 Ok(SearchResult {
2926 items: response
2927 .items
2928 .into_iter()
2929 .map(|item| item.into_media_item(self.user_id.clone()))
2930 .collect(),
2931 total_record_count: response.total_record_count,
2932 })
2933 }
2934
2935 async fn get_similar_items(
2936 &self,
2937 item_id: &str,
2938 limit: Option<usize>,
2939 ) -> Result<SearchResult, RepoError> {
2940 let limit_str = limit.unwrap_or(20);
2941
2942 let endpoint = format!(
2944 "/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
2945 item_id, self.user_id, limit_str
2946 );
2947
2948 let response: ItemsResponse = self.get_json(&endpoint).await?;
2949 Ok(SearchResult {
2950 items: response
2951 .items
2952 .into_iter()
2953 .map(|item| item.into_media_item(self.user_id.clone()))
2954 .collect(),
2955 total_record_count: response.total_record_count,
2956 })
2957 }
2958
2959 async fn create_playlist(
2962 &self,
2963 name: &str,
2964 item_ids: &[String],
2965 ) -> Result<PlaylistCreatedResult, RepoError> {
2966 info!(
2967 "[OnlineRepo] Creating playlist '{}' with {} items",
2968 name,
2969 item_ids.len()
2970 );
2971 let body = serde_json::json!({
2972 "Name": name,
2973 "Ids": item_ids,
2974 "MediaType": "Audio",
2975 "UserId": self.user_id,
2976 });
2977 let response: CreatePlaylistResponse = self.post_json_response("/Playlists", &body).await?;
2978 Ok(PlaylistCreatedResult { id: response.id })
2979 }
2980
2981 async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
2982 info!("[OnlineRepo] Deleting playlist {}", playlist_id);
2983 let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
2984 let url = format!("{}{}", self.server_url, endpoint);
2985
2986 let request = self
2987 .http_client
2988 .client
2989 .delete(&url)
2990 .header("X-Emby-Authorization", self.auth_header())
2991 .build()
2992 .map_err(|e| RepoError::Network {
2993 message: format!("Failed to build request: {}", e),
2994 })?;
2995
2996 let response = self
2997 .http_client
2998 .request_with_retry(request)
2999 .await
3000 .map_err(|e| RepoError::Network {
3001 message: e.to_string(),
3002 })?;
3003
3004 if !response.status().is_success() {
3005 return Err(RepoError::Server {
3006 message: format!("HTTP {}", response.status()),
3007 });
3008 }
3009
3010 Ok(())
3011 }
3012
3013 async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
3014 info!(
3015 "[OnlineRepo] Renaming playlist {} to '{}'",
3016 playlist_id, name
3017 );
3018 let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
3019 self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
3020 .await
3021 }
3022
3023 async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
3024 let endpoint = format!(
3025 "/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000",
3026 playlist_id, self.user_id
3027 );
3028
3029 let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
3030 debug!(
3031 "[OnlineRepo] Got {} playlist items for {}",
3032 response.items.len(),
3033 playlist_id
3034 );
3035
3036 Ok(response
3037 .items
3038 .into_iter()
3039 .map(|pi| PlaylistEntry {
3040 playlist_item_id: pi.playlist_item_id,
3041 item: pi.item.into_media_item(self.user_id.clone()),
3042 })
3043 .collect())
3044 }
3045
3046 async fn add_to_playlist(
3047 &self,
3048 playlist_id: &str,
3049 item_ids: &[String],
3050 ) -> Result<(), RepoError> {
3051 info!(
3052 "[OnlineRepo] Adding {} items to playlist {}",
3053 item_ids.len(),
3054 playlist_id
3055 );
3056 let ids_param = item_ids
3058 .iter()
3059 .map(|id| urlencoding::encode(id).into_owned())
3060 .collect::<Vec<_>>()
3061 .join(",");
3062 let endpoint = format!(
3063 "/Playlists/{}/Items?Ids={}",
3064 urlencoding::encode(playlist_id),
3065 ids_param
3066 );
3067 self.post_json(&endpoint, &serde_json::json!({})).await
3068 }
3069
3070 async fn remove_from_playlist(
3071 &self,
3072 playlist_id: &str,
3073 entry_ids: &[String],
3074 ) -> Result<(), RepoError> {
3075 info!(
3076 "[OnlineRepo] Removing {} entries from playlist {}",
3077 entry_ids.len(),
3078 playlist_id
3079 );
3080 let ids_param = entry_ids
3081 .iter()
3082 .map(|id| urlencoding::encode(id).into_owned())
3083 .collect::<Vec<_>>()
3084 .join(",");
3085 let endpoint = format!(
3086 "/Playlists/{}/Items?EntryIds={}",
3087 urlencoding::encode(playlist_id),
3088 ids_param
3089 );
3090 let url = format!("{}{}", self.server_url, endpoint);
3091
3092 let request = self
3093 .http_client
3094 .client
3095 .delete(&url)
3096 .header("X-Emby-Authorization", self.auth_header())
3097 .build()
3098 .map_err(|e| RepoError::Network {
3099 message: format!("Failed to build request: {}", e),
3100 })?;
3101
3102 let response = self
3103 .http_client
3104 .request_with_retry(request)
3105 .await
3106 .map_err(|e| RepoError::Network {
3107 message: e.to_string(),
3108 })?;
3109
3110 if !response.status().is_success() {
3111 return Err(RepoError::Server {
3112 message: format!("HTTP {}", response.status()),
3113 });
3114 }
3115
3116 Ok(())
3117 }
3118
3119 async fn move_playlist_item(
3120 &self,
3121 playlist_id: &str,
3122 item_id: &str,
3123 new_index: u32,
3124 ) -> Result<(), RepoError> {
3125 info!(
3126 "[OnlineRepo] Moving item {} in playlist {} to index {}",
3127 item_id, playlist_id, new_index
3128 );
3129 let endpoint = format!(
3130 "/Playlists/{}/Items/{}/Move/{}",
3131 playlist_id, item_id, new_index
3132 );
3133 self.post_json(&endpoint, &serde_json::json!({})).await
3134 }
3135}
3136
3137#[cfg(test)]
3138mod tests {
3139 use super::*;
3140 use crate::domain::MediaKind;
3141 use crate::utils::lock::MutexSafe;
3142 use std::sync::Arc;
3143
3144 fn create_test_repository() -> OnlineRepository {
3145 let http_config = crate::jellyfin::HttpConfig::default();
3146 let http_client =
3147 Arc::new(HttpClient::new(http_config).expect("Failed to create HTTP client for test"));
3148 OnlineRepository::new(
3149 http_client,
3150 "https://test.server.com".to_string(),
3151 "test-user-id".to_string(),
3152 "test-access-token".to_string(),
3153 )
3154 }
3155
3156 #[test]
3176 fn subtitle_url_uses_jellyfins_stream_route() {
3177 let repo = create_test_repository();
3178
3179 assert_eq!(
3180 repo.get_subtitle_url("item123", "source456", 2, "vtt"),
3181 "https://test.server.com/Videos/item123/source456/Subtitles/2/Stream.vtt"
3182 );
3183 }
3184
3185 fn create_test_repository_with_connectivity(
3189 ) -> (OnlineRepository, crate::connectivity::ConnectivityReporter) {
3190 let monitor_http = HttpClient::new(crate::jellyfin::HttpConfig::default())
3191 .expect("Failed to create HTTP client for monitor");
3192 let monitor = crate::connectivity::ConnectivityMonitor::new(monitor_http);
3193 let reporter = monitor.reporter();
3194 let repo = create_test_repository().with_connectivity(reporter.clone());
3195 (repo, reporter)
3196 }
3197
3198 #[tokio::test]
3207 async fn test_report_outcome_classifies_server_answered_as_reachable() {
3208 let (repo, reporter) = create_test_repository_with_connectivity();
3209
3210 for err in [
3212 RepoError::Authentication {
3213 message: "401".into(),
3214 },
3215 RepoError::NotFound {
3216 message: "404".into(),
3217 },
3218 RepoError::Server {
3219 message: "500".into(),
3220 },
3221 ] {
3222 reporter.mark_unreachable_for_test().await;
3223 assert!(!reporter.is_reachable().await, "precondition: offline");
3224
3225 let result: Result<(), RepoError> = Err(err);
3226 repo.report_outcome(&result).await;
3227
3228 assert!(
3229 reporter.is_reachable().await,
3230 "a server that answers should be reported reachable"
3231 );
3232 }
3233
3234 reporter.mark_unreachable_for_test().await;
3236 let ok: Result<(), RepoError> = Ok(());
3237 repo.report_outcome(&ok).await;
3238 assert!(reporter.is_reachable().await, "Ok ⇒ reachable");
3239 }
3240
3241 #[tokio::test]
3244 async fn test_report_outcome_ignores_local_errors() {
3245 let (repo, reporter) = create_test_repository_with_connectivity();
3246
3247 reporter.mark_unreachable_for_test().await;
3250 for err in [
3251 RepoError::Database {
3252 message: "cache".into(),
3253 },
3254 RepoError::Offline,
3255 ] {
3256 let result: Result<(), RepoError> = Err(err);
3257 repo.report_outcome(&result).await;
3258 assert!(
3259 !reporter.is_reachable().await,
3260 "local-side error must not change reachability"
3261 );
3262 }
3263 }
3264
3265 #[tokio::test]
3271 async fn test_get_json_fast_fails_when_offline() {
3272 let (repo, reporter) = create_test_repository_with_connectivity();
3273 reporter.mark_unreachable_for_test().await;
3274 assert!(!reporter.is_reachable().await, "precondition: offline");
3275
3276 let result: Result<serde_json::Value, RepoError> = repo.get_json("/System/Info").await;
3277 assert!(
3278 matches!(result, Err(RepoError::Offline)),
3279 "known-offline get_json should return Offline immediately, got {:?}",
3280 result
3281 );
3282 }
3283
3284 #[tokio::test]
3287 async fn test_report_outcome_network_error_is_debounced() {
3288 let (repo, reporter) = create_test_repository_with_connectivity();
3289 assert!(reporter.is_reachable().await, "starts online");
3290
3291 let result: Result<(), RepoError> = Err(RepoError::Network {
3292 message: "timeout".into(),
3293 });
3294 repo.report_outcome(&result).await;
3295
3296 assert!(
3297 reporter.is_reachable().await,
3298 "a single network failure stays online (debounced)"
3299 );
3300 }
3301
3302 #[tokio::test]
3303 async fn test_get_audio_stream_url_formats_correctly() {
3304 let repo = create_test_repository();
3305 let item_id = "test-track-123";
3306
3307 let result = repo.get_audio_stream_url(item_id).await;
3308
3309 assert!(result.is_ok());
3310 let url = result.unwrap();
3311 assert_eq!(
3312 url,
3313 "https://test.server.com/Audio/test-track-123/stream?UserId=test-user-id&api_key=test-access-token&Static=true"
3314 );
3315 }
3316
3317 static QUALITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3323
3324 struct QualityFixture(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
3325
3326 impl QualityFixture {
3327 fn set(quality: StreamingQuality) -> Self {
3328 let guard = QUALITY_LOCK.lock_safe();
3329 set_streaming_quality(quality);
3330 Self(guard)
3331 }
3332 }
3333
3334 impl Drop for QualityFixture {
3335 fn drop(&mut self) {
3336 set_streaming_quality(StreamingQuality::Original);
3337 clear_playback_quality_override();
3341 }
3342 }
3343
3344 #[tokio::test]
3351 async fn test_video_stream_url_applies_bitrate_cap() {
3352 let _fixture = QualityFixture::set(StreamingQuality::Mbps2);
3353 let repo = create_test_repository();
3354
3355 let url = repo
3356 .get_video_stream_url("vid-1", None, None)
3357 .await
3358 .unwrap();
3359
3360 assert!(url.contains("MaxStreamingBitrate=2000000"), "url: {url}");
3361 assert!(url.contains("VideoBitrate=1808000"), "url: {url}");
3364 assert!(url.contains("AudioBitrate=192000"), "url: {url}");
3365 assert!(url.contains("MaxHeight=720"), "url: {url}");
3366 }
3367
3368 #[tokio::test]
3373 async fn test_video_stream_url_uncapped_keeps_legacy_allowance() {
3374 let _fixture = QualityFixture::set(StreamingQuality::Original);
3375 let repo = create_test_repository();
3376
3377 let url = repo
3378 .get_video_stream_url("vid-1", None, None)
3379 .await
3380 .unwrap();
3381
3382 assert!(url.contains("MaxStreamingBitrate=20000000"), "url: {url}");
3383 assert!(url.contains("VideoBitrate=18000000"), "url: {url}");
3384 assert!(url.contains("AudioBitrate=384000"), "url: {url}");
3385 assert!(
3386 !url.contains("MaxHeight"),
3387 "uncapped must not scale the picture down: {url}"
3388 );
3389 }
3390
3391 #[tokio::test]
3396 async fn test_audio_only_stream_url_takes_the_lower_of_cap_and_default() {
3397 {
3398 let _fixture = QualityFixture::set(StreamingQuality::Kbps720);
3399 let repo = create_test_repository();
3400 let url = repo
3401 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
3402 .await
3403 .unwrap();
3404 assert!(url.contains("MaxStreamingBitrate=96000"), "url: {url}");
3405 }
3406
3407 let _fixture = QualityFixture::set(StreamingQuality::Original);
3408 let repo = create_test_repository();
3409 let url = repo
3410 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
3411 .await
3412 .unwrap();
3413 assert!(url.contains("MaxStreamingBitrate=384000"), "url: {url}");
3414 }
3415
3416 #[tokio::test]
3429 async fn test_get_video_stream_url_returns_an_hls_master_playlist() {
3430 let _fixture = QualityFixture::set(StreamingQuality::Original);
3431 let repo = create_test_repository();
3432
3433 let url = repo
3434 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
3435 .await
3436 .unwrap();
3437
3438 assert!(
3439 url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"),
3440 "expected HLS master playlist, got: {url}"
3441 );
3442 assert!(url.contains("VideoCodec=h264"));
3443 assert!(url.contains("MediaSourceId=source-1"));
3444 assert!(url.contains("AudioStreamIndex=1"));
3445 assert!(!url.contains("stream.mp4"));
3446 }
3447
3448 #[tokio::test]
3473 async fn test_video_stream_url_never_carries_start_time_ticks() {
3474 let _fixture = QualityFixture::set(StreamingQuality::Original);
3475 let repo = create_test_repository();
3476
3477 let url = repo
3478 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
3479 .await
3480 .unwrap();
3481
3482 assert!(
3483 !url.contains("StartTimeTicks"),
3484 "an HLS playlist must never carry StartTimeTicks — the server copies it \
3485 onto every segment URI and then rejects each one with 400: {url}"
3486 );
3487 }
3488
3489 #[tokio::test]
3490 async fn test_get_video_stream_url_omits_position_when_absent() {
3491 let _fixture = QualityFixture::set(StreamingQuality::Original);
3492 let repo = create_test_repository();
3493
3494 let url = repo
3495 .get_video_stream_url("vid-1", None, None)
3496 .await
3497 .unwrap();
3498
3499 assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
3500 assert!(!url.contains("StartTimeTicks"));
3501 assert!(!url.contains("MediaSourceId"));
3502 assert!(
3507 !url.contains("AudioStreamIndex"),
3508 "must not pin an audio index when none was chosen: {url}"
3509 );
3510 }
3511
3512 #[tokio::test]
3523 async fn test_video_stream_url_carries_a_play_session_id() {
3524 let _fixture = QualityFixture::set(StreamingQuality::Original);
3525 let repo = create_test_repository();
3526
3527 let url = repo
3528 .get_video_stream_url("vid-1", None, None)
3529 .await
3530 .unwrap();
3531
3532 assert!(
3533 url.contains("PlaySessionId="),
3534 "every transcode must be openable as its own job: {url}"
3535 );
3536 }
3537
3538 #[tokio::test]
3552 async fn test_video_stream_url_asks_for_no_subtitle_stream() {
3553 let _fixture = QualityFixture::set(StreamingQuality::Original);
3554 let repo = create_test_repository();
3555
3556 let url = repo
3557 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
3558 .await
3559 .unwrap();
3560
3561 assert!(
3562 url.contains("SubtitleStreamIndex=-1"),
3563 "the stream URL must ask for no subtitle, not leave the choice open: {url}"
3564 );
3565 }
3566
3567 #[test]
3575 fn test_media_streams_carry_whether_the_app_can_render_them() {
3576 let item: JellyfinItem = serde_json::from_value(serde_json::json!({
3577 "Id": "ep-1",
3578 "Name": "Partings",
3579 "Type": "Episode",
3580 "MediaStreams": [
3581 { "Type": "Video", "Index": 0, "Codec": "hevc", "IsDefault": true },
3582 { "Type": "Audio", "Index": 1, "Codec": "eac3", "IsDefault": true },
3583 { "Type": "Subtitle", "Index": 2, "Codec": "PGSSUB", "IsDefault": true },
3584 { "Type": "Subtitle", "Index": 3, "Codec": "subrip", "IsDefault": false },
3585 { "Type": "Subtitle", "Index": 4, "Codec": null, "IsDefault": false },
3586 ],
3587 }))
3588 .expect("fixture must deserialize");
3589
3590 let streams = item.into_media_item("server-1".to_string()).media_streams;
3591 let streams = streams.expect("the item carries streams");
3592 let deliverable = |index: i32| {
3593 streams
3594 .iter()
3595 .find(|s| s.index == index)
3596 .unwrap_or_else(|| panic!("stream {index} missing"))
3597 .supports_external_delivery
3598 };
3599
3600 assert_eq!(deliverable(2), Some(false));
3602 assert_eq!(deliverable(3), Some(true));
3604 assert_eq!(deliverable(4), Some(false));
3607 assert_eq!(deliverable(0), None);
3610 assert_eq!(deliverable(1), None);
3611 }
3612
3613 #[test]
3619 fn test_each_stream_open_gets_a_fresh_session_and_reports_the_previous() {
3620 let _lock = QUALITY_LOCK.lock_safe();
3621
3622 let (first, _) = begin_video_play_session();
3623 let (second, replaced) = begin_video_play_session();
3624
3625 assert_ne!(first, second, "each open needs its own job identity");
3626 assert_eq!(
3627 replaced,
3628 Some(first),
3629 "the open must hand back the job it superseded so it can be stopped"
3630 );
3631
3632 let replaced_by_adoption = adopt_video_play_session("server-named-session".to_string());
3636 assert_eq!(replaced_by_adoption, Some(second));
3637
3638 let (_, after_adoption) = begin_video_play_session();
3639 assert_eq!(
3640 after_adoption,
3641 Some("server-named-session".to_string()),
3642 "the adopted job must be the one the next open stops"
3643 );
3644 }
3645
3646 #[tokio::test]
3647 async fn test_get_audio_only_stream_url_for_video_carries_track_and_position() {
3648 let repo = create_test_repository();
3653
3654 let url = repo
3655 .get_audio_only_stream_url_for_video("vid-1", Some("source-1"), Some(193.0), Some(2))
3656 .await
3657 .unwrap();
3658
3659 assert!(
3660 url.starts_with("https://test.server.com/Audio/vid-1/universal?"),
3661 "expected audio-only universal endpoint, got: {url}"
3662 );
3663 assert!(
3665 !url.contains("/Videos/"),
3666 "url must not hit the video endpoint: {url}"
3667 );
3668 assert!(
3669 !url.contains("master.m3u8"),
3670 "url must not be a video HLS playlist: {url}"
3671 );
3672 assert!(url.contains("AudioStreamIndex=2"));
3673 assert!(url.contains("MediaSourceId=source-1"));
3674 assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
3676 assert!(url.contains("TranscodingProtocol=http"), "url: {url}");
3679 assert!(url.contains("TranscodingContainer=mp3"), "url: {url}");
3680 assert!(
3681 !url.contains("TranscodingProtocol=hls"),
3682 "url must not be HLS: {url}"
3683 );
3684 assert!(!url.contains("Container=ts"), "url must not be ts: {url}");
3685 }
3686
3687 #[tokio::test]
3688 async fn test_get_audio_only_stream_url_for_video_omits_position_when_absent() {
3689 let repo = create_test_repository();
3691
3692 let url = repo
3693 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
3694 .await
3695 .unwrap();
3696
3697 assert!(url.starts_with("https://test.server.com/Audio/vid-1/universal?"));
3698 assert!(!url.contains("StartTimeTicks"));
3699 assert!(!url.contains("MediaSourceId"));
3700 assert!(
3703 !url.contains("AudioStreamIndex"),
3704 "must not pin an audio index when none was chosen: {url}"
3705 );
3706 }
3707
3708 #[tokio::test]
3709 async fn test_get_audio_stream_url_with_special_characters() {
3710 let repo = create_test_repository();
3711 let item_id = "track-with-special-chars-!@#";
3712
3713 let result = repo.get_audio_stream_url(item_id).await;
3714
3715 assert!(result.is_ok());
3716 let url = result.unwrap();
3717 assert!(url.contains("track-with-special-chars-!@#"));
3718 assert!(url.starts_with("https://test.server.com/Audio/"));
3719 }
3720
3721 #[test]
3722 fn test_image_tags_deserialize_hashmap_format() {
3723 let json = r#"{"Primary":"abc123","Banner":"def456","Backdrop":"ghi789"}"#;
3725 let result: Result<ImageTags, _> = serde_json::from_str(json);
3726
3727 assert!(result.is_ok());
3728 let tags = result.unwrap();
3729 assert_eq!(tags.primary(), Some("abc123".to_string()));
3730 }
3731
3732 #[test]
3733 fn test_image_tags_deserialize_structured_format() {
3734 let json = r#"{"Primary":"xyz789"}"#;
3736 let result: Result<ImageTags, _> = serde_json::from_str(json);
3737
3738 assert!(result.is_ok());
3739 let tags = result.unwrap();
3740 assert_eq!(tags.primary(), Some("xyz789".to_string()));
3741 }
3742
3743 #[test]
3744 fn test_image_tags_deserialize_missing_primary() {
3745 let json = r#"{"Banner":"def456","Backdrop":"ghi789"}"#;
3747 let result: Result<ImageTags, _> = serde_json::from_str(json);
3748
3749 assert!(result.is_ok());
3750 let tags = result.unwrap();
3751 assert_eq!(tags.primary(), None);
3752 }
3753
3754 #[test]
3755 fn test_image_tags_deserialize_empty_map() {
3756 let json = r#"{}"#;
3758 let result: Result<ImageTags, _> = serde_json::from_str(json);
3759
3760 assert!(result.is_ok());
3761 let tags = result.unwrap();
3762 assert_eq!(tags.primary(), None);
3763 }
3764
3765 #[test]
3776 fn test_video_download_url_uses_stream_not_download_endpoint() {
3777 let repo = create_test_repository();
3778 let url = repo.get_video_download_url("item123", "original", None, None);
3779
3780 assert!(
3782 !url.contains("/download"),
3783 "download URL must not use the broken /Videos/{{id}}/download endpoint: {url}"
3784 );
3785 assert!(
3787 url.contains("/Videos/item123/stream.mp4"),
3788 "download URL must target /Videos/{{id}}/stream.mp4: {url}"
3789 );
3790 assert!(url.contains("api_key=test-access-token"), "url: {url}");
3791 }
3792
3793 #[test]
3794 fn test_video_download_url_original_is_static_direct_copy() {
3795 let repo = create_test_repository();
3796 let url = repo.get_video_download_url("item123", "original", None, None);
3797
3798 assert!(url.contains("Static=true"), "url: {url}");
3801 assert!(
3802 !url.contains("videoBitRate"),
3803 "original must not transcode: {url}"
3804 );
3805 assert!(
3806 !url.contains("maxHeight"),
3807 "original must not transcode: {url}"
3808 );
3809 }
3810
3811 #[test]
3812 fn test_video_download_url_quality_presets_transcode() {
3813 let repo = create_test_repository();
3814
3815 for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
3816 let url = repo.get_video_download_url("item123", quality, None, None);
3817 assert!(
3818 url.contains("/Videos/item123/stream.mp4"),
3819 "{quality} must use stream.mp4: {url}"
3820 );
3821 assert!(
3822 url.contains("videoBitRate="),
3823 "{quality} must set bitrate: {url}"
3824 );
3825 assert!(
3826 url.contains(&format!("maxHeight={height}")),
3827 "{quality} must cap height at {height}: {url}"
3828 );
3829 assert!(url.contains("videoCodec=h264"), "{quality}: {url}");
3830 assert!(
3832 !url.contains("Static=true"),
3833 "{quality} must not be Static: {url}"
3834 );
3835 }
3836 }
3837
3838 #[test]
3845 fn test_video_download_url_bitrate_params_use_capital_r_spelling() {
3846 let repo = create_test_repository();
3847
3848 for quality in ["high", "medium", "low"] {
3849 let url = repo.get_video_download_url("item123", quality, None, None);
3850
3851 assert!(
3852 url.contains("videoBitRate="),
3853 "{quality} must spell it videoBitRate (capital R): {url}"
3854 );
3855 assert!(
3856 url.contains("audioBitRate="),
3857 "{quality} must spell it audioBitRate (capital R): {url}"
3858 );
3859
3860 assert!(
3863 !url.contains("videoBitrate="),
3864 "{quality} emits the unbindable lowercase-r spelling: {url}"
3865 );
3866 assert!(
3867 !url.contains("audioBitrate="),
3868 "{quality} emits the unbindable lowercase-r spelling: {url}"
3869 );
3870 }
3871 }
3872
3873 #[test]
3879 fn test_video_download_url_transcode_presets_forbid_video_stream_copy() {
3880 let repo = create_test_repository();
3881
3882 for quality in ["high", "medium", "low"] {
3883 let url = repo.get_video_download_url("item123", quality, None, None);
3884 assert!(
3885 url.contains("allowVideoStreamCopy=false"),
3886 "{quality} must forbid video stream copy: {url}"
3887 );
3888 }
3889
3890 let original = repo.get_video_download_url("item123", "original", None, None);
3892 assert!(
3893 !original.contains("allowVideoStreamCopy=false"),
3894 "original must remain a direct copy: {original}"
3895 );
3896 }
3897
3898 #[test]
3911 fn test_video_download_url_original_transcodes_undecodable_audio() {
3912 let repo = create_test_repository();
3913
3914 for codec in ["eac3", "ac3", "dts", "truehd", "EAC3"] {
3915 let url = repo.get_video_download_url("item123", "original", None, Some(codec));
3916 assert!(
3917 !url.contains("Static=true"),
3918 "{codec} cannot be decoded here, so the source must not be copied verbatim: {url}"
3919 );
3920 assert!(
3921 url.contains("audioCodec=aac"),
3922 "{codec} must be re-encoded to aac on the way down: {url}"
3923 );
3924 assert!(
3927 url.contains("allowVideoStreamCopy=true"),
3928 "the video stream must still be copied where possible: {url}"
3929 );
3930 assert!(
3931 !url.contains("videoBitRate") && !url.contains("maxHeight"),
3932 "original must not degrade the picture to fix the audio: {url}"
3933 );
3934 }
3935 }
3936
3937 #[test]
3943 fn test_video_download_url_original_keeps_static_copy_for_playable_audio() {
3944 let repo = create_test_repository();
3945
3946 for codec in ["aac", "mp3", "opus", "vorbis", "flac", "AAC"] {
3947 let url = repo.get_video_download_url("item123", "original", None, Some(codec));
3948 assert!(
3949 url.contains("Static=true"),
3950 "{codec} plays here — the download must stay a direct copy: {url}"
3951 );
3952 assert!(
3953 !url.contains("audioCodec="),
3954 "{codec} needs no transcode: {url}"
3955 );
3956 }
3957
3958 let unknown = repo.get_video_download_url("item123", "original", None, None);
3961 assert!(unknown.contains("Static=true"), "url: {unknown}");
3962 }
3963
3964 #[test]
3969 fn test_video_download_url_presets_ignore_the_audio_policy() {
3970 let repo = create_test_repository();
3971
3972 for quality in ["high", "medium", "low"] {
3973 let with = repo.get_video_download_url("item123", quality, None, Some("eac3"));
3974 let without = repo.get_video_download_url("item123", quality, None, None);
3975 assert_eq!(with, without, "{quality} must not vary with source audio");
3976 assert!(with.contains("audioCodec=aac"), "url: {with}");
3977 }
3978 }
3979
3980 #[test]
3981 fn test_video_download_url_passes_media_source_id() {
3982 let repo = create_test_repository();
3983 let url = repo.get_video_download_url("item123", "original", Some("src-42"), None);
3984 assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
3985 }
3986
3987 #[test]
3988 fn test_jellyfin_item_deserialize_with_image_tags() {
3989 let json = r#"{
3991 "Id": "album123",
3992 "Name": "Test Album",
3993 "Type": "MusicAlbum",
3994 "ImageTags": {"Primary": "tag123"},
3995 "ArtistItems": [
3996 {"Id": "artist1", "Name": "Artist One"},
3997 {"Id": "artist2", "Name": "Artist Two"}
3998 ]
3999 }"#;
4000
4001 let result: Result<JellyfinItem, _> = serde_json::from_str(json);
4002 assert!(result.is_ok());
4003
4004 let item = result.unwrap();
4005 assert_eq!(item.id, "album123");
4006 assert_eq!(item.name, "Test Album");
4007 assert_eq!(item.item_type, "MusicAlbum");
4008 assert!(item.image_tags.is_some());
4009 assert_eq!(
4010 item.image_tags.unwrap().primary(),
4011 Some("tag123".to_string())
4012 );
4013 }
4014
4015 #[test]
4019 fn test_build_favorites_endpoint_scopes_and_filters() {
4020 let movies = build_favorites_endpoint("u1", SearchScope::Movies, None);
4021 assert!(movies.starts_with("/Users/u1/Items?Filters=IsFavorite&Recursive=true"));
4022 assert!(movies.contains("&IncludeItemTypes=Movie"));
4023 assert!(movies.contains("&SortBy=SortName&SortOrder=Ascending"));
4025 assert!(movies.contains("UserData"));
4027
4028 let tv = build_favorites_endpoint("u1", SearchScope::Tv, None);
4030 assert!(tv.contains("&IncludeItemTypes=Series,Episode"));
4031
4032 let music = build_favorites_endpoint("u1", SearchScope::Music, None);
4033 assert!(music.contains("&IncludeItemTypes=MusicAlbum,MusicArtist,Audio,Playlist"));
4034 }
4035
4036 #[test]
4041 fn test_build_favorites_endpoint_all_scope_omits_type_filter() {
4042 let all = build_favorites_endpoint("u1", SearchScope::All, None);
4043 assert!(!all.contains("IncludeItemTypes"));
4044 }
4045
4046 #[test]
4050 fn test_build_favorites_endpoint_honours_paging_and_sort() {
4051 let endpoint = build_favorites_endpoint(
4052 "u1",
4053 SearchScope::All,
4054 Some(&GetItemsOptions {
4055 limit: Some(20),
4056 start_index: Some(40),
4057 sort_by: Some("Random".to_string()),
4058 sort_order: Some("Descending".to_string()),
4059 ..Default::default()
4060 }),
4061 );
4062 assert!(endpoint.contains("&Limit=20"));
4063 assert!(endpoint.contains("&StartIndex=40"));
4064 assert!(endpoint.contains("&SortBy=Random&SortOrder=Descending"));
4065 }
4066
4067 #[test]
4072 fn test_get_items_endpoint_applies_favorites_only() {
4073 let plain = build_get_items_endpoint("u1", "lib-1", None);
4074 assert!(!plain.contains("Filters=IsFavorite"));
4075
4076 let filtered = build_get_items_endpoint(
4077 "u1",
4078 "lib-1",
4079 Some(&GetItemsOptions {
4080 favorites_only: Some(true),
4081 include_item_types: Some(vec!["Movie".to_string()]),
4082 ..Default::default()
4083 }),
4084 );
4085 assert!(filtered.contains("&Filters=IsFavorite"));
4086 assert!(filtered.contains("&IncludeItemTypes=Movie"));
4088 assert!(filtered.contains("ParentId=lib-1"));
4089
4090 let off = build_get_items_endpoint(
4092 "u1",
4093 "lib-1",
4094 Some(&GetItemsOptions {
4095 favorites_only: Some(false),
4096 ..Default::default()
4097 }),
4098 );
4099 assert!(!off.contains("Filters=IsFavorite"));
4100 }
4101
4102 #[test]
4111 fn test_get_items_endpoint_encodes_query_values() {
4112 let endpoint = build_get_items_endpoint(
4113 "u1",
4114 "lib 1&Filters=IsFavorite",
4115 Some(&GetItemsOptions {
4116 include_item_types: Some(vec!["Movie&x=1".to_string()]),
4117 sort_by: Some("Sort Name".to_string()),
4118 sort_order: Some("Ascending&y=2".to_string()),
4119 ..Default::default()
4120 }),
4121 );
4122 assert!(
4123 endpoint.contains("ParentId=lib%201%26Filters%3DIsFavorite"),
4124 "{endpoint}"
4125 );
4126 assert!(
4127 endpoint.contains("&IncludeItemTypes=Movie%26x%3D1"),
4128 "{endpoint}"
4129 );
4130 assert!(endpoint.contains("&SortBy=Sort%20Name"), "{endpoint}");
4131 assert!(
4132 endpoint.contains("&SortOrder=Ascending%26y%3D2"),
4133 "{endpoint}"
4134 );
4135 assert!(!endpoint.contains("&Filters=IsFavorite"), "{endpoint}");
4137 assert!(!endpoint.contains("&x=1"), "{endpoint}");
4138 assert!(!endpoint.contains("&y=2"), "{endpoint}");
4139 }
4140
4141 #[test]
4147 fn test_get_items_endpoint_keeps_list_separators() {
4148 let endpoint = build_get_items_endpoint(
4149 "u1",
4150 "lib-1",
4151 Some(&GetItemsOptions {
4152 sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
4153 include_item_types: Some(vec!["Movie".to_string(), "Series".to_string()]),
4154 ..Default::default()
4155 }),
4156 );
4157 assert!(
4158 endpoint.contains("&SortBy=ParentIndexNumber,IndexNumber,SortName"),
4159 "{endpoint}"
4160 );
4161 assert!(
4162 endpoint.contains("&IncludeItemTypes=Movie,Series"),
4163 "{endpoint}"
4164 );
4165 assert!(endpoint.contains("ParentId=lib-1"), "{endpoint}");
4167 }
4168
4169 #[test]
4182 fn test_get_items_endpoint_orders_channel_folders_by_release_date() {
4183 let podcast = build_get_items_endpoint(
4184 "u1",
4185 "podcast-1",
4186 Some(&GetItemsOptions {
4187 parent_kind: Some(MediaKind::ChannelFolder),
4188 ..Default::default()
4189 }),
4190 );
4191 assert!(
4192 podcast.contains("&SortBy=PremiereDate&SortOrder=Descending"),
4193 "{podcast}"
4194 );
4195
4196 let season = build_get_items_endpoint(
4198 "u1",
4199 "season-1",
4200 Some(&GetItemsOptions {
4201 parent_kind: Some(MediaKind::Season),
4202 ..Default::default()
4203 }),
4204 );
4205 assert!(
4206 season.contains("&SortBy=SortName&SortOrder=Ascending"),
4207 "{season}"
4208 );
4209
4210 let explicit = build_get_items_endpoint(
4212 "u1",
4213 "podcast-1",
4214 Some(&GetItemsOptions {
4215 parent_kind: Some(MediaKind::ChannelFolder),
4216 sort_by: Some("SortName".to_string()),
4217 sort_order: Some("Ascending".to_string()),
4218 ..Default::default()
4219 }),
4220 );
4221 assert!(
4222 explicit.contains("&SortBy=SortName&SortOrder=Ascending"),
4223 "{explicit}"
4224 );
4225 assert!(!explicit.contains("SortBy=PremiereDate"), "{explicit}");
4226
4227 let unspecified = build_get_items_endpoint("u1", "lib-1", None);
4230 assert!(!unspecified.contains("SortBy="), "{unspecified}");
4231 }
4232
4233 #[test]
4240 fn test_latest_items_endpoint_groups_children_into_containers() {
4241 let endpoint = build_latest_items_endpoint("u1", "lib-1", Some(16));
4242
4243 assert!(
4244 endpoint.contains("GroupItems=true"),
4245 "latest items must be grouped so an album counts once, got: {}",
4246 endpoint
4247 );
4248 assert!(endpoint.contains("ParentId=lib-1"));
4249 assert!(endpoint.contains("Limit=16"));
4250 }
4251
4252 fn item_from_json(json: &str) -> MediaItem {
4255 let parsed: JellyfinItem = serde_json::from_str(json).expect("fixture must parse");
4256 parsed.into_media_item("srv".to_string())
4257 }
4258
4259 fn track(id: &str, name: &str, album_id: Option<&str>) -> MediaItem {
4260 let album = match album_id {
4261 Some(a) => format!(r#""AlbumId": "{a}", "Album": "Kind of Blue","#),
4262 None => String::new(),
4263 };
4264 item_from_json(&format!(
4265 r#"{{
4266 "Id": "{id}",
4267 "Name": "{name}",
4268 "Type": "Audio",
4269 {album}
4270 "ImageTags": {{"Primary": "art-{id}"}},
4271 "AlbumArtist": "Miles Davis",
4272 "Artists": ["Miles Davis"],
4273 "IndexNumber": 1,
4274 "RunTimeTicks": 1000
4275 }}"#
4276 ))
4277 }
4278
4279 #[test]
4286 fn test_collapse_tracks_into_albums_shows_one_card_per_album() {
4287 let movie = item_from_json(
4288 r#"{"Id": "mov-1", "Name": "Heat", "Type": "Movie", "ImageTags": {"Primary": "art-mov"}}"#,
4289 );
4290 let items = vec![
4291 track("trk-1", "So What", Some("alb-1")),
4292 track("trk-2", "Blue in Green", Some("alb-1")),
4293 movie,
4294 track("trk-3", "Flamenco Sketches", Some("alb-1")),
4295 ];
4296
4297 let collapsed = collapse_tracks_into_albums(items);
4298
4299 assert_eq!(
4300 collapsed.len(),
4301 2,
4302 "three tracks of one album plus a movie must read as two cards, got: {:?}",
4303 collapsed.iter().map(|i| &i.name).collect::<Vec<_>>()
4304 );
4305
4306 let album = &collapsed[0];
4307 assert_eq!(album.id, "alb-1", "the card must open the album");
4308 assert_eq!(album.name, "Kind of Blue");
4309 assert_eq!(album.item_type, "MusicAlbum");
4310 assert_eq!(album.kind, crate::domain::MediaKind::Album);
4311 assert!(album.is_folder);
4312 assert_eq!(album.album_artist.as_deref(), Some("Miles Davis"));
4313 assert!(album.image_id.is_some(), "album card needs artwork");
4314 assert!(album.index_number.is_none());
4316 assert!(album.album_id.is_none());
4317 assert!(album.runtime_ticks.is_none());
4318
4319 assert_eq!(collapsed[1].id, "mov-1");
4321 }
4322
4323 #[test]
4328 fn test_collapse_prefers_the_album_row_the_server_returned() {
4329 let album = item_from_json(
4330 r#"{"Id": "alb-1", "Name": "Kind of Blue", "Type": "MusicAlbum", "IsFolder": true,
4331 "Overview": "1959", "ImageTags": {"Primary": "art-alb"}}"#,
4332 );
4333 let items = vec![
4334 album,
4335 track("trk-1", "So What", Some("alb-1")),
4336 track("trk-2", "Blue in Green", Some("alb-1")),
4337 ];
4338
4339 let collapsed = collapse_tracks_into_albums(items);
4340
4341 assert_eq!(collapsed.len(), 1, "one album, one card");
4342 assert_eq!(collapsed[0].id, "alb-1");
4343 assert_eq!(
4344 collapsed[0].overview.as_deref(),
4345 Some("1959"),
4346 "the server's own album row must survive, not a track-built stand-in"
4347 );
4348 }
4349
4350 #[test]
4355 fn test_collapse_leaves_a_standalone_track_alone() {
4356 let items = vec![track("trk-1", "Field Recording", None)];
4357
4358 let collapsed = collapse_tracks_into_albums(items);
4359
4360 assert_eq!(collapsed.len(), 1);
4361 assert_eq!(collapsed[0].id, "trk-1");
4362 assert_eq!(collapsed[0].item_type, "Audio");
4363 }
4364
4365 #[test]
4371 fn test_latest_items_over_fetches_before_collapsing() {
4372 assert!(
4373 latest_items_fetch_limit(16) > 16,
4374 "must ask for more rows than the row shows"
4375 );
4376 let endpoint =
4377 build_latest_items_endpoint("u1", "lib-1", Some(latest_items_fetch_limit(16)));
4378 assert!(endpoint.contains(&format!("Limit={}", latest_items_fetch_limit(16))));
4379 }
4380
4381 #[test]
4390 fn test_build_next_up_endpoint_excludes_resumable() {
4391 let endpoint = build_next_up_endpoint("u1", None, Some(12));
4392
4393 assert!(
4394 endpoint.contains("EnableResumable=false"),
4395 "next up must exclude in-progress episodes, got: {}",
4396 endpoint
4397 );
4398 assert!(endpoint.contains("UserId=u1"));
4399 assert!(endpoint.contains("Limit=12"));
4400 assert!(
4401 !endpoint.contains("SeriesId"),
4402 "no series filter when none was requested, got: {}",
4403 endpoint
4404 );
4405 }
4406
4407 #[test]
4411 fn test_build_next_up_endpoint_scopes_to_series() {
4412 let endpoint = build_next_up_endpoint("u1", Some("series-a"), None);
4413
4414 assert!(endpoint.contains("SeriesId=series-a"));
4415 assert!(endpoint.contains("EnableResumable=false"));
4416 assert!(
4417 endpoint.contains("Limit=16"),
4418 "default limit, got: {}",
4419 endpoint
4420 );
4421 }
4422
4423 #[test]
4430 fn test_jellyfin_item_maps_user_data_favorite() {
4431 let json = r#"{
4432 "Id": "movie123",
4433 "Name": "Test Movie",
4434 "Type": "Movie",
4435 "UserData": {
4436 "PlaybackPositionTicks": 6000000000,
4437 "Played": false,
4438 "IsFavorite": true,
4439 "PlayCount": 2,
4440 "LastPlayedDate": "2026-08-01T12:00:00Z"
4441 }
4442 }"#;
4443
4444 let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
4445 let media = item.into_media_item("server1".to_string());
4446
4447 let user_data = media.user_data.expect("user data should be mapped");
4448 assert_eq!(user_data.is_favorite, Some(true));
4449 assert_eq!(user_data.is_played, Some(false));
4450 assert_eq!(user_data.play_count, Some(2));
4451 assert_eq!(user_data.playback_position_ticks, Some(6_000_000_000));
4452 assert_eq!(user_data.playback_position_ms, Some(600_000));
4454 }
4455
4456 #[test]
4461 fn test_jellyfin_item_without_user_data_maps_to_none() {
4462 let json = r#"{"Id": "x", "Name": "No User Data", "Type": "Movie"}"#;
4463
4464 let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
4465 let media = item.into_media_item("server1".to_string());
4466
4467 assert!(media.user_data.is_none());
4468 }
4469
4470 #[test]
4471 fn test_jellyfin_item_deserialize_with_artist_items() {
4472 let json = r#"{
4474 "Id": "track123",
4475 "Name": "Test Track",
4476 "Type": "Audio",
4477 "ArtistItems": [
4478 {"Id": "artist1", "Name": "Bob Dylan"},
4479 {"Id": "artist2", "Name": "Johnny Cash"}
4480 ]
4481 }"#;
4482
4483 let result: Result<JellyfinItem, _> = serde_json::from_str(json);
4484 assert!(result.is_ok());
4485
4486 let item = result.unwrap();
4487 let artist_items = item.artist_items.expect("Expected artist items");
4488 assert_eq!(artist_items.len(), 2);
4489 assert_eq!(artist_items[0].id, "artist1");
4490 assert_eq!(artist_items[0].name, "Bob Dylan");
4491 assert_eq!(artist_items[1].id, "artist2");
4492 assert_eq!(artist_items[1].name, "Johnny Cash");
4493 }
4494
4495 #[test]
4496 fn test_jellyfin_item_to_media_item_conversion() {
4497 let json = r#"{
4499 "Id": "album456",
4500 "Name": "Love and Theft",
4501 "Type": "MusicAlbum",
4502 "ImageTags": {"Primary": "7ebab4f6a80cd09d"},
4503 "Artists": ["Bob Dylan"],
4504 "ArtistItems": [{"Id": "0b2a6e969a27f22aba97f9f0e69fa849", "Name": "Bob Dylan"}],
4505 "RunTimeTicks": 33900137190
4506 }"#;
4507
4508 let jellyfin_item: JellyfinItem = serde_json::from_str(json).expect("Failed to parse");
4509 let media_item = jellyfin_item.into_media_item("test-server-id".to_string());
4510
4511 assert_eq!(media_item.id, "album456");
4512 assert_eq!(media_item.name, "Love and Theft");
4513 assert_eq!(media_item.item_type, "MusicAlbum");
4514 assert_eq!(
4515 media_item.primary_image_tag,
4516 Some("7ebab4f6a80cd09d".to_string())
4517 );
4518 assert_eq!(media_item.server_id, "test-server-id");
4519 }
4520
4521 #[test]
4522 fn test_items_response_deserialize() {
4523 let json = r#"{
4525 "Items": [
4526 {
4527 "Id": "item1",
4528 "Name": "Item One",
4529 "Type": "MusicAlbum",
4530 "ImageTags": {"Primary": "tag1"}
4531 },
4532 {
4533 "Id": "item2",
4534 "Name": "Item Two",
4535 "Type": "Audio",
4536 "ImageTags": {"Primary": "tag2"}
4537 }
4538 ],
4539 "TotalRecordCount": 2
4540 }"#;
4541
4542 let result: Result<ItemsResponse, _> = serde_json::from_str(json);
4543 assert!(result.is_ok());
4544
4545 let response = result.unwrap();
4546 assert_eq!(response.total_record_count, 2);
4547 assert_eq!(response.items.len(), 2);
4548 assert_eq!(response.items[0].id, "item1");
4549 assert_eq!(response.items[1].id, "item2");
4550 }
4551
4552 #[test]
4553 fn test_search_term_is_url_encoded() {
4554 assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
4558 assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
4559 }
4560
4561 #[test]
4562 fn test_jray_context_deserializes_actors() {
4563 let json = r#"{
4565 "actors": [
4566 { "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
4567 ]
4568 }"#;
4569 let ctx: JRayContext = serde_json::from_str(json).expect("should parse");
4570 assert_eq!(ctx.actors.len(), 1);
4571 assert_eq!(ctx.actors[0].name, "Tom Hanks");
4572 assert_eq!(ctx.actors[0].jellyfin_id, "abc123-guid");
4573 }
4574
4575 #[test]
4576 fn test_jray_context_ignores_unknown_keys_and_missing_ids() {
4577 let json = r#"{
4580 "actors": [ { "name": "Extra" } ],
4581 "locations": ["Beach"],
4582 "trivia": "filmed in 1994"
4583 }"#;
4584 let ctx: JRayContext = serde_json::from_str(json).expect("should tolerate extra keys");
4585 assert_eq!(ctx.actors.len(), 1);
4586 assert_eq!(ctx.actors[0].name, "Extra");
4587 assert_eq!(ctx.actors[0].imdb_id, "");
4588 assert_eq!(ctx.actors[0].jellyfin_id, "");
4589 }
4590
4591 fn source_fixture() -> NegotiatedSource {
4606 NegotiatedSource {
4607 id: "source-1".to_string(),
4608 supports_direct_play: true,
4609 supports_direct_stream: true,
4610 supports_transcoding: true,
4611 transcoding_url: None,
4612 bitrate: Some(6_652_961),
4613 media_streams: Vec::new(),
4614 }
4615 }
4616
4617 #[test]
4623 fn test_a_supported_source_direct_plays() {
4624 let source = source_fixture();
4625 assert_eq!(
4626 decide_playback_kind(&source, false, false),
4627 PlaybackKind::DirectPlay
4628 );
4629 }
4630
4631 #[test]
4636 fn test_a_remuxable_source_direct_streams() {
4637 let source = NegotiatedSource {
4638 supports_direct_play: false,
4639 supports_direct_stream: true,
4640 ..source_fixture()
4641 };
4642 let kind = decide_playback_kind(&source, false, false);
4643 assert_eq!(kind, PlaybackKind::DirectStream);
4644 assert!(
4645 !kind.needs_transcoding(),
4646 "a remux costs no encoder time and must not be reported as transcoding"
4647 );
4648 }
4649
4650 #[test]
4655 fn test_an_unsupported_source_transcodes() {
4656 let source = NegotiatedSource {
4657 supports_direct_play: false,
4658 supports_direct_stream: false,
4659 ..source_fixture()
4660 };
4661 assert_eq!(
4662 decide_playback_kind(&source, false, false),
4663 PlaybackKind::Transcode
4664 );
4665 }
4666
4667 #[test]
4674 fn test_undecodable_audio_overrides_the_servers_direct_play_offer() {
4675 let source = source_fixture();
4676 assert!(source.supports_direct_play, "the server said yes");
4677 assert_eq!(
4678 decide_playback_kind(&source, true, false),
4679 PlaybackKind::Transcode,
4680 "silent direct play is worse than a transcode"
4681 );
4682 }
4683
4684 #[test]
4690 fn test_pinning_an_audio_track_forces_a_transcode() {
4691 let source = source_fixture();
4692 assert_eq!(
4693 decide_playback_kind(&source, false, true),
4694 PlaybackKind::Transcode
4695 );
4696 }
4697
4698 #[test]
4706 fn test_a_ceiling_below_the_source_bitrate_transcodes() {
4707 let source = NegotiatedSource {
4709 supports_direct_play: false,
4710 supports_direct_stream: false,
4711 bitrate: Some(6_652_961),
4712 ..source_fixture()
4713 };
4714 assert_eq!(
4715 decide_playback_kind(&source, false, false),
4716 PlaybackKind::Transcode
4717 );
4718
4719 let options =
4721 crate::repository::stream_selection::quality_options_for_source(Some(6_652_961));
4722 let two_mbps = options
4723 .iter()
4724 .find(|o| o.quality == StreamingQuality::Mbps2)
4725 .expect("2 Mbps is on the ladder");
4726 assert!(!two_mbps.exceeds_source);
4727 }
4728
4729 #[test]
4734 fn test_direct_play_is_preferred_over_direct_stream() {
4735 let source = source_fixture();
4736 assert!(source.supports_direct_play && source.supports_direct_stream);
4737 assert_eq!(
4738 decide_playback_kind(&source, false, false),
4739 PlaybackKind::DirectPlay
4740 );
4741 }
4742
4743 #[test]
4754 fn test_a_playback_override_does_not_disturb_the_device_default() {
4755 let _guard = QUALITY_LOCK.lock_safe();
4756 set_streaming_quality(StreamingQuality::Mbps10);
4757 clear_playback_quality_override();
4758 assert_eq!(effective_streaming_quality(), StreamingQuality::Mbps10);
4759
4760 set_playback_quality_override(StreamingQuality::Kbps720);
4761 assert_eq!(
4762 effective_streaming_quality(),
4763 StreamingQuality::Kbps720,
4764 "the override governs the stream being opened now"
4765 );
4766 assert_eq!(
4767 streaming_quality(),
4768 StreamingQuality::Mbps10,
4769 "but the durable default the Settings screen shows is untouched"
4770 );
4771
4772 clear_playback_quality_override();
4773 assert_eq!(
4774 effective_streaming_quality(),
4775 StreamingQuality::Mbps10,
4776 "and dropping the override returns to it"
4777 );
4778 set_streaming_quality(StreamingQuality::Original);
4779 }
4780
4781 #[test]
4787 fn test_the_override_is_droppable_so_it_cannot_outlive_its_playback() {
4788 let _guard = QUALITY_LOCK.lock_safe();
4789 set_streaming_quality(StreamingQuality::Original);
4790 set_playback_quality_override(StreamingQuality::Mbps1);
4791 assert_eq!(playback_quality_override(), Some(StreamingQuality::Mbps1));
4792
4793 clear_playback_quality_override();
4794 assert_eq!(playback_quality_override(), None);
4795 assert_eq!(effective_streaming_quality(), StreamingQuality::Original);
4796 }
4797}