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 if let Some(sort_by) = &opts.sort_by {
1234 let encoded: Vec<String> = sort_by
1237 .split(',')
1238 .map(|field| urlencoding::encode(field).into_owned())
1239 .collect();
1240 endpoint.push_str(&format!("&SortBy={}", encoded.join(",")));
1241 }
1242 if let Some(sort_order) = &opts.sort_order {
1243 endpoint.push_str(&format!("&SortOrder={}", urlencoding::encode(sort_order)));
1244 }
1245 if let Some(recursive) = opts.recursive {
1246 endpoint.push_str(&format!("&Recursive={}", recursive));
1247 }
1248 if let Some(genres) = &opts.genres {
1249 if !genres.is_empty() {
1250 let encoded: Vec<String> = genres
1252 .iter()
1253 .map(|g| urlencoding::encode(g).into_owned())
1254 .collect();
1255 endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
1256 }
1257 }
1258 if opts.favorites_only == Some(true) {
1260 endpoint.push_str("&Filters=IsFavorite");
1261 }
1262 }
1263
1264 endpoint
1268 .push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
1269 endpoint
1270}
1271
1272fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
1286 format!(
1287 "/Users/{}/Items/Latest?ParentId={}&Limit={}&GroupItems=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1288 user_id,
1289 parent_id,
1290 limit.unwrap_or(16)
1291 )
1292}
1293
1294fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
1308 let mut endpoint = format!(
1309 "/Shows/NextUp?UserId={}&Limit={}&EnableResumable=false&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1310 user_id,
1311 limit.unwrap_or(16)
1312 );
1313
1314 if let Some(sid) = series_id {
1315 endpoint.push_str(&format!("&SeriesId={}", sid));
1316 }
1317
1318 endpoint
1319}
1320
1321fn build_favorites_endpoint(
1331 user_id: &str,
1332 scope: SearchScope,
1333 options: Option<&GetItemsOptions>,
1334) -> String {
1335 let mut endpoint = format!("/Users/{}/Items?Filters=IsFavorite&Recursive=true", user_id);
1336
1337 if let Some(types) = scope.item_types() {
1338 endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
1339 }
1340
1341 let sort_by = options
1344 .and_then(|o| o.sort_by.as_deref())
1345 .unwrap_or("SortName");
1346 let sort_order = options
1347 .and_then(|o| o.sort_order.as_deref())
1348 .unwrap_or("Ascending");
1349 endpoint.push_str(&format!("&SortBy={}&SortOrder={}", sort_by, sort_order));
1350
1351 if let Some(limit) = options.and_then(|o| o.limit) {
1352 endpoint.push_str(&format!("&Limit={}", limit));
1353 }
1354 if let Some(start_index) = options.and_then(|o| o.start_index) {
1355 endpoint.push_str(&format!("&StartIndex={}", start_index));
1356 }
1357
1358 endpoint
1359 .push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
1360 endpoint
1361}
1362
1363#[derive(Debug, Deserialize)]
1366#[serde(untagged)]
1367enum ImageTags {
1368 Map(std::collections::HashMap<String, String>),
1370 Structured {
1372 #[serde(rename = "Primary")]
1373 primary: Option<String>,
1374 },
1375}
1376
1377impl ImageTags {
1378 fn primary(&self) -> Option<String> {
1379 match self {
1380 ImageTags::Map(map) => map.get("Primary").cloned(),
1381 ImageTags::Structured { primary } => primary.clone(),
1382 }
1383 }
1384}
1385
1386#[derive(Debug, Deserialize, Clone)]
1387#[serde(rename_all = "PascalCase")]
1388struct JellyfinMediaStream {
1389 #[serde(rename = "Type")]
1390 stream_type: String,
1391 codec: Option<String>,
1392 language: Option<String>,
1393 display_title: Option<String>,
1394 index: i32,
1395 is_default: bool,
1396 #[serde(default)]
1397 is_forced: bool,
1398}
1399
1400#[derive(Debug, Deserialize, Clone)]
1401#[serde(rename_all = "PascalCase")]
1402struct JellyfinMediaSource {
1403 id: String,
1404 name: String,
1405 container: Option<String>,
1406 size: Option<i64>,
1407 bitrate: Option<i32>,
1408 supports_direct_play: bool,
1409 supports_direct_stream: bool,
1410 supports_transcoding: bool,
1411 direct_stream_url: Option<String>,
1412}
1413
1414impl JellyfinItem {
1415 fn into_media_item(self, server_id: String) -> MediaItem {
1416 let primary_tag = self.image_tags.as_ref().and_then(|tags| tags.primary());
1418 let backdrop_tags = self.backdrop_image_tags;
1419
1420 let kind = crate::domain::kind_from_jellyfin(&self.item_type, self.is_folder);
1421
1422 MediaItem {
1423 id: self.id,
1424 name: self.name,
1425 item_type: self.item_type,
1426 kind,
1427 is_folder: self.is_folder,
1428 server_id,
1429 parent_id: self.parent_id,
1430 library_id: None, overview: self.overview,
1432 genres: self.genres,
1433 production_year: self.production_year,
1434 premiere_date: self.premiere_date,
1435 community_rating: self.community_rating,
1436 official_rating: self.official_rating,
1437 runtime_ticks: self.run_time_ticks,
1438 duration_ms: self.run_time_ticks.map(crate::domain::ticks_to_ms),
1439 primary_image_tag: primary_tag.clone(),
1440 image_id: primary_tag,
1441 backdrop_image_tags: backdrop_tags,
1442 parent_backdrop_image_tags: self.parent_backdrop_image_tags,
1443 album_id: self.album_id,
1444 album_name: self.album,
1445 album_artist: self.album_artist,
1446 artists: self.artists,
1447 artist_items: self.artist_items,
1448 index_number: self.index_number,
1449 parent_index_number: self.parent_index_number,
1450 series_id: self.series_id,
1451 series_name: self.series_name,
1452 season_id: self.season_id,
1453 season_name: self.season_name,
1454 user_data: self.user_data.map(UserData::from),
1457 media_streams: self.media_streams.map(|streams| {
1458 streams
1459 .into_iter()
1460 .map(|s| {
1461 let kind = crate::domain::stream_kind_from_jellyfin(&s.stream_type);
1462 let supports_external_delivery =
1466 (kind == crate::domain::StreamKind::Subtitle).then(|| {
1467 super::device_profile::subtitle_supports_external_delivery(
1468 s.codec.as_deref(),
1469 )
1470 });
1471 crate::repository::types::MediaStream {
1472 kind,
1473 stream_type: s.stream_type,
1474 codec: s.codec,
1475 language: s.language,
1476 display_title: s.display_title,
1477 index: s.index,
1478 is_default: s.is_default,
1479 is_forced: s.is_forced,
1480 supports_external_delivery,
1481 }
1482 })
1483 .collect()
1484 }),
1485 media_sources: self.media_sources.map(|sources| {
1486 sources
1487 .into_iter()
1488 .map(|s| crate::repository::types::MediaSource {
1489 id: s.id,
1490 name: s.name,
1491 container: s.container,
1492 size: s.size,
1493 bitrate: s.bitrate,
1494 supports_direct_play: s.supports_direct_play,
1495 supports_direct_stream: s.supports_direct_stream,
1496 supports_transcoding: s.supports_transcoding,
1497 direct_stream_url: s.direct_stream_url,
1498 })
1499 .collect()
1500 }),
1501 people: self.people,
1502 }
1503 }
1504}
1505
1506#[derive(Debug, Serialize)]
1519#[serde(rename_all = "PascalCase")]
1520struct PlaybackInfoRequest {
1521 user_id: String,
1522 #[serde(skip_serializing_if = "Option::is_none")]
1526 audio_stream_index: Option<i32>,
1527 #[serde(skip_serializing_if = "Option::is_none")]
1528 subtitle_stream_index: Option<i32>,
1529 start_time_ticks: i64,
1530 is_playback: bool,
1531 auto_open_live_stream: bool,
1532 max_streaming_bitrate: i64,
1533 #[serde(skip_serializing_if = "Option::is_none")]
1534 device_profile: Option<DeviceProfile>,
1535}
1536
1537#[derive(Debug, Serialize)]
1538#[serde(rename_all = "PascalCase")]
1539struct DeviceProfile {
1540 name: String,
1541 max_streaming_bitrate: i64,
1542 max_static_bitrate: i64,
1543 max_audio_channels: String,
1547 direct_play_profiles: Vec<DirectPlayProfile>,
1548 transcoding_profiles: Vec<TranscodingProfile>,
1549 subtitle_profiles: Vec<SubtitleProfile>,
1550}
1551
1552#[derive(Debug, Serialize)]
1553#[serde(rename_all = "PascalCase")]
1554struct DirectPlayProfile {
1555 #[serde(rename = "Type")]
1556 profile_type: String,
1557 container: String,
1558 #[serde(skip_serializing_if = "Option::is_none")]
1559 video_codec: Option<String>,
1560 audio_codec: String,
1561}
1562
1563#[derive(Debug, Serialize)]
1564#[serde(rename_all = "PascalCase")]
1565struct TranscodingProfile {
1566 #[serde(rename = "Type")]
1567 profile_type: String,
1568 context: String,
1569 protocol: String,
1570 container: String,
1571 #[serde(skip_serializing_if = "Option::is_none")]
1572 video_codec: Option<String>,
1573 audio_codec: String,
1574 max_audio_channels: String,
1575}
1576
1577#[derive(Debug, Serialize)]
1578#[serde(rename_all = "PascalCase")]
1579struct SubtitleProfile {
1580 format: String,
1581 method: String,
1582}
1583
1584#[derive(Debug, Deserialize)]
1585#[serde(rename_all = "PascalCase")]
1586struct PlaybackInfoResponse {
1587 media_sources: Vec<NegotiatedSource>,
1588 play_session_id: String,
1589}
1590
1591#[derive(Debug, Deserialize)]
1592#[serde(rename_all = "PascalCase")]
1593pub struct NegotiatedSource {
1594 pub id: String,
1595 pub supports_direct_play: bool,
1596 #[serde(default)]
1602 pub supports_direct_stream: bool,
1603 pub supports_transcoding: bool,
1604 pub transcoding_url: Option<String>,
1605 #[serde(default)]
1612 pub bitrate: Option<i64>,
1613 #[serde(default)]
1614 pub media_streams: Vec<NegotiatedStream>,
1615}
1616
1617#[derive(Debug, Deserialize)]
1618#[serde(rename_all = "PascalCase")]
1619pub struct NegotiatedStream {
1620 #[serde(rename = "Type")]
1621 stream_type: String,
1622 #[serde(default)]
1623 index: i32,
1624 #[serde(default)]
1625 codec: Option<String>,
1626 #[serde(default)]
1628 is_default: bool,
1629}
1630
1631pub fn decide_playback_kind(
1645 source: &NegotiatedSource,
1646 audio_forces_transcode: bool,
1647 audio_track_pinned: bool,
1648) -> PlaybackKind {
1649 if audio_forces_transcode {
1650 warn!(
1651 "[StreamSelection] Server offered direct play for audio this renderer cannot decode — forcing a transcode"
1652 );
1653 return PlaybackKind::Transcode;
1654 }
1655 if audio_track_pinned {
1656 return PlaybackKind::Transcode;
1659 }
1660 if source.supports_direct_play {
1661 PlaybackKind::DirectPlay
1662 } else if source.supports_direct_stream {
1663 PlaybackKind::DirectStream
1664 } else {
1665 PlaybackKind::Transcode
1666 }
1667}
1668
1669#[async_trait]
1670impl MediaRepository for OnlineRepository {
1671 async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
1672 #[derive(Debug, Deserialize)]
1673 #[serde(rename_all = "PascalCase")]
1674 struct LibrariesResponse {
1675 items: Vec<JellyfinLibrary>,
1676 }
1677
1678 #[derive(Debug, Deserialize)]
1679 #[serde(rename_all = "PascalCase")]
1680 struct JellyfinLibrary {
1681 id: String,
1682 name: String,
1683 collection_type: Option<String>,
1684 image_tags: Option<ImageTags>,
1685 }
1686
1687 let endpoint = format!("/Users/{}/Views", self.user_id);
1688 let response: LibrariesResponse = self.get_json(&endpoint).await?;
1689
1690 Ok(response
1691 .items
1692 .into_iter()
1693 .map(|lib| {
1694 Library::new(
1695 lib.id,
1696 lib.name,
1697 lib.collection_type.unwrap_or_else(|| "unknown".to_string()),
1698 lib.image_tags.and_then(|tags| tags.primary()),
1699 )
1700 })
1701 .collect())
1702 }
1703
1704 async fn get_items(
1705 &self,
1706 parent_id: &str,
1707 options: Option<GetItemsOptions>,
1708 ) -> Result<SearchResult, RepoError> {
1709 let endpoint = build_get_items_endpoint(&self.user_id, parent_id, options.as_ref());
1710
1711 let response: ItemsResponse = self.get_json(&endpoint).await?;
1712
1713 Ok(SearchResult {
1714 items: response
1715 .items
1716 .into_iter()
1717 .map(|item| item.into_media_item(self.user_id.clone()))
1718 .collect(),
1719 total_record_count: response.total_record_count,
1720 })
1721 }
1722
1723 async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
1735 let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, urlencoding::encode(item_id));
1736
1737 let item: JellyfinItem = self.get_json(&endpoint).await?;
1738 let media_item = item.into_media_item(self.user_id.clone());
1739
1740 Ok(media_item)
1741 }
1742
1743 async fn get_latest_items(
1744 &self,
1745 parent_id: &str,
1746 limit: Option<usize>,
1747 ) -> Result<Vec<MediaItem>, RepoError> {
1748 let endpoint = build_latest_items_endpoint(&self.user_id, parent_id, limit);
1749
1750 let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
1751 Ok(items
1752 .into_iter()
1753 .map(|item| item.into_media_item(self.user_id.clone()))
1754 .collect())
1755 }
1756
1757 async fn get_resume_items(
1766 &self,
1767 parent_id: Option<&str>,
1768 limit: Option<usize>,
1769 ) -> Result<Vec<MediaItem>, RepoError> {
1770 let limit_str = limit.unwrap_or(16);
1771 let mut endpoint = format!(
1772 "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1773 self.user_id, limit_str
1774 );
1775
1776 if let Some(pid) = parent_id {
1777 endpoint.push_str(&format!("&ParentId={}", pid));
1778 }
1779
1780 let response: ItemsResponse = self.get_json(&endpoint).await?;
1781 Ok(response
1782 .items
1783 .into_iter()
1784 .map(|item| item.into_media_item(self.user_id.clone()))
1785 .collect())
1786 }
1787
1788 async fn get_next_up_episodes(
1793 &self,
1794 series_id: Option<&str>,
1795 limit: Option<usize>,
1796 ) -> Result<Vec<MediaItem>, RepoError> {
1797 let endpoint = build_next_up_endpoint(&self.user_id, series_id, limit);
1798
1799 let response: ItemsResponse = self.get_json(&endpoint).await?;
1800 Ok(response
1801 .items
1802 .into_iter()
1803 .map(|item| item.into_media_item(self.user_id.clone()))
1804 .collect())
1805 }
1806
1807 async fn get_recently_played_audio(
1808 &self,
1809 limit: Option<usize>,
1810 ) -> Result<Vec<MediaItem>, RepoError> {
1811 let limit_val = limit.unwrap_or(12);
1812 let fetch_limit = limit_val * 3;
1814 let endpoint = format!(
1815 "/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1816 self.user_id, fetch_limit
1817 );
1818
1819 let response: ItemsResponse = self.get_json(&endpoint).await?;
1820 let items: Vec<MediaItem> = response
1821 .items
1822 .into_iter()
1823 .map(|item| item.into_media_item(self.user_id.clone()))
1824 .collect();
1825
1826 debug!("[get_recently_played_audio] Fetched {} items", items.len());
1827 for item in &items {
1828 debug!("[get_recently_played_audio] Item: name={}, type={}, album_id={:?}, album_name={:?}",
1829 item.name, item.item_type, item.album_id, item.album_name);
1830 }
1831
1832 use std::collections::BTreeMap;
1834 let mut album_map: BTreeMap<String, Vec<MediaItem>> = BTreeMap::new();
1835 let mut ungrouped = Vec::new();
1836
1837 for item in items {
1838 let group_key = item.album_id.clone().or_else(|| item.album_name.clone());
1840
1841 if let Some(key) = group_key {
1842 debug!(
1843 "[get_recently_played_audio] Grouping item '{}' into album '{}'",
1844 item.name, key
1845 );
1846 album_map.entry(key).or_default().push(item);
1847 } else {
1848 debug!(
1849 "[get_recently_played_audio] No album_id or album_name for item: '{}'",
1850 item.name
1851 );
1852 ungrouped.push(item);
1853 }
1854 }
1855
1856 let mut result: Vec<MediaItem> = album_map
1858 .into_iter()
1859 .map(|(album_id, tracks)| {
1860 let first_track = &tracks[0];
1861 let most_recent = tracks
1862 .iter()
1863 .max_by(|a, b| {
1864 let date_a = a
1865 .user_data
1866 .as_ref()
1867 .and_then(|ud| ud.last_played_date.as_deref())
1868 .unwrap_or("");
1869 let date_b = b
1870 .user_data
1871 .as_ref()
1872 .and_then(|ud| ud.last_played_date.as_deref())
1873 .unwrap_or("");
1874 date_b.cmp(date_a)
1875 })
1876 .unwrap_or(first_track);
1877
1878 MediaItem {
1879 id: album_id,
1880 name: first_track
1881 .album_name
1882 .clone()
1883 .unwrap_or_else(|| "Unknown Album".to_string()),
1884 item_type: "MusicAlbum".to_string(),
1885 kind: crate::domain::MediaKind::Album,
1886 is_folder: true,
1887 server_id: first_track.server_id.clone(),
1888 parent_id: None,
1889 library_id: None,
1890 overview: None,
1891 genres: None,
1892 production_year: None,
1893 premiere_date: None,
1894 community_rating: None,
1895 official_rating: None,
1896 runtime_ticks: None,
1897 duration_ms: None,
1898 primary_image_tag: first_track.primary_image_tag.clone(),
1899 image_id: first_track.primary_image_tag.clone(),
1900 backdrop_image_tags: None,
1901 parent_backdrop_image_tags: None,
1902 album_id: None,
1903 album_name: None,
1904 album_artist: None,
1905 artists: first_track.artists.clone(),
1906 artist_items: first_track.artist_items.clone(),
1907 index_number: None,
1908 parent_index_number: None,
1909 series_id: None,
1910 series_name: None,
1911 season_id: None,
1912 season_name: None,
1913 user_data: most_recent.user_data.clone(),
1914 media_streams: None,
1915 media_sources: None,
1916 people: None,
1917 }
1918 })
1919 .collect();
1920
1921 result.extend(ungrouped);
1923
1924 let final_result: Vec<MediaItem> = result.into_iter().take(limit_val).collect();
1926 debug!(
1927 "[get_recently_played_audio] Returning {} items after grouping",
1928 final_result.len()
1929 );
1930 for item in &final_result {
1931 debug!(
1932 "[get_recently_played_audio] Return: name={}, type={}",
1933 item.name, item.item_type
1934 );
1935 }
1936 Ok(final_result)
1937 }
1938
1939 async fn get_rediscover_albums(
1940 &self,
1941 parent_id: Option<&str>,
1942 limit: Option<usize>,
1943 ) -> Result<Vec<MediaItem>, RepoError> {
1944 let limit_val = limit.unwrap_or(12);
1945 let mut endpoint = format!(
1949 "/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1950 self.user_id, limit_val
1951 );
1952
1953 if let Some(pid) = parent_id {
1954 endpoint.push_str(&format!("&ParentId={}", pid));
1955 }
1956
1957 let response: ItemsResponse = self.get_json(&endpoint).await?;
1958 Ok(response
1959 .items
1960 .into_iter()
1961 .map(|item| item.into_media_item(self.user_id.clone()))
1962 .collect())
1963 }
1964
1965 async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
1971 let limit_str = limit.unwrap_or(16);
1972 let endpoint = format!(
1973 "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1974 self.user_id, limit_str
1975 );
1976
1977 let response: ItemsResponse = self.get_json(&endpoint).await?;
1978 Ok(response
1979 .items
1980 .into_iter()
1981 .map(|item| item.into_media_item(self.user_id.clone()))
1982 .collect())
1983 }
1984
1985 async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
1986 let mut endpoint = format!(
1989 "/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
1990 self.user_id
1991 );
1992
1993 if let Some(pid) = parent_id {
1994 endpoint.push_str(&format!("&ParentId={}", pid));
1995 }
1996
1997 #[derive(Debug, Deserialize)]
1998 #[serde(rename_all = "PascalCase")]
1999 struct GenresResponse {
2000 items: Vec<JellyfinGenre>,
2001 }
2002
2003 #[derive(Debug, Deserialize)]
2004 #[serde(rename_all = "PascalCase")]
2005 struct JellyfinGenre {
2006 id: String,
2007 name: String,
2008 album_count: Option<u32>,
2014 child_count: Option<u32>,
2015 }
2016
2017 let response: GenresResponse = self.get_json(&endpoint).await?;
2018 let genres: Vec<Genre> = response
2019 .items
2020 .into_iter()
2021 .map(|g| Genre {
2022 id: g.id,
2023 name: g.name,
2024 album_count: g.album_count.or(g.child_count),
2025 })
2026 .collect();
2027
2028 let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
2029 log::warn!(
2032 "get_genres: {} genres, {} carry counts. sample: {:?}",
2033 genres.len(),
2034 with_counts,
2035 genres
2036 .iter()
2037 .take(8)
2038 .map(|g| (g.name.as_str(), g.album_count))
2039 .collect::<Vec<_>>()
2040 );
2041
2042 Ok(genres)
2043 }
2044
2045 async fn search(
2054 &self,
2055 query: &str,
2056 options: Option<SearchOptions>,
2057 ) -> Result<SearchResult, RepoError> {
2058 let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50);
2059 let mut endpoint = format!(
2063 "/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
2064 self.user_id,
2065 urlencoding::encode(query),
2066 limit
2067 );
2068
2069 if let Some(opts) = options {
2070 if let Some(types) = opts.include_item_types {
2071 let encoded_types = types
2072 .iter()
2073 .map(|t| urlencoding::encode(t).into_owned())
2074 .collect::<Vec<_>>()
2075 .join(",");
2076 endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
2077 }
2078 }
2079
2080 endpoint.push_str(
2083 "&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData",
2084 );
2085
2086 let response: ItemsResponse = self.get_json(&endpoint).await?;
2087 Ok(SearchResult {
2088 items: response
2089 .items
2090 .into_iter()
2091 .map(|item| item.into_media_item(self.user_id.clone()))
2092 .collect(),
2093 total_record_count: response.total_record_count,
2094 })
2095 }
2096
2097 async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
2098 let (source, play_session_id) = self.negotiate_playback(item_id).await?;
2099
2100 info!(
2102 "PlaybackInfo MediaSource has {} streams",
2103 source.media_streams.len()
2104 );
2105 for stream in &source.media_streams {
2106 info!(
2107 " Stream type={}, index={}, codec={:?}",
2108 stream.stream_type, stream.index, stream.codec
2109 );
2110 }
2111
2112 for stream in &source.media_streams {
2117 if stream.stream_type == "Subtitle" {
2118 if let Some(codec) = stream.codec.as_deref() {
2119 if super::device_profile::subtitle_forces_burn_in(codec) {
2120 info!(
2121 " 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)",
2122 stream.index, codec
2123 );
2124 }
2125 }
2126 }
2127 }
2128
2129 let audio_streams: Vec<(Option<&str>, bool)> = source
2135 .media_streams
2136 .iter()
2137 .filter(|stream| stream.stream_type == "Audio")
2138 .map(|stream| (stream.codec.as_deref(), stream.is_default))
2139 .collect();
2140 let audio_forces_transcode = super::device_profile::audio_forces_transcode(&audio_streams);
2141
2142 let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
2144 if let Some(previous) = adopt_video_play_session(play_session_id.clone()) {
2148 self.stop_transcode(&previous).await;
2149 }
2150 format!(
2156 "{}{}",
2157 self.server_url,
2158 super::device_profile::without_server_chosen_subtitle(transcoding_url)
2159 )
2160 } else if audio_forces_transcode {
2161 warn!(
2162 "[PlaybackInfo] Server offered direct play for audio the webview cannot decode ({:?}) — forcing an HLS transcode",
2163 audio_streams.first().and_then(|(codec, _)| *codec)
2164 );
2165 self.get_video_stream_url(item_id, Some(&source.id), None)
2166 .await?
2167 } else {
2168 format!(
2172 "{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&userId={}",
2173 self.server_url,
2174 item_id,
2175 source.id,
2176 self.access_token,
2177 self.user_id
2178 )
2179 };
2180
2181 info!("Final stream URL: {}", stream_url);
2182
2183 Ok(PlaybackInfo {
2184 media_source_id: source.id.clone(),
2185 play_session_id,
2186 stream_url,
2187 direct_play: source.supports_direct_play && !audio_forces_transcode,
2188 needs_transcoding: audio_forces_transcode
2189 || (!source.supports_direct_play && source.supports_transcoding),
2190 })
2191 }
2192
2193 async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
2194 let url = format!(
2196 "{}/Audio/{}/stream?UserId={}&api_key={}&Static=true",
2197 self.server_url, item_id, self.user_id, self.access_token
2198 );
2199 Ok(url)
2200 }
2201
2202 async fn get_audio_only_stream_url_for_video(
2203 &self,
2204 item_id: &str,
2205 media_source_id: Option<&str>,
2206 start_time_seconds: Option<f64>,
2207 audio_stream_index: Option<i32>,
2208 ) -> Result<String, RepoError> {
2209 self.build_audio_only_stream_url_for_video(
2210 item_id,
2211 media_source_id,
2212 start_time_seconds,
2213 audio_stream_index,
2214 )
2215 .await
2216 }
2217
2218 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
2219 let endpoint = format!(
2222 "/LiveTv/Channels?UserId={}&Fields=PrimaryImageAspectRatio,Overview&EnableImageTypes=Primary",
2223 self.user_id
2224 );
2225 let response: ItemsResponse = self.get_json(&endpoint).await?;
2226 Ok(response
2227 .items
2228 .into_iter()
2229 .map(|item| item.into_media_item(self.server_url.clone()))
2230 .collect())
2231 }
2232
2233 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
2234 let endpoint = format!("/Channels?UserId={}", self.user_id);
2237 let response: ItemsResponse = self.get_json(&endpoint).await?;
2238 let total = response.total_record_count;
2239 let items = response
2240 .items
2241 .into_iter()
2242 .map(|item| item.into_media_item(self.server_url.clone()))
2243 .collect();
2244 Ok(SearchResult {
2245 items,
2246 total_record_count: total,
2247 })
2248 }
2249
2250 async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
2251 #[derive(Debug, Serialize)]
2255 #[serde(rename_all = "PascalCase")]
2256 struct OpenLiveStreamRequest {
2257 user_id: String,
2258 #[serde(rename = "AutoOpenLiveStream")]
2259 auto_open_live_stream: bool,
2260 is_playback: bool,
2261 max_streaming_bitrate: u64,
2262 subtitle_stream_index: i32,
2269 }
2270
2271 #[derive(Debug, Deserialize)]
2272 #[serde(rename_all = "PascalCase")]
2273 struct OpenLiveStreamResponse {
2274 #[serde(default)]
2275 media_sources: Vec<LiveMediaSource>,
2276 play_session_id: Option<String>,
2277 }
2278
2279 #[derive(Debug, Deserialize)]
2280 #[serde(rename_all = "PascalCase")]
2281 struct LiveMediaSource {
2282 id: String,
2283 transcoding_url: Option<String>,
2284 live_stream_id: Option<String>,
2285 }
2286
2287 let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
2288 let request = OpenLiveStreamRequest {
2289 user_id: self.user_id.clone(),
2290 auto_open_live_stream: true,
2291 is_playback: true,
2292 max_streaming_bitrate: effective_streaming_quality()
2296 .max_bitrate()
2297 .unwrap_or(20_000_000),
2298 subtitle_stream_index: super::device_profile::playback_subtitle_stream_index(),
2299 };
2300
2301 let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?;
2302
2303 let source = response
2304 .media_sources
2305 .into_iter()
2306 .next()
2307 .ok_or(RepoError::NotFound {
2308 message: "No live media source returned".to_string(),
2309 })?;
2310
2311 let stream_url = match source.transcoding_url {
2314 Some(url) => format!(
2317 "{}{}",
2318 self.server_url,
2319 super::device_profile::without_server_chosen_subtitle(&url)
2320 ),
2321 None => format!(
2322 "{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts&SubtitleStreamIndex={}",
2323 self.server_url,
2324 item_id,
2325 self.access_token,
2326 source.id,
2327 source.live_stream_id.clone().unwrap_or_default(),
2328 super::device_profile::playback_subtitle_stream_index(),
2329 ),
2330 };
2331
2332 Ok(LiveStreamInfo {
2333 stream_url,
2334 play_session_id: response.play_session_id,
2335 live_stream_id: source.live_stream_id,
2336 media_source_id: Some(source.id),
2337 transport: Transport::Hls,
2340 })
2341 }
2342
2343 async fn report_playback_start(
2344 &self,
2345 item_id: &str,
2346 position_ticks: i64,
2347 ) -> Result<(), RepoError> {
2348 #[derive(Serialize)]
2349 #[serde(rename_all = "PascalCase")]
2350 struct PlaybackStartRequest {
2351 item_id: String,
2352 position_ticks: i64,
2353 play_command: String,
2354 is_paused: bool,
2355 }
2356
2357 let request = PlaybackStartRequest {
2358 item_id: item_id.to_string(),
2359 position_ticks,
2360 play_command: "PlayNow".to_string(),
2361 is_paused: false,
2362 };
2363
2364 self.post_json("/Sessions/Playing", &request).await
2365 }
2366
2367 async fn report_playback_progress(
2368 &self,
2369 item_id: &str,
2370 position_ticks: i64,
2371 ) -> Result<(), RepoError> {
2372 #[derive(Serialize)]
2373 #[serde(rename_all = "PascalCase")]
2374 struct PlaybackProgressRequest {
2375 item_id: String,
2376 position_ticks: i64,
2377 is_paused: bool,
2378 }
2379
2380 let request = PlaybackProgressRequest {
2381 item_id: item_id.to_string(),
2382 position_ticks,
2383 is_paused: false,
2384 };
2385
2386 self.post_json("/Sessions/Playing/Progress", &request).await
2387 }
2388
2389 async fn report_playback_stopped(
2390 &self,
2391 item_id: &str,
2392 position_ticks: i64,
2393 ) -> Result<(), RepoError> {
2394 #[derive(Serialize)]
2395 #[serde(rename_all = "PascalCase")]
2396 struct PlaybackStoppedRequest {
2397 item_id: String,
2398 position_ticks: i64,
2399 }
2400
2401 let request = PlaybackStoppedRequest {
2402 item_id: item_id.to_string(),
2403 position_ticks,
2404 };
2405
2406 self.post_json("/Sessions/Playing/Stopped", &request).await
2407 }
2408
2409 fn get_image_url(
2410 &self,
2411 item_id: &str,
2412 image_type: ImageType,
2413 options: Option<ImageOptions>,
2414 ) -> String {
2415 let mut url = format!(
2416 "{}/Items/{}/Images/{}",
2417 self.server_url,
2418 item_id,
2419 image_type.as_str()
2420 );
2421
2422 let mut params: Vec<String> = Vec::new();
2426
2427 if let Some(opts) = options {
2428 if let Some(width) = opts.max_width {
2429 params.push(format!("maxWidth={}", width));
2430 }
2431 if let Some(height) = opts.max_height {
2432 params.push(format!("maxHeight={}", height));
2433 }
2434 if let Some(quality) = opts.quality {
2435 params.push(format!("quality={}", quality));
2436 }
2437 if let Some(tag) = opts.tag {
2438 params.push(format!("tag={}", tag));
2439 }
2440 }
2441
2442 if !params.is_empty() {
2443 url.push('?');
2444 url.push_str(¶ms.join("&"));
2445 }
2446
2447 url
2448 }
2449
2450 fn get_subtitle_url(
2451 &self,
2452 item_id: &str,
2453 media_source_id: &str,
2454 stream_index: i32,
2455 format: &str,
2456 ) -> String {
2457 format!(
2458 "{}/Videos/{}/{}/Subtitles/{}/{}",
2459 self.server_url, item_id, media_source_id, stream_index, format
2460 )
2461 }
2462
2463 fn get_video_download_url(
2465 &self,
2466 item_id: &str,
2467 quality: &str,
2468 media_source_id: Option<&str>,
2469 source_audio_codec: Option<&str>,
2470 ) -> String {
2471 let mut url = format!("{}/Videos/{}/stream.mp4", self.server_url, item_id);
2477 let mut params = vec![format!("api_key={}", self.access_token)];
2478
2479 match quality {
2496 "high" => {
2497 params.push("videoBitRate=8000000".to_string());
2498 params.push("maxHeight=1080".to_string());
2499 params.push("audioBitRate=384000".to_string());
2500 params.push("videoCodec=h264".to_string());
2501 params.push("audioCodec=aac".to_string());
2502 params.push("allowVideoStreamCopy=false".to_string());
2503 }
2504 "medium" => {
2505 params.push("videoBitRate=4000000".to_string());
2506 params.push("maxHeight=720".to_string());
2507 params.push("audioBitRate=256000".to_string());
2508 params.push("videoCodec=h264".to_string());
2509 params.push("audioCodec=aac".to_string());
2510 params.push("allowVideoStreamCopy=false".to_string());
2511 }
2512 "low" => {
2513 params.push("videoBitRate=1500000".to_string());
2514 params.push("maxHeight=480".to_string());
2515 params.push("audioBitRate=128000".to_string());
2516 params.push("videoCodec=h264".to_string());
2517 params.push("audioCodec=aac".to_string());
2518 params.push("allowVideoStreamCopy=false".to_string());
2519 }
2520 _ => match source_audio_codec {
2542 Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
2543 params.push("videoCodec=h264".to_string());
2544 params.push("allowVideoStreamCopy=true".to_string());
2545 params.push("audioCodec=aac".to_string());
2546 params.push("audioBitRate=384000".to_string());
2547 }
2548 _ => params.push("Static=true".to_string()),
2552 },
2553 }
2554
2555 if let Some(source_id) = media_source_id {
2557 params.push(format!("mediaSourceId={}", source_id));
2558 }
2559
2560 url.push('?');
2561 url.push_str(¶ms.join("&"));
2562
2563 url
2564 }
2565
2566 async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
2567 let endpoint = format!(
2568 "/Users/{}/FavoriteItems/{}",
2569 self.user_id,
2570 urlencoding::encode(item_id)
2571 );
2572 self.post_json(&endpoint, &serde_json::json!({})).await
2573 }
2574
2575 async fn get_favorites(
2577 &self,
2578 scope: SearchScope,
2579 options: Option<GetItemsOptions>,
2580 ) -> Result<SearchResult, RepoError> {
2581 let endpoint = build_favorites_endpoint(&self.user_id, scope, options.as_ref());
2582 let response: ItemsResponse = self.get_json(&endpoint).await?;
2583
2584 Ok(SearchResult {
2585 items: response
2586 .items
2587 .into_iter()
2588 .map(|item| item.into_media_item(self.user_id.clone()))
2589 .collect(),
2590 total_record_count: response.total_record_count,
2591 })
2592 }
2593
2594 async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
2601 let endpoint = format!(
2602 "/Users/{}/FavoriteItems/{}",
2603 self.user_id,
2604 urlencoding::encode(item_id)
2605 );
2606 let url = format!("{}{}", self.server_url, endpoint);
2607
2608 let result = async {
2609 let request = self
2610 .http_client
2611 .client
2612 .delete(&url)
2613 .header("X-Emby-Authorization", self.auth_header())
2614 .build()
2615 .map_err(|e| RepoError::Network {
2616 message: format!("Failed to build request: {}", e),
2617 })?;
2618
2619 let response = self
2620 .http_client
2621 .request_with_retry(request)
2622 .await
2623 .map_err(|e| RepoError::Network {
2624 message: e.to_string(),
2625 })?;
2626
2627 if !response.status().is_success() {
2628 return Err(RepoError::Server {
2629 message: format!("HTTP {}", response.status()),
2630 });
2631 }
2632
2633 Ok(())
2634 }
2635 .await;
2636
2637 self.report_outcome(&result).await;
2638 result
2639 }
2640
2641 async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
2647 let endpoint = format!(
2648 "/Users/{}/PlayedItems/{}",
2649 self.user_id,
2650 urlencoding::encode(item_id)
2651 );
2652 let url = format!("{}{}", self.server_url, endpoint);
2653
2654 let result = async {
2655 let request = self
2656 .http_client
2657 .client
2658 .delete(&url)
2659 .header("X-Emby-Authorization", self.auth_header())
2660 .build()
2661 .map_err(|e| RepoError::Network {
2662 message: format!("Failed to build request: {}", e),
2663 })?;
2664
2665 let response = self
2666 .http_client
2667 .request_with_retry(request)
2668 .await
2669 .map_err(|e| RepoError::Network {
2670 message: e.to_string(),
2671 })?;
2672
2673 if !response.status().is_success() {
2674 return Err(RepoError::Server {
2675 message: format!("HTTP {}", response.status()),
2676 });
2677 }
2678
2679 Ok(())
2680 }
2681 .await;
2682
2683 self.report_outcome(&result).await;
2684 result
2685 }
2686
2687 async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
2692 let endpoint = format!(
2693 "/Users/{}/PlayedItems/{}",
2694 self.user_id,
2695 urlencoding::encode(item_id)
2696 );
2697 let url = format!("{}{}", self.server_url, endpoint);
2698
2699 let result = async {
2700 let request = self
2701 .http_client
2702 .client
2703 .post(&url)
2704 .header("X-Emby-Authorization", self.auth_header())
2705 .header("Content-Length", "0")
2706 .build()
2707 .map_err(|e| RepoError::Network {
2708 message: format!("Failed to build request: {}", e),
2709 })?;
2710
2711 let response = self
2712 .http_client
2713 .request_with_retry(request)
2714 .await
2715 .map_err(|e| RepoError::Network {
2716 message: e.to_string(),
2717 })?;
2718
2719 if !response.status().is_success() {
2720 return Err(RepoError::Server {
2721 message: format!("HTTP {}", response.status()),
2722 });
2723 }
2724
2725 Ok(())
2726 }
2727 .await;
2728
2729 self.report_outcome(&result).await;
2730 result
2731 }
2732
2733 async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
2741 let endpoint = format!(
2742 "/Users/{}/Items/{}",
2743 self.user_id,
2744 urlencoding::encode(person_id)
2745 );
2746 let item: JellyfinItem = self.get_json(&endpoint).await?;
2747 Ok(item.into_media_item(self.user_id.clone()))
2748 }
2749
2750 async fn get_items_by_person(
2754 &self,
2755 person_id: &str,
2756 options: Option<GetItemsOptions>,
2757 ) -> Result<SearchResult, RepoError> {
2758 let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
2759
2760 let mut endpoint = format!(
2761 "/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
2762 self.user_id, person_id, limit
2763 );
2764
2765 if let Some(ref opts) = options {
2767 if let Some(ref include_types) = opts.include_item_types {
2768 if !include_types.is_empty() {
2769 let types_param = include_types.join(",");
2770 endpoint.push_str(&format!("&IncludeItemTypes={}", types_param));
2771 }
2772 }
2773 }
2774
2775 let response: ItemsResponse = self.get_json(&endpoint).await?;
2776 Ok(SearchResult {
2777 items: response
2778 .items
2779 .into_iter()
2780 .map(|item| item.into_media_item(self.user_id.clone()))
2781 .collect(),
2782 total_record_count: response.total_record_count,
2783 })
2784 }
2785
2786 async fn get_similar_items(
2787 &self,
2788 item_id: &str,
2789 limit: Option<usize>,
2790 ) -> Result<SearchResult, RepoError> {
2791 let limit_str = limit.unwrap_or(20);
2792
2793 let endpoint = format!(
2795 "/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
2796 item_id, self.user_id, limit_str
2797 );
2798
2799 let response: ItemsResponse = self.get_json(&endpoint).await?;
2800 Ok(SearchResult {
2801 items: response
2802 .items
2803 .into_iter()
2804 .map(|item| item.into_media_item(self.user_id.clone()))
2805 .collect(),
2806 total_record_count: response.total_record_count,
2807 })
2808 }
2809
2810 async fn create_playlist(
2813 &self,
2814 name: &str,
2815 item_ids: &[String],
2816 ) -> Result<PlaylistCreatedResult, RepoError> {
2817 info!(
2818 "[OnlineRepo] Creating playlist '{}' with {} items",
2819 name,
2820 item_ids.len()
2821 );
2822 let body = serde_json::json!({
2823 "Name": name,
2824 "Ids": item_ids,
2825 "MediaType": "Audio",
2826 "UserId": self.user_id,
2827 });
2828 let response: CreatePlaylistResponse = self.post_json_response("/Playlists", &body).await?;
2829 Ok(PlaylistCreatedResult { id: response.id })
2830 }
2831
2832 async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
2833 info!("[OnlineRepo] Deleting playlist {}", playlist_id);
2834 let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
2835 let url = format!("{}{}", self.server_url, endpoint);
2836
2837 let request = self
2838 .http_client
2839 .client
2840 .delete(&url)
2841 .header("X-Emby-Authorization", self.auth_header())
2842 .build()
2843 .map_err(|e| RepoError::Network {
2844 message: format!("Failed to build request: {}", e),
2845 })?;
2846
2847 let response = self
2848 .http_client
2849 .request_with_retry(request)
2850 .await
2851 .map_err(|e| RepoError::Network {
2852 message: e.to_string(),
2853 })?;
2854
2855 if !response.status().is_success() {
2856 return Err(RepoError::Server {
2857 message: format!("HTTP {}", response.status()),
2858 });
2859 }
2860
2861 Ok(())
2862 }
2863
2864 async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
2865 info!(
2866 "[OnlineRepo] Renaming playlist {} to '{}'",
2867 playlist_id, name
2868 );
2869 let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
2870 self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
2871 .await
2872 }
2873
2874 async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
2875 let endpoint = format!(
2876 "/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000",
2877 playlist_id, self.user_id
2878 );
2879
2880 let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
2881 debug!(
2882 "[OnlineRepo] Got {} playlist items for {}",
2883 response.items.len(),
2884 playlist_id
2885 );
2886
2887 Ok(response
2888 .items
2889 .into_iter()
2890 .map(|pi| PlaylistEntry {
2891 playlist_item_id: pi.playlist_item_id,
2892 item: pi.item.into_media_item(self.user_id.clone()),
2893 })
2894 .collect())
2895 }
2896
2897 async fn add_to_playlist(
2898 &self,
2899 playlist_id: &str,
2900 item_ids: &[String],
2901 ) -> Result<(), RepoError> {
2902 info!(
2903 "[OnlineRepo] Adding {} items to playlist {}",
2904 item_ids.len(),
2905 playlist_id
2906 );
2907 let ids_param = item_ids
2909 .iter()
2910 .map(|id| urlencoding::encode(id).into_owned())
2911 .collect::<Vec<_>>()
2912 .join(",");
2913 let endpoint = format!(
2914 "/Playlists/{}/Items?Ids={}",
2915 urlencoding::encode(playlist_id),
2916 ids_param
2917 );
2918 self.post_json(&endpoint, &serde_json::json!({})).await
2919 }
2920
2921 async fn remove_from_playlist(
2922 &self,
2923 playlist_id: &str,
2924 entry_ids: &[String],
2925 ) -> Result<(), RepoError> {
2926 info!(
2927 "[OnlineRepo] Removing {} entries from playlist {}",
2928 entry_ids.len(),
2929 playlist_id
2930 );
2931 let ids_param = entry_ids
2932 .iter()
2933 .map(|id| urlencoding::encode(id).into_owned())
2934 .collect::<Vec<_>>()
2935 .join(",");
2936 let endpoint = format!(
2937 "/Playlists/{}/Items?EntryIds={}",
2938 urlencoding::encode(playlist_id),
2939 ids_param
2940 );
2941 let url = format!("{}{}", self.server_url, endpoint);
2942
2943 let request = self
2944 .http_client
2945 .client
2946 .delete(&url)
2947 .header("X-Emby-Authorization", self.auth_header())
2948 .build()
2949 .map_err(|e| RepoError::Network {
2950 message: format!("Failed to build request: {}", e),
2951 })?;
2952
2953 let response = self
2954 .http_client
2955 .request_with_retry(request)
2956 .await
2957 .map_err(|e| RepoError::Network {
2958 message: e.to_string(),
2959 })?;
2960
2961 if !response.status().is_success() {
2962 return Err(RepoError::Server {
2963 message: format!("HTTP {}", response.status()),
2964 });
2965 }
2966
2967 Ok(())
2968 }
2969
2970 async fn move_playlist_item(
2971 &self,
2972 playlist_id: &str,
2973 item_id: &str,
2974 new_index: u32,
2975 ) -> Result<(), RepoError> {
2976 info!(
2977 "[OnlineRepo] Moving item {} in playlist {} to index {}",
2978 item_id, playlist_id, new_index
2979 );
2980 let endpoint = format!(
2981 "/Playlists/{}/Items/{}/Move/{}",
2982 playlist_id, item_id, new_index
2983 );
2984 self.post_json(&endpoint, &serde_json::json!({})).await
2985 }
2986}
2987
2988#[cfg(test)]
2989mod tests {
2990 use super::*;
2991 use crate::utils::lock::MutexSafe;
2992 use std::sync::Arc;
2993
2994 fn create_test_repository() -> OnlineRepository {
2995 let http_config = crate::jellyfin::HttpConfig::default();
2996 let http_client =
2997 Arc::new(HttpClient::new(http_config).expect("Failed to create HTTP client for test"));
2998 OnlineRepository::new(
2999 http_client,
3000 "https://test.server.com".to_string(),
3001 "test-user-id".to_string(),
3002 "test-access-token".to_string(),
3003 )
3004 }
3005
3006 fn create_test_repository_with_connectivity(
3010 ) -> (OnlineRepository, crate::connectivity::ConnectivityReporter) {
3011 let monitor_http = HttpClient::new(crate::jellyfin::HttpConfig::default())
3012 .expect("Failed to create HTTP client for monitor");
3013 let monitor = crate::connectivity::ConnectivityMonitor::new(monitor_http);
3014 let reporter = monitor.reporter();
3015 let repo = create_test_repository().with_connectivity(reporter.clone());
3016 (repo, reporter)
3017 }
3018
3019 #[tokio::test]
3028 async fn test_report_outcome_classifies_server_answered_as_reachable() {
3029 let (repo, reporter) = create_test_repository_with_connectivity();
3030
3031 for err in [
3033 RepoError::Authentication {
3034 message: "401".into(),
3035 },
3036 RepoError::NotFound {
3037 message: "404".into(),
3038 },
3039 RepoError::Server {
3040 message: "500".into(),
3041 },
3042 ] {
3043 reporter.mark_unreachable_for_test().await;
3044 assert!(!reporter.is_reachable().await, "precondition: offline");
3045
3046 let result: Result<(), RepoError> = Err(err);
3047 repo.report_outcome(&result).await;
3048
3049 assert!(
3050 reporter.is_reachable().await,
3051 "a server that answers should be reported reachable"
3052 );
3053 }
3054
3055 reporter.mark_unreachable_for_test().await;
3057 let ok: Result<(), RepoError> = Ok(());
3058 repo.report_outcome(&ok).await;
3059 assert!(reporter.is_reachable().await, "Ok ⇒ reachable");
3060 }
3061
3062 #[tokio::test]
3065 async fn test_report_outcome_ignores_local_errors() {
3066 let (repo, reporter) = create_test_repository_with_connectivity();
3067
3068 reporter.mark_unreachable_for_test().await;
3071 for err in [
3072 RepoError::Database {
3073 message: "cache".into(),
3074 },
3075 RepoError::Offline,
3076 ] {
3077 let result: Result<(), RepoError> = Err(err);
3078 repo.report_outcome(&result).await;
3079 assert!(
3080 !reporter.is_reachable().await,
3081 "local-side error must not change reachability"
3082 );
3083 }
3084 }
3085
3086 #[tokio::test]
3092 async fn test_get_json_fast_fails_when_offline() {
3093 let (repo, reporter) = create_test_repository_with_connectivity();
3094 reporter.mark_unreachable_for_test().await;
3095 assert!(!reporter.is_reachable().await, "precondition: offline");
3096
3097 let result: Result<serde_json::Value, RepoError> = repo.get_json("/System/Info").await;
3098 assert!(
3099 matches!(result, Err(RepoError::Offline)),
3100 "known-offline get_json should return Offline immediately, got {:?}",
3101 result
3102 );
3103 }
3104
3105 #[tokio::test]
3108 async fn test_report_outcome_network_error_is_debounced() {
3109 let (repo, reporter) = create_test_repository_with_connectivity();
3110 assert!(reporter.is_reachable().await, "starts online");
3111
3112 let result: Result<(), RepoError> = Err(RepoError::Network {
3113 message: "timeout".into(),
3114 });
3115 repo.report_outcome(&result).await;
3116
3117 assert!(
3118 reporter.is_reachable().await,
3119 "a single network failure stays online (debounced)"
3120 );
3121 }
3122
3123 #[tokio::test]
3124 async fn test_get_audio_stream_url_formats_correctly() {
3125 let repo = create_test_repository();
3126 let item_id = "test-track-123";
3127
3128 let result = repo.get_audio_stream_url(item_id).await;
3129
3130 assert!(result.is_ok());
3131 let url = result.unwrap();
3132 assert_eq!(
3133 url,
3134 "https://test.server.com/Audio/test-track-123/stream?UserId=test-user-id&api_key=test-access-token&Static=true"
3135 );
3136 }
3137
3138 static QUALITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3144
3145 struct QualityFixture(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
3146
3147 impl QualityFixture {
3148 fn set(quality: StreamingQuality) -> Self {
3149 let guard = QUALITY_LOCK.lock_safe();
3150 set_streaming_quality(quality);
3151 Self(guard)
3152 }
3153 }
3154
3155 impl Drop for QualityFixture {
3156 fn drop(&mut self) {
3157 set_streaming_quality(StreamingQuality::Original);
3158 clear_playback_quality_override();
3162 }
3163 }
3164
3165 #[tokio::test]
3172 async fn test_video_stream_url_applies_bitrate_cap() {
3173 let _fixture = QualityFixture::set(StreamingQuality::Mbps2);
3174 let repo = create_test_repository();
3175
3176 let url = repo
3177 .get_video_stream_url("vid-1", None, None)
3178 .await
3179 .unwrap();
3180
3181 assert!(url.contains("MaxStreamingBitrate=2000000"), "url: {url}");
3182 assert!(url.contains("VideoBitrate=1808000"), "url: {url}");
3185 assert!(url.contains("AudioBitrate=192000"), "url: {url}");
3186 assert!(url.contains("MaxHeight=720"), "url: {url}");
3187 }
3188
3189 #[tokio::test]
3194 async fn test_video_stream_url_uncapped_keeps_legacy_allowance() {
3195 let _fixture = QualityFixture::set(StreamingQuality::Original);
3196 let repo = create_test_repository();
3197
3198 let url = repo
3199 .get_video_stream_url("vid-1", None, None)
3200 .await
3201 .unwrap();
3202
3203 assert!(url.contains("MaxStreamingBitrate=20000000"), "url: {url}");
3204 assert!(url.contains("VideoBitrate=18000000"), "url: {url}");
3205 assert!(url.contains("AudioBitrate=384000"), "url: {url}");
3206 assert!(
3207 !url.contains("MaxHeight"),
3208 "uncapped must not scale the picture down: {url}"
3209 );
3210 }
3211
3212 #[tokio::test]
3217 async fn test_audio_only_stream_url_takes_the_lower_of_cap_and_default() {
3218 {
3219 let _fixture = QualityFixture::set(StreamingQuality::Kbps720);
3220 let repo = create_test_repository();
3221 let url = repo
3222 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
3223 .await
3224 .unwrap();
3225 assert!(url.contains("MaxStreamingBitrate=96000"), "url: {url}");
3226 }
3227
3228 let _fixture = QualityFixture::set(StreamingQuality::Original);
3229 let repo = create_test_repository();
3230 let url = repo
3231 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
3232 .await
3233 .unwrap();
3234 assert!(url.contains("MaxStreamingBitrate=384000"), "url: {url}");
3235 }
3236
3237 #[tokio::test]
3250 async fn test_get_video_stream_url_returns_an_hls_master_playlist() {
3251 let _fixture = QualityFixture::set(StreamingQuality::Original);
3252 let repo = create_test_repository();
3253
3254 let url = repo
3255 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
3256 .await
3257 .unwrap();
3258
3259 assert!(
3260 url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"),
3261 "expected HLS master playlist, got: {url}"
3262 );
3263 assert!(url.contains("VideoCodec=h264"));
3264 assert!(url.contains("MediaSourceId=source-1"));
3265 assert!(url.contains("AudioStreamIndex=1"));
3266 assert!(!url.contains("stream.mp4"));
3267 }
3268
3269 #[tokio::test]
3294 async fn test_video_stream_url_never_carries_start_time_ticks() {
3295 let _fixture = QualityFixture::set(StreamingQuality::Original);
3296 let repo = create_test_repository();
3297
3298 let url = repo
3299 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
3300 .await
3301 .unwrap();
3302
3303 assert!(
3304 !url.contains("StartTimeTicks"),
3305 "an HLS playlist must never carry StartTimeTicks — the server copies it \
3306 onto every segment URI and then rejects each one with 400: {url}"
3307 );
3308 }
3309
3310 #[tokio::test]
3311 async fn test_get_video_stream_url_omits_position_when_absent() {
3312 let _fixture = QualityFixture::set(StreamingQuality::Original);
3313 let repo = create_test_repository();
3314
3315 let url = repo
3316 .get_video_stream_url("vid-1", None, None)
3317 .await
3318 .unwrap();
3319
3320 assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
3321 assert!(!url.contains("StartTimeTicks"));
3322 assert!(!url.contains("MediaSourceId"));
3323 assert!(
3328 !url.contains("AudioStreamIndex"),
3329 "must not pin an audio index when none was chosen: {url}"
3330 );
3331 }
3332
3333 #[tokio::test]
3344 async fn test_video_stream_url_carries_a_play_session_id() {
3345 let _fixture = QualityFixture::set(StreamingQuality::Original);
3346 let repo = create_test_repository();
3347
3348 let url = repo
3349 .get_video_stream_url("vid-1", None, None)
3350 .await
3351 .unwrap();
3352
3353 assert!(
3354 url.contains("PlaySessionId="),
3355 "every transcode must be openable as its own job: {url}"
3356 );
3357 }
3358
3359 #[tokio::test]
3373 async fn test_video_stream_url_asks_for_no_subtitle_stream() {
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", Some("source-1"), Some(1))
3379 .await
3380 .unwrap();
3381
3382 assert!(
3383 url.contains("SubtitleStreamIndex=-1"),
3384 "the stream URL must ask for no subtitle, not leave the choice open: {url}"
3385 );
3386 }
3387
3388 #[test]
3396 fn test_media_streams_carry_whether_the_app_can_render_them() {
3397 let item: JellyfinItem = serde_json::from_value(serde_json::json!({
3398 "Id": "ep-1",
3399 "Name": "Partings",
3400 "Type": "Episode",
3401 "MediaStreams": [
3402 { "Type": "Video", "Index": 0, "Codec": "hevc", "IsDefault": true },
3403 { "Type": "Audio", "Index": 1, "Codec": "eac3", "IsDefault": true },
3404 { "Type": "Subtitle", "Index": 2, "Codec": "PGSSUB", "IsDefault": true },
3405 { "Type": "Subtitle", "Index": 3, "Codec": "subrip", "IsDefault": false },
3406 { "Type": "Subtitle", "Index": 4, "Codec": null, "IsDefault": false },
3407 ],
3408 }))
3409 .expect("fixture must deserialize");
3410
3411 let streams = item.into_media_item("server-1".to_string()).media_streams;
3412 let streams = streams.expect("the item carries streams");
3413 let deliverable = |index: i32| {
3414 streams
3415 .iter()
3416 .find(|s| s.index == index)
3417 .unwrap_or_else(|| panic!("stream {index} missing"))
3418 .supports_external_delivery
3419 };
3420
3421 assert_eq!(deliverable(2), Some(false));
3423 assert_eq!(deliverable(3), Some(true));
3425 assert_eq!(deliverable(4), Some(false));
3428 assert_eq!(deliverable(0), None);
3431 assert_eq!(deliverable(1), None);
3432 }
3433
3434 #[test]
3440 fn test_each_stream_open_gets_a_fresh_session_and_reports_the_previous() {
3441 let _lock = QUALITY_LOCK.lock_safe();
3442
3443 let (first, _) = begin_video_play_session();
3444 let (second, replaced) = begin_video_play_session();
3445
3446 assert_ne!(first, second, "each open needs its own job identity");
3447 assert_eq!(
3448 replaced,
3449 Some(first),
3450 "the open must hand back the job it superseded so it can be stopped"
3451 );
3452
3453 let replaced_by_adoption = adopt_video_play_session("server-named-session".to_string());
3457 assert_eq!(replaced_by_adoption, Some(second));
3458
3459 let (_, after_adoption) = begin_video_play_session();
3460 assert_eq!(
3461 after_adoption,
3462 Some("server-named-session".to_string()),
3463 "the adopted job must be the one the next open stops"
3464 );
3465 }
3466
3467 #[tokio::test]
3468 async fn test_get_audio_only_stream_url_for_video_carries_track_and_position() {
3469 let repo = create_test_repository();
3474
3475 let url = repo
3476 .get_audio_only_stream_url_for_video("vid-1", Some("source-1"), Some(193.0), Some(2))
3477 .await
3478 .unwrap();
3479
3480 assert!(
3481 url.starts_with("https://test.server.com/Audio/vid-1/universal?"),
3482 "expected audio-only universal endpoint, got: {url}"
3483 );
3484 assert!(
3486 !url.contains("/Videos/"),
3487 "url must not hit the video endpoint: {url}"
3488 );
3489 assert!(
3490 !url.contains("master.m3u8"),
3491 "url must not be a video HLS playlist: {url}"
3492 );
3493 assert!(url.contains("AudioStreamIndex=2"));
3494 assert!(url.contains("MediaSourceId=source-1"));
3495 assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
3497 assert!(url.contains("TranscodingProtocol=http"), "url: {url}");
3500 assert!(url.contains("TranscodingContainer=mp3"), "url: {url}");
3501 assert!(
3502 !url.contains("TranscodingProtocol=hls"),
3503 "url must not be HLS: {url}"
3504 );
3505 assert!(!url.contains("Container=ts"), "url must not be ts: {url}");
3506 }
3507
3508 #[tokio::test]
3509 async fn test_get_audio_only_stream_url_for_video_omits_position_when_absent() {
3510 let repo = create_test_repository();
3512
3513 let url = repo
3514 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
3515 .await
3516 .unwrap();
3517
3518 assert!(url.starts_with("https://test.server.com/Audio/vid-1/universal?"));
3519 assert!(!url.contains("StartTimeTicks"));
3520 assert!(!url.contains("MediaSourceId"));
3521 assert!(
3524 !url.contains("AudioStreamIndex"),
3525 "must not pin an audio index when none was chosen: {url}"
3526 );
3527 }
3528
3529 #[tokio::test]
3530 async fn test_get_audio_stream_url_with_special_characters() {
3531 let repo = create_test_repository();
3532 let item_id = "track-with-special-chars-!@#";
3533
3534 let result = repo.get_audio_stream_url(item_id).await;
3535
3536 assert!(result.is_ok());
3537 let url = result.unwrap();
3538 assert!(url.contains("track-with-special-chars-!@#"));
3539 assert!(url.starts_with("https://test.server.com/Audio/"));
3540 }
3541
3542 #[test]
3543 fn test_image_tags_deserialize_hashmap_format() {
3544 let json = r#"{"Primary":"abc123","Banner":"def456","Backdrop":"ghi789"}"#;
3546 let result: Result<ImageTags, _> = serde_json::from_str(json);
3547
3548 assert!(result.is_ok());
3549 let tags = result.unwrap();
3550 assert_eq!(tags.primary(), Some("abc123".to_string()));
3551 }
3552
3553 #[test]
3554 fn test_image_tags_deserialize_structured_format() {
3555 let json = r#"{"Primary":"xyz789"}"#;
3557 let result: Result<ImageTags, _> = serde_json::from_str(json);
3558
3559 assert!(result.is_ok());
3560 let tags = result.unwrap();
3561 assert_eq!(tags.primary(), Some("xyz789".to_string()));
3562 }
3563
3564 #[test]
3565 fn test_image_tags_deserialize_missing_primary() {
3566 let json = r#"{"Banner":"def456","Backdrop":"ghi789"}"#;
3568 let result: Result<ImageTags, _> = serde_json::from_str(json);
3569
3570 assert!(result.is_ok());
3571 let tags = result.unwrap();
3572 assert_eq!(tags.primary(), None);
3573 }
3574
3575 #[test]
3576 fn test_image_tags_deserialize_empty_map() {
3577 let json = r#"{}"#;
3579 let result: Result<ImageTags, _> = serde_json::from_str(json);
3580
3581 assert!(result.is_ok());
3582 let tags = result.unwrap();
3583 assert_eq!(tags.primary(), None);
3584 }
3585
3586 #[test]
3597 fn test_video_download_url_uses_stream_not_download_endpoint() {
3598 let repo = create_test_repository();
3599 let url = repo.get_video_download_url("item123", "original", None, None);
3600
3601 assert!(
3603 !url.contains("/download"),
3604 "download URL must not use the broken /Videos/{{id}}/download endpoint: {url}"
3605 );
3606 assert!(
3608 url.contains("/Videos/item123/stream.mp4"),
3609 "download URL must target /Videos/{{id}}/stream.mp4: {url}"
3610 );
3611 assert!(url.contains("api_key=test-access-token"), "url: {url}");
3612 }
3613
3614 #[test]
3615 fn test_video_download_url_original_is_static_direct_copy() {
3616 let repo = create_test_repository();
3617 let url = repo.get_video_download_url("item123", "original", None, None);
3618
3619 assert!(url.contains("Static=true"), "url: {url}");
3622 assert!(
3623 !url.contains("videoBitRate"),
3624 "original must not transcode: {url}"
3625 );
3626 assert!(
3627 !url.contains("maxHeight"),
3628 "original must not transcode: {url}"
3629 );
3630 }
3631
3632 #[test]
3633 fn test_video_download_url_quality_presets_transcode() {
3634 let repo = create_test_repository();
3635
3636 for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
3637 let url = repo.get_video_download_url("item123", quality, None, None);
3638 assert!(
3639 url.contains("/Videos/item123/stream.mp4"),
3640 "{quality} must use stream.mp4: {url}"
3641 );
3642 assert!(
3643 url.contains("videoBitRate="),
3644 "{quality} must set bitrate: {url}"
3645 );
3646 assert!(
3647 url.contains(&format!("maxHeight={height}")),
3648 "{quality} must cap height at {height}: {url}"
3649 );
3650 assert!(url.contains("videoCodec=h264"), "{quality}: {url}");
3651 assert!(
3653 !url.contains("Static=true"),
3654 "{quality} must not be Static: {url}"
3655 );
3656 }
3657 }
3658
3659 #[test]
3666 fn test_video_download_url_bitrate_params_use_capital_r_spelling() {
3667 let repo = create_test_repository();
3668
3669 for quality in ["high", "medium", "low"] {
3670 let url = repo.get_video_download_url("item123", quality, None, None);
3671
3672 assert!(
3673 url.contains("videoBitRate="),
3674 "{quality} must spell it videoBitRate (capital R): {url}"
3675 );
3676 assert!(
3677 url.contains("audioBitRate="),
3678 "{quality} must spell it audioBitRate (capital R): {url}"
3679 );
3680
3681 assert!(
3684 !url.contains("videoBitrate="),
3685 "{quality} emits the unbindable lowercase-r spelling: {url}"
3686 );
3687 assert!(
3688 !url.contains("audioBitrate="),
3689 "{quality} emits the unbindable lowercase-r spelling: {url}"
3690 );
3691 }
3692 }
3693
3694 #[test]
3700 fn test_video_download_url_transcode_presets_forbid_video_stream_copy() {
3701 let repo = create_test_repository();
3702
3703 for quality in ["high", "medium", "low"] {
3704 let url = repo.get_video_download_url("item123", quality, None, None);
3705 assert!(
3706 url.contains("allowVideoStreamCopy=false"),
3707 "{quality} must forbid video stream copy: {url}"
3708 );
3709 }
3710
3711 let original = repo.get_video_download_url("item123", "original", None, None);
3713 assert!(
3714 !original.contains("allowVideoStreamCopy=false"),
3715 "original must remain a direct copy: {original}"
3716 );
3717 }
3718
3719 #[test]
3732 fn test_video_download_url_original_transcodes_undecodable_audio() {
3733 let repo = create_test_repository();
3734
3735 for codec in ["eac3", "ac3", "dts", "truehd", "EAC3"] {
3736 let url = repo.get_video_download_url("item123", "original", None, Some(codec));
3737 assert!(
3738 !url.contains("Static=true"),
3739 "{codec} cannot be decoded here, so the source must not be copied verbatim: {url}"
3740 );
3741 assert!(
3742 url.contains("audioCodec=aac"),
3743 "{codec} must be re-encoded to aac on the way down: {url}"
3744 );
3745 assert!(
3748 url.contains("allowVideoStreamCopy=true"),
3749 "the video stream must still be copied where possible: {url}"
3750 );
3751 assert!(
3752 !url.contains("videoBitRate") && !url.contains("maxHeight"),
3753 "original must not degrade the picture to fix the audio: {url}"
3754 );
3755 }
3756 }
3757
3758 #[test]
3764 fn test_video_download_url_original_keeps_static_copy_for_playable_audio() {
3765 let repo = create_test_repository();
3766
3767 for codec in ["aac", "mp3", "opus", "vorbis", "flac", "AAC"] {
3768 let url = repo.get_video_download_url("item123", "original", None, Some(codec));
3769 assert!(
3770 url.contains("Static=true"),
3771 "{codec} plays here — the download must stay a direct copy: {url}"
3772 );
3773 assert!(
3774 !url.contains("audioCodec="),
3775 "{codec} needs no transcode: {url}"
3776 );
3777 }
3778
3779 let unknown = repo.get_video_download_url("item123", "original", None, None);
3782 assert!(unknown.contains("Static=true"), "url: {unknown}");
3783 }
3784
3785 #[test]
3790 fn test_video_download_url_presets_ignore_the_audio_policy() {
3791 let repo = create_test_repository();
3792
3793 for quality in ["high", "medium", "low"] {
3794 let with = repo.get_video_download_url("item123", quality, None, Some("eac3"));
3795 let without = repo.get_video_download_url("item123", quality, None, None);
3796 assert_eq!(with, without, "{quality} must not vary with source audio");
3797 assert!(with.contains("audioCodec=aac"), "url: {with}");
3798 }
3799 }
3800
3801 #[test]
3802 fn test_video_download_url_passes_media_source_id() {
3803 let repo = create_test_repository();
3804 let url = repo.get_video_download_url("item123", "original", Some("src-42"), None);
3805 assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
3806 }
3807
3808 #[test]
3809 fn test_jellyfin_item_deserialize_with_image_tags() {
3810 let json = r#"{
3812 "Id": "album123",
3813 "Name": "Test Album",
3814 "Type": "MusicAlbum",
3815 "ImageTags": {"Primary": "tag123"},
3816 "ArtistItems": [
3817 {"Id": "artist1", "Name": "Artist One"},
3818 {"Id": "artist2", "Name": "Artist Two"}
3819 ]
3820 }"#;
3821
3822 let result: Result<JellyfinItem, _> = serde_json::from_str(json);
3823 assert!(result.is_ok());
3824
3825 let item = result.unwrap();
3826 assert_eq!(item.id, "album123");
3827 assert_eq!(item.name, "Test Album");
3828 assert_eq!(item.item_type, "MusicAlbum");
3829 assert!(item.image_tags.is_some());
3830 assert_eq!(
3831 item.image_tags.unwrap().primary(),
3832 Some("tag123".to_string())
3833 );
3834 }
3835
3836 #[test]
3840 fn test_build_favorites_endpoint_scopes_and_filters() {
3841 let movies = build_favorites_endpoint("u1", SearchScope::Movies, None);
3842 assert!(movies.starts_with("/Users/u1/Items?Filters=IsFavorite&Recursive=true"));
3843 assert!(movies.contains("&IncludeItemTypes=Movie"));
3844 assert!(movies.contains("&SortBy=SortName&SortOrder=Ascending"));
3846 assert!(movies.contains("UserData"));
3848
3849 let tv = build_favorites_endpoint("u1", SearchScope::Tv, None);
3851 assert!(tv.contains("&IncludeItemTypes=Series,Episode"));
3852
3853 let music = build_favorites_endpoint("u1", SearchScope::Music, None);
3854 assert!(music.contains("&IncludeItemTypes=MusicAlbum,MusicArtist,Audio,Playlist"));
3855 }
3856
3857 #[test]
3862 fn test_build_favorites_endpoint_all_scope_omits_type_filter() {
3863 let all = build_favorites_endpoint("u1", SearchScope::All, None);
3864 assert!(!all.contains("IncludeItemTypes"));
3865 }
3866
3867 #[test]
3871 fn test_build_favorites_endpoint_honours_paging_and_sort() {
3872 let endpoint = build_favorites_endpoint(
3873 "u1",
3874 SearchScope::All,
3875 Some(&GetItemsOptions {
3876 limit: Some(20),
3877 start_index: Some(40),
3878 sort_by: Some("Random".to_string()),
3879 sort_order: Some("Descending".to_string()),
3880 ..Default::default()
3881 }),
3882 );
3883 assert!(endpoint.contains("&Limit=20"));
3884 assert!(endpoint.contains("&StartIndex=40"));
3885 assert!(endpoint.contains("&SortBy=Random&SortOrder=Descending"));
3886 }
3887
3888 #[test]
3893 fn test_get_items_endpoint_applies_favorites_only() {
3894 let plain = build_get_items_endpoint("u1", "lib-1", None);
3895 assert!(!plain.contains("Filters=IsFavorite"));
3896
3897 let filtered = build_get_items_endpoint(
3898 "u1",
3899 "lib-1",
3900 Some(&GetItemsOptions {
3901 favorites_only: Some(true),
3902 include_item_types: Some(vec!["Movie".to_string()]),
3903 ..Default::default()
3904 }),
3905 );
3906 assert!(filtered.contains("&Filters=IsFavorite"));
3907 assert!(filtered.contains("&IncludeItemTypes=Movie"));
3909 assert!(filtered.contains("ParentId=lib-1"));
3910
3911 let off = build_get_items_endpoint(
3913 "u1",
3914 "lib-1",
3915 Some(&GetItemsOptions {
3916 favorites_only: Some(false),
3917 ..Default::default()
3918 }),
3919 );
3920 assert!(!off.contains("Filters=IsFavorite"));
3921 }
3922
3923 #[test]
3932 fn test_get_items_endpoint_encodes_query_values() {
3933 let endpoint = build_get_items_endpoint(
3934 "u1",
3935 "lib 1&Filters=IsFavorite",
3936 Some(&GetItemsOptions {
3937 include_item_types: Some(vec!["Movie&x=1".to_string()]),
3938 sort_by: Some("Sort Name".to_string()),
3939 sort_order: Some("Ascending&y=2".to_string()),
3940 ..Default::default()
3941 }),
3942 );
3943 assert!(
3944 endpoint.contains("ParentId=lib%201%26Filters%3DIsFavorite"),
3945 "{endpoint}"
3946 );
3947 assert!(
3948 endpoint.contains("&IncludeItemTypes=Movie%26x%3D1"),
3949 "{endpoint}"
3950 );
3951 assert!(endpoint.contains("&SortBy=Sort%20Name"), "{endpoint}");
3952 assert!(
3953 endpoint.contains("&SortOrder=Ascending%26y%3D2"),
3954 "{endpoint}"
3955 );
3956 assert!(!endpoint.contains("&Filters=IsFavorite"), "{endpoint}");
3958 assert!(!endpoint.contains("&x=1"), "{endpoint}");
3959 assert!(!endpoint.contains("&y=2"), "{endpoint}");
3960 }
3961
3962 #[test]
3968 fn test_get_items_endpoint_keeps_list_separators() {
3969 let endpoint = build_get_items_endpoint(
3970 "u1",
3971 "lib-1",
3972 Some(&GetItemsOptions {
3973 sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
3974 include_item_types: Some(vec!["Movie".to_string(), "Series".to_string()]),
3975 ..Default::default()
3976 }),
3977 );
3978 assert!(
3979 endpoint.contains("&SortBy=ParentIndexNumber,IndexNumber,SortName"),
3980 "{endpoint}"
3981 );
3982 assert!(
3983 endpoint.contains("&IncludeItemTypes=Movie,Series"),
3984 "{endpoint}"
3985 );
3986 assert!(endpoint.contains("ParentId=lib-1"), "{endpoint}");
3988 }
3989
3990 #[test]
3997 fn test_latest_items_endpoint_groups_children_into_containers() {
3998 let endpoint = build_latest_items_endpoint("u1", "lib-1", Some(16));
3999
4000 assert!(
4001 endpoint.contains("GroupItems=true"),
4002 "latest items must be grouped so an album counts once, got: {}",
4003 endpoint
4004 );
4005 assert!(endpoint.contains("ParentId=lib-1"));
4006 assert!(endpoint.contains("Limit=16"));
4007 }
4008
4009 #[test]
4018 fn test_build_next_up_endpoint_excludes_resumable() {
4019 let endpoint = build_next_up_endpoint("u1", None, Some(12));
4020
4021 assert!(
4022 endpoint.contains("EnableResumable=false"),
4023 "next up must exclude in-progress episodes, got: {}",
4024 endpoint
4025 );
4026 assert!(endpoint.contains("UserId=u1"));
4027 assert!(endpoint.contains("Limit=12"));
4028 assert!(
4029 !endpoint.contains("SeriesId"),
4030 "no series filter when none was requested, got: {}",
4031 endpoint
4032 );
4033 }
4034
4035 #[test]
4039 fn test_build_next_up_endpoint_scopes_to_series() {
4040 let endpoint = build_next_up_endpoint("u1", Some("series-a"), None);
4041
4042 assert!(endpoint.contains("SeriesId=series-a"));
4043 assert!(endpoint.contains("EnableResumable=false"));
4044 assert!(
4045 endpoint.contains("Limit=16"),
4046 "default limit, got: {}",
4047 endpoint
4048 );
4049 }
4050
4051 #[test]
4058 fn test_jellyfin_item_maps_user_data_favorite() {
4059 let json = r#"{
4060 "Id": "movie123",
4061 "Name": "Test Movie",
4062 "Type": "Movie",
4063 "UserData": {
4064 "PlaybackPositionTicks": 6000000000,
4065 "Played": false,
4066 "IsFavorite": true,
4067 "PlayCount": 2,
4068 "LastPlayedDate": "2026-08-01T12:00:00Z"
4069 }
4070 }"#;
4071
4072 let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
4073 let media = item.into_media_item("server1".to_string());
4074
4075 let user_data = media.user_data.expect("user data should be mapped");
4076 assert_eq!(user_data.is_favorite, Some(true));
4077 assert_eq!(user_data.is_played, Some(false));
4078 assert_eq!(user_data.play_count, Some(2));
4079 assert_eq!(user_data.playback_position_ticks, Some(6_000_000_000));
4080 assert_eq!(user_data.playback_position_ms, Some(600_000));
4082 }
4083
4084 #[test]
4089 fn test_jellyfin_item_without_user_data_maps_to_none() {
4090 let json = r#"{"Id": "x", "Name": "No User Data", "Type": "Movie"}"#;
4091
4092 let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
4093 let media = item.into_media_item("server1".to_string());
4094
4095 assert!(media.user_data.is_none());
4096 }
4097
4098 #[test]
4099 fn test_jellyfin_item_deserialize_with_artist_items() {
4100 let json = r#"{
4102 "Id": "track123",
4103 "Name": "Test Track",
4104 "Type": "Audio",
4105 "ArtistItems": [
4106 {"Id": "artist1", "Name": "Bob Dylan"},
4107 {"Id": "artist2", "Name": "Johnny Cash"}
4108 ]
4109 }"#;
4110
4111 let result: Result<JellyfinItem, _> = serde_json::from_str(json);
4112 assert!(result.is_ok());
4113
4114 let item = result.unwrap();
4115 let artist_items = item.artist_items.expect("Expected artist items");
4116 assert_eq!(artist_items.len(), 2);
4117 assert_eq!(artist_items[0].id, "artist1");
4118 assert_eq!(artist_items[0].name, "Bob Dylan");
4119 assert_eq!(artist_items[1].id, "artist2");
4120 assert_eq!(artist_items[1].name, "Johnny Cash");
4121 }
4122
4123 #[test]
4124 fn test_jellyfin_item_to_media_item_conversion() {
4125 let json = r#"{
4127 "Id": "album456",
4128 "Name": "Love and Theft",
4129 "Type": "MusicAlbum",
4130 "ImageTags": {"Primary": "7ebab4f6a80cd09d"},
4131 "Artists": ["Bob Dylan"],
4132 "ArtistItems": [{"Id": "0b2a6e969a27f22aba97f9f0e69fa849", "Name": "Bob Dylan"}],
4133 "RunTimeTicks": 33900137190
4134 }"#;
4135
4136 let jellyfin_item: JellyfinItem = serde_json::from_str(json).expect("Failed to parse");
4137 let media_item = jellyfin_item.into_media_item("test-server-id".to_string());
4138
4139 assert_eq!(media_item.id, "album456");
4140 assert_eq!(media_item.name, "Love and Theft");
4141 assert_eq!(media_item.item_type, "MusicAlbum");
4142 assert_eq!(
4143 media_item.primary_image_tag,
4144 Some("7ebab4f6a80cd09d".to_string())
4145 );
4146 assert_eq!(media_item.server_id, "test-server-id");
4147 }
4148
4149 #[test]
4150 fn test_items_response_deserialize() {
4151 let json = r#"{
4153 "Items": [
4154 {
4155 "Id": "item1",
4156 "Name": "Item One",
4157 "Type": "MusicAlbum",
4158 "ImageTags": {"Primary": "tag1"}
4159 },
4160 {
4161 "Id": "item2",
4162 "Name": "Item Two",
4163 "Type": "Audio",
4164 "ImageTags": {"Primary": "tag2"}
4165 }
4166 ],
4167 "TotalRecordCount": 2
4168 }"#;
4169
4170 let result: Result<ItemsResponse, _> = serde_json::from_str(json);
4171 assert!(result.is_ok());
4172
4173 let response = result.unwrap();
4174 assert_eq!(response.total_record_count, 2);
4175 assert_eq!(response.items.len(), 2);
4176 assert_eq!(response.items[0].id, "item1");
4177 assert_eq!(response.items[1].id, "item2");
4178 }
4179
4180 #[test]
4181 fn test_search_term_is_url_encoded() {
4182 assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
4186 assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
4187 }
4188
4189 #[test]
4190 fn test_jray_context_deserializes_actors() {
4191 let json = r#"{
4193 "actors": [
4194 { "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
4195 ]
4196 }"#;
4197 let ctx: JRayContext = serde_json::from_str(json).expect("should parse");
4198 assert_eq!(ctx.actors.len(), 1);
4199 assert_eq!(ctx.actors[0].name, "Tom Hanks");
4200 assert_eq!(ctx.actors[0].jellyfin_id, "abc123-guid");
4201 }
4202
4203 #[test]
4204 fn test_jray_context_ignores_unknown_keys_and_missing_ids() {
4205 let json = r#"{
4208 "actors": [ { "name": "Extra" } ],
4209 "locations": ["Beach"],
4210 "trivia": "filmed in 1994"
4211 }"#;
4212 let ctx: JRayContext = serde_json::from_str(json).expect("should tolerate extra keys");
4213 assert_eq!(ctx.actors.len(), 1);
4214 assert_eq!(ctx.actors[0].name, "Extra");
4215 assert_eq!(ctx.actors[0].imdb_id, "");
4216 assert_eq!(ctx.actors[0].jellyfin_id, "");
4217 }
4218
4219 fn source_fixture() -> NegotiatedSource {
4234 NegotiatedSource {
4235 id: "source-1".to_string(),
4236 supports_direct_play: true,
4237 supports_direct_stream: true,
4238 supports_transcoding: true,
4239 transcoding_url: None,
4240 bitrate: Some(6_652_961),
4241 media_streams: Vec::new(),
4242 }
4243 }
4244
4245 #[test]
4251 fn test_a_supported_source_direct_plays() {
4252 let source = source_fixture();
4253 assert_eq!(
4254 decide_playback_kind(&source, false, false),
4255 PlaybackKind::DirectPlay
4256 );
4257 }
4258
4259 #[test]
4264 fn test_a_remuxable_source_direct_streams() {
4265 let source = NegotiatedSource {
4266 supports_direct_play: false,
4267 supports_direct_stream: true,
4268 ..source_fixture()
4269 };
4270 let kind = decide_playback_kind(&source, false, false);
4271 assert_eq!(kind, PlaybackKind::DirectStream);
4272 assert!(
4273 !kind.needs_transcoding(),
4274 "a remux costs no encoder time and must not be reported as transcoding"
4275 );
4276 }
4277
4278 #[test]
4283 fn test_an_unsupported_source_transcodes() {
4284 let source = NegotiatedSource {
4285 supports_direct_play: false,
4286 supports_direct_stream: false,
4287 ..source_fixture()
4288 };
4289 assert_eq!(
4290 decide_playback_kind(&source, false, false),
4291 PlaybackKind::Transcode
4292 );
4293 }
4294
4295 #[test]
4302 fn test_undecodable_audio_overrides_the_servers_direct_play_offer() {
4303 let source = source_fixture();
4304 assert!(source.supports_direct_play, "the server said yes");
4305 assert_eq!(
4306 decide_playback_kind(&source, true, false),
4307 PlaybackKind::Transcode,
4308 "silent direct play is worse than a transcode"
4309 );
4310 }
4311
4312 #[test]
4318 fn test_pinning_an_audio_track_forces_a_transcode() {
4319 let source = source_fixture();
4320 assert_eq!(
4321 decide_playback_kind(&source, false, true),
4322 PlaybackKind::Transcode
4323 );
4324 }
4325
4326 #[test]
4334 fn test_a_ceiling_below_the_source_bitrate_transcodes() {
4335 let source = NegotiatedSource {
4337 supports_direct_play: false,
4338 supports_direct_stream: false,
4339 bitrate: Some(6_652_961),
4340 ..source_fixture()
4341 };
4342 assert_eq!(
4343 decide_playback_kind(&source, false, false),
4344 PlaybackKind::Transcode
4345 );
4346
4347 let options =
4349 crate::repository::stream_selection::quality_options_for_source(Some(6_652_961));
4350 let two_mbps = options
4351 .iter()
4352 .find(|o| o.quality == StreamingQuality::Mbps2)
4353 .expect("2 Mbps is on the ladder");
4354 assert!(!two_mbps.exceeds_source);
4355 }
4356
4357 #[test]
4362 fn test_direct_play_is_preferred_over_direct_stream() {
4363 let source = source_fixture();
4364 assert!(source.supports_direct_play && source.supports_direct_stream);
4365 assert_eq!(
4366 decide_playback_kind(&source, false, false),
4367 PlaybackKind::DirectPlay
4368 );
4369 }
4370
4371 #[test]
4382 fn test_a_playback_override_does_not_disturb_the_device_default() {
4383 let _guard = QUALITY_LOCK.lock_safe();
4384 set_streaming_quality(StreamingQuality::Mbps10);
4385 clear_playback_quality_override();
4386 assert_eq!(effective_streaming_quality(), StreamingQuality::Mbps10);
4387
4388 set_playback_quality_override(StreamingQuality::Kbps720);
4389 assert_eq!(
4390 effective_streaming_quality(),
4391 StreamingQuality::Kbps720,
4392 "the override governs the stream being opened now"
4393 );
4394 assert_eq!(
4395 streaming_quality(),
4396 StreamingQuality::Mbps10,
4397 "but the durable default the Settings screen shows is untouched"
4398 );
4399
4400 clear_playback_quality_override();
4401 assert_eq!(
4402 effective_streaming_quality(),
4403 StreamingQuality::Mbps10,
4404 "and dropping the override returns to it"
4405 );
4406 set_streaming_quality(StreamingQuality::Original);
4407 }
4408
4409 #[test]
4415 fn test_the_override_is_droppable_so_it_cannot_outlive_its_playback() {
4416 let _guard = QUALITY_LOCK.lock_safe();
4417 set_streaming_quality(StreamingQuality::Original);
4418 set_playback_quality_override(StreamingQuality::Mbps1);
4419 assert_eq!(playback_quality_override(), Some(StreamingQuality::Mbps1));
4420
4421 clear_playback_quality_override();
4422 assert_eq!(playback_quality_override(), None);
4423 assert_eq!(effective_streaming_quality(), StreamingQuality::Original);
4424 }
4425}