1use async_trait::async_trait;
4use log::{debug, error, info, warn};
5use serde::{Deserialize, Serialize};
6use std::sync::{Arc, RwLock};
7
8use super::{types::*, MediaRepository};
9use crate::connectivity::ConnectivityReporter;
10use crate::jellyfin::HttpClient;
11use crate::settings::StreamingQuality;
12use crate::utils::lock::RwLockSafe;
13
14static STREAMING_QUALITY: RwLock<StreamingQuality> = RwLock::new(StreamingQuality::Original);
28
29pub fn set_streaming_quality(quality: StreamingQuality) {
37 *STREAMING_QUALITY.write_safe() = quality;
38}
39
40pub fn streaming_quality() -> StreamingQuality {
44 *STREAMING_QUALITY.read_safe()
45}
46
47const DEVICE_ID: &str = "jellytau-tauri";
51
52static VIDEO_PLAY_SESSION: RwLock<Option<String>> = RwLock::new(None);
61
62pub fn begin_video_play_session() -> (String, Option<String>) {
75 let new_session = uuid::Uuid::new_v4().to_string();
76 let mut current = VIDEO_PLAY_SESSION.write_safe();
77 let previous = current.replace(new_session.clone());
78 (new_session, previous)
79}
80
81pub fn adopt_video_play_session(session_id: String) -> Option<String> {
91 VIDEO_PLAY_SESSION.write_safe().replace(session_id)
92}
93
94#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
100pub struct JRayActor {
101 pub name: String,
102 #[serde(default)]
103 pub imdb_id: String,
104 #[serde(default)]
105 pub tmdb_id: String,
106 #[serde(default)]
107 pub jellyfin_id: String,
108}
109
110#[derive(Debug, Clone, Deserialize)]
113struct JRayContext {
114 #[serde(default)]
115 actors: Vec<JRayActor>,
116}
117
118pub struct OnlineRepository {
120 http_client: Arc<HttpClient>,
121 server_url: String,
122 user_id: String,
123 access_token: String,
124 connectivity: Option<ConnectivityReporter>,
128}
129
130impl OnlineRepository {
131 pub fn user_id(&self) -> &str {
134 &self.user_id
135 }
136
137 pub fn new(
138 http_client: Arc<HttpClient>,
139 server_url: String,
140 user_id: String,
141 access_token: String,
142 ) -> Self {
143 Self {
144 http_client,
145 server_url,
146 user_id,
147 access_token,
148 connectivity: None,
149 }
150 }
151
152 pub fn with_connectivity(mut self, reporter: ConnectivityReporter) -> Self {
155 self.connectivity = Some(reporter);
156 self
157 }
158
159 async fn report_outcome<T>(&self, result: &Result<T, RepoError>) {
168 let Some(reporter) = &self.connectivity else {
169 return;
170 };
171
172 match result {
173 Ok(_)
174 | Err(RepoError::Authentication { .. })
175 | Err(RepoError::NotFound { .. })
176 | Err(RepoError::Server { .. }) => {
177 reporter.report_success().await;
178 }
179 Err(RepoError::Network { message }) => {
180 reporter.report_network_failure(Some(message.clone())).await;
181 }
182 Err(RepoError::Database { .. }) | Err(RepoError::Offline) => {
183 }
186 }
187 }
188
189 fn auth_header(&self) -> String {
191 HttpClient::build_auth_header(Some(&self.access_token), "jellytau-device")
192 }
193
194 pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
197 let request = self
198 .http_client
199 .client
200 .get(url)
201 .header("X-Emby-Authorization", self.auth_header())
202 .build()
203 .map_err(|e| format!("Failed to build request: {}", e))?;
204
205 let response = self
206 .http_client
207 .request_with_retry(request)
208 .await
209 .map_err(|e| format!("Download failed: {}", e))?;
210
211 if !response.status().is_success() {
212 let status = response.status();
213 let body = response.text().await.unwrap_or_default();
214 let body_preview = if body.len() > 200 {
215 &body[..200]
216 } else {
217 &body
218 };
219 return Err(format!("HTTP {} ({})", status, body_preview.trim()));
220 }
221
222 response
223 .bytes()
224 .await
225 .map(|b| b.to_vec())
226 .map_err(|e| format!("Failed to read bytes: {}", e))
227 }
228
229 pub async fn get_jray_actors(
234 &self,
235 item_id: &str,
236 t: f64,
237 ) -> Result<Vec<JRayActor>, RepoError> {
238 let endpoint = format!(
239 "/Plugins/JRay/Items/{}/jray?t={}",
240 urlencoding::encode(item_id),
241 t
242 );
243 match self.get_json::<JRayContext>(&endpoint).await {
244 Ok(context) => Ok(context.actors),
245 Err(RepoError::NotFound { .. }) => Ok(Vec::new()),
247 Err(e) => Err(e),
248 }
249 }
250
251 async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
253 if let Some(reporter) = &self.connectivity {
259 if !reporter.is_reachable().await {
260 return Err(RepoError::Offline);
261 }
262 }
263
264 let result = self.get_json_inner(endpoint).await;
265 self.report_outcome(&result).await;
266 result
267 }
268
269 async fn get_json_inner<T: for<'de> Deserialize<'de>>(
270 &self,
271 endpoint: &str,
272 ) -> Result<T, RepoError> {
273 let url = format!("{}{}", self.server_url, endpoint);
274
275 let request = self
276 .http_client
277 .client
278 .get(&url)
279 .header("X-Emby-Authorization", self.auth_header())
280 .build()
281 .map_err(|e| RepoError::Network {
282 message: format!("Failed to build request: {}", e),
283 })?;
284
285 let response = self
286 .http_client
287 .request_with_retry(request)
288 .await
289 .map_err(|e| RepoError::Network {
290 message: e.to_string(),
291 })?;
292
293 if !response.status().is_success() {
294 let status = response.status();
295 if status.as_u16() == 401 || status.as_u16() == 403 {
296 return Err(RepoError::Authentication {
297 message: format!("HTTP {}", status),
298 });
299 } else if status.as_u16() == 404 {
300 return Err(RepoError::NotFound {
301 message: "Resource not found".to_string(),
302 });
303 } else {
304 return Err(RepoError::Server {
305 message: format!("HTTP {}", status),
306 });
307 }
308 }
309
310 let text = response.text().await.map_err(|e| RepoError::Server {
312 message: format!("Failed to read response: {}", e),
313 })?;
314
315 serde_json::from_str(&text).map_err(|e| {
317 error!(
318 "[OnlineRepo] Failed to deserialize {} response: {}",
319 endpoint, e
320 );
321 error!(
322 "[OnlineRepo] Response body (first 1000 chars): {}",
323 if text.len() > 1000 {
324 &text[..1000]
325 } else {
326 &text
327 }
328 );
329 RepoError::Server {
330 message: format!("Failed to parse response: {}", e),
331 }
332 })
333 }
334
335 async fn post_json<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
337 let result = self.post_json_inner(endpoint, body).await;
338 self.report_outcome(&result).await;
339 result
340 }
341
342 async fn post_json_inner<T: Serialize>(
343 &self,
344 endpoint: &str,
345 body: &T,
346 ) -> Result<(), RepoError> {
347 let url = format!("{}{}", self.server_url, endpoint);
348
349 let request = self
350 .http_client
351 .client
352 .post(&url)
353 .header("Content-Type", "application/json")
354 .header("X-Emby-Authorization", self.auth_header())
355 .json(body)
356 .build()
357 .map_err(|e| RepoError::Network {
358 message: format!("Failed to build request: {}", e),
359 })?;
360
361 let response = self
362 .http_client
363 .request_with_retry(request)
364 .await
365 .map_err(|e| RepoError::Network {
366 message: e.to_string(),
367 })?;
368
369 if !response.status().is_success() {
370 let status = response.status();
371 if status.as_u16() == 401 || status.as_u16() == 403 {
372 return Err(RepoError::Authentication {
373 message: format!("HTTP {}", status),
374 });
375 } else {
376 return Err(RepoError::Server {
377 message: format!("HTTP {}", status),
378 });
379 }
380 }
381
382 Ok(())
383 }
384
385 async fn post_json_response<T: Serialize, R: for<'de> Deserialize<'de>>(
387 &self,
388 endpoint: &str,
389 body: &T,
390 ) -> Result<R, RepoError> {
391 let result = self.post_json_response_inner(endpoint, body).await;
392 self.report_outcome(&result).await;
393 result
394 }
395
396 async fn post_json_response_inner<T: Serialize, R: for<'de> Deserialize<'de>>(
397 &self,
398 endpoint: &str,
399 body: &T,
400 ) -> Result<R, RepoError> {
401 let url = format!("{}{}", self.server_url, endpoint);
402
403 if let Ok(json) = serde_json::to_string_pretty(body) {
405 debug!("[HTTP] POST {}", endpoint);
406 debug!("[HTTP] Request body:\n{}", json);
407 }
408
409 let request = self
410 .http_client
411 .client
412 .post(&url)
413 .header("Content-Type", "application/json")
414 .header("X-Emby-Authorization", self.auth_header())
415 .json(body)
416 .build()
417 .map_err(|e| RepoError::Network {
418 message: format!("Failed to build request: {}", e),
419 })?;
420
421 let response = self
422 .http_client
423 .request_with_retry(request)
424 .await
425 .map_err(|e| RepoError::Network {
426 message: e.to_string(),
427 })?;
428
429 if !response.status().is_success() {
430 let status = response.status();
431
432 let error_body = response
434 .text()
435 .await
436 .unwrap_or_else(|_| "Failed to read error body".to_string());
437 error!("[HTTP] Error response ({}): {}", status, error_body);
438
439 if status.as_u16() == 401 || status.as_u16() == 403 {
440 return Err(RepoError::Authentication {
441 message: format!("HTTP {}: {}", status, error_body),
442 });
443 } else if status.as_u16() == 404 {
444 return Err(RepoError::NotFound {
445 message: format!("Resource not found: {}", error_body),
446 });
447 } else {
448 return Err(RepoError::Server {
449 message: format!("HTTP {}: {}", status, error_body),
450 });
451 }
452 }
453
454 response.json().await.map_err(|e| RepoError::Server {
455 message: format!("Failed to parse response: {}", e),
456 })
457 }
458
459 async fn stop_transcode(&self, play_session_id: &str) {
469 let url = format!(
470 "{}/Videos/ActiveEncodings?deviceId={}&playSessionId={}",
471 self.server_url, DEVICE_ID, play_session_id
472 );
473
474 let request = self
475 .http_client
476 .client
477 .delete(&url)
478 .header("X-Emby-Authorization", self.auth_header())
479 .send();
480
481 match request.await {
482 Ok(response) if response.status().is_success() => {
483 debug!("[Transcode] Stopped previous encoding {}", play_session_id);
484 }
485 Ok(response) => {
486 debug!(
487 "[Transcode] Server declined to stop encoding {}: HTTP {}",
488 play_session_id,
489 response.status()
490 );
491 }
492 Err(e) => {
493 debug!(
494 "[Transcode] Could not stop encoding {}: {}",
495 play_session_id, e
496 );
497 }
498 }
499 }
500
501 pub async fn get_video_stream_url(
529 &self,
530 item_id: &str,
531 media_source_id: Option<&str>,
532 audio_stream_index: Option<i32>,
533 ) -> Result<String, RepoError> {
534 let quality = streaming_quality();
535 let max_bitrate = quality.max_bitrate().unwrap_or(20_000_000);
539 let video_bitrate = quality.video_bitrate().unwrap_or(18_000_000);
540
541 let (play_session_id, superseded) = begin_video_play_session();
546 if let Some(previous) = superseded {
547 self.stop_transcode(&previous).await;
548 }
549
550 let mut params = vec![
553 ("api_key", self.access_token.clone()),
554 ("DeviceId", DEVICE_ID.to_string()),
555 ("PlaySessionId", play_session_id),
556 ("VideoCodec", "h264".to_string()),
557 ("AudioCodec", "aac".to_string()),
558 ("MaxStreamingBitrate", max_bitrate.to_string()),
559 ("VideoBitrate", video_bitrate.to_string()),
560 ("AudioBitrate", quality.audio_bitrate().to_string()),
561 (
562 "TranscodingMaxAudioChannels",
563 super::device_profile::max_audio_channels().to_string(),
564 ),
565 ("SegmentContainer", "ts".to_string()),
566 ("TranscodingContainer", "ts".to_string()),
567 ("TranscodingProtocol", "hls".to_string()),
568 (
577 "SubtitleStreamIndex",
578 super::device_profile::playback_subtitle_stream_index().to_string(),
579 ),
580 ];
581
582 if let Some(height) = quality.max_height() {
585 params.push(("MaxHeight", height.to_string()));
586 }
587
588 if let Some(index) = audio_stream_index {
595 params.push(("AudioStreamIndex", index.to_string()));
596 }
597
598 if let Some(source_id) = media_source_id {
599 params.push(("MediaSourceId", source_id.to_string()));
600 }
601
602 let query = params
604 .iter()
605 .map(|(k, v)| format!("{}={}", k, v))
606 .collect::<Vec<_>>()
607 .join("&");
608
609 let url = format!(
610 "{}/Videos/{}/master.m3u8?{}",
611 self.server_url, item_id, query
612 );
613
614 Ok(url)
615 }
616
617 pub async fn build_audio_only_stream_url_for_video(
639 &self,
640 item_id: &str,
641 media_source_id: Option<&str>,
642 start_time_seconds: Option<f64>,
643 audio_stream_index: Option<i32>,
644 ) -> Result<String, RepoError> {
645 let mut params = vec![
646 ("UserId", self.user_id.clone()),
647 ("api_key", self.access_token.clone()),
648 ("DeviceId", DEVICE_ID.to_string()),
649 ("Container", "mp3".to_string()),
651 ("AudioCodec", "mp3".to_string()),
652 ("TranscodingContainer", "mp3".to_string()),
653 ("TranscodingProtocol", "http".to_string()),
654 (
659 "MaxStreamingBitrate",
660 streaming_quality().audio_bitrate().min(384_000).to_string(),
661 ),
662 ];
663
664 if let Some(index) = audio_stream_index {
667 params.push(("AudioStreamIndex", index.to_string()));
668 }
669
670 if let Some(source_id) = media_source_id {
671 params.push(("MediaSourceId", source_id.to_string()));
672 }
673
674 if let Some(seconds) = start_time_seconds {
675 let ticks = (seconds * 10_000_000.0) as i64;
676 params.push(("StartTimeTicks", ticks.to_string()));
677 }
678
679 let query = params
680 .iter()
681 .map(|(k, v)| format!("{}={}", k, v))
682 .collect::<Vec<_>>()
683 .join("&");
684
685 let url = format!("{}/Audio/{}/universal?{}", self.server_url, item_id, query);
686
687 Ok(url)
688 }
689}
690
691#[derive(Debug, Deserialize)]
693#[serde(rename_all = "PascalCase")]
694struct ItemsResponse {
695 items: Vec<JellyfinItem>,
696 total_record_count: usize,
697}
698
699#[derive(Debug, Deserialize)]
701#[serde(rename_all = "PascalCase")]
702struct CreatePlaylistResponse {
703 id: String,
704}
705
706#[derive(Debug, Deserialize)]
708#[serde(rename_all = "PascalCase")]
709#[allow(dead_code)]
710struct PlaylistItemsResponse {
711 items: Vec<JellyfinPlaylistItem>,
712 total_record_count: usize,
713}
714
715#[derive(Debug, Deserialize)]
717#[serde(rename_all = "PascalCase")]
718struct JellyfinPlaylistItem {
719 playlist_item_id: String,
720 #[serde(flatten)]
721 item: JellyfinItem,
722}
723
724#[derive(Debug, Deserialize)]
725#[serde(rename_all = "PascalCase")]
726struct JellyfinItem {
727 id: String,
728 name: String,
729 #[serde(rename = "Type")]
730 item_type: String,
731 #[serde(default)]
732 is_folder: bool,
733 parent_id: Option<String>,
734 overview: Option<String>,
735 genres: Option<Vec<String>>,
736 production_year: Option<i32>,
737 premiere_date: Option<String>,
738 community_rating: Option<f64>,
739 official_rating: Option<String>,
740 run_time_ticks: Option<i64>,
741 image_tags: Option<ImageTags>,
742 backdrop_image_tags: Option<Vec<String>>,
743 parent_backdrop_image_tags: Option<Vec<String>>,
744 album_id: Option<String>,
745 album: Option<String>,
746 album_artist: Option<String>,
747 artists: Option<Vec<String>>,
748 artist_items: Option<Vec<crate::repository::types::ArtistItem>>,
749 index_number: Option<i32>,
750 parent_index_number: Option<i32>,
751 series_id: Option<String>,
752 series_name: Option<String>,
753 season_id: Option<String>,
754 season_name: Option<String>,
755 media_streams: Option<Vec<JellyfinMediaStream>>,
756 media_sources: Option<Vec<JellyfinMediaSource>>,
757 people: Option<Vec<crate::repository::types::Person>>,
758 user_data: Option<JellyfinUserData>,
759}
760
761#[derive(Debug, Deserialize, Clone)]
773#[serde(rename_all = "PascalCase")]
774struct JellyfinUserData {
775 playback_position_ticks: Option<i64>,
776 #[serde(rename = "Played")]
777 is_played: Option<bool>,
778 is_favorite: Option<bool>,
779 play_count: Option<i32>,
780 last_played_date: Option<String>,
781}
782
783impl From<JellyfinUserData> for UserData {
784 fn from(jf: JellyfinUserData) -> Self {
785 UserData {
786 playback_position_ticks: jf.playback_position_ticks,
787 playback_position_ms: jf.playback_position_ticks.map(crate::domain::ticks_to_ms),
788 is_played: jf.is_played,
789 is_favorite: jf.is_favorite,
790 play_count: jf.play_count,
791 last_played_date: jf.last_played_date,
792 playback_context_type: None,
793 playback_context_id: None,
794 }
795 }
796}
797
798fn build_get_items_endpoint(
805 user_id: &str,
806 parent_id: &str,
807 options: Option<&GetItemsOptions>,
808) -> String {
809 let mut endpoint = format!(
816 "/Users/{}/Items?ParentId={}",
817 user_id,
818 urlencoding::encode(parent_id)
819 );
820
821 if let Some(opts) = options {
822 if let Some(limit) = opts.limit {
823 endpoint.push_str(&format!("&Limit={}", limit));
824 }
825 if let Some(start_index) = opts.start_index {
826 endpoint.push_str(&format!("&StartIndex={}", start_index));
827 }
828 if let Some(types) = &opts.include_item_types {
829 let encoded: Vec<String> = types
832 .iter()
833 .map(|t| urlencoding::encode(t).into_owned())
834 .collect();
835 endpoint.push_str(&format!("&IncludeItemTypes={}", encoded.join(",")));
836 }
837 if let Some(sort_by) = &opts.sort_by {
838 let encoded: Vec<String> = sort_by
841 .split(',')
842 .map(|field| urlencoding::encode(field).into_owned())
843 .collect();
844 endpoint.push_str(&format!("&SortBy={}", encoded.join(",")));
845 }
846 if let Some(sort_order) = &opts.sort_order {
847 endpoint.push_str(&format!("&SortOrder={}", urlencoding::encode(sort_order)));
848 }
849 if let Some(recursive) = opts.recursive {
850 endpoint.push_str(&format!("&Recursive={}", recursive));
851 }
852 if let Some(genres) = &opts.genres {
853 if !genres.is_empty() {
854 let encoded: Vec<String> = genres
856 .iter()
857 .map(|g| urlencoding::encode(g).into_owned())
858 .collect();
859 endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
860 }
861 }
862 if opts.favorites_only == Some(true) {
864 endpoint.push_str("&Filters=IsFavorite");
865 }
866 }
867
868 endpoint
872 .push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
873 endpoint
874}
875
876fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
890 format!(
891 "/Users/{}/Items/Latest?ParentId={}&Limit={}&GroupItems=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
892 user_id,
893 parent_id,
894 limit.unwrap_or(16)
895 )
896}
897
898fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
912 let mut endpoint = format!(
913 "/Shows/NextUp?UserId={}&Limit={}&EnableResumable=false&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
914 user_id,
915 limit.unwrap_or(16)
916 );
917
918 if let Some(sid) = series_id {
919 endpoint.push_str(&format!("&SeriesId={}", sid));
920 }
921
922 endpoint
923}
924
925fn build_favorites_endpoint(
935 user_id: &str,
936 scope: SearchScope,
937 options: Option<&GetItemsOptions>,
938) -> String {
939 let mut endpoint = format!("/Users/{}/Items?Filters=IsFavorite&Recursive=true", user_id);
940
941 if let Some(types) = scope.item_types() {
942 endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
943 }
944
945 let sort_by = options
948 .and_then(|o| o.sort_by.as_deref())
949 .unwrap_or("SortName");
950 let sort_order = options
951 .and_then(|o| o.sort_order.as_deref())
952 .unwrap_or("Ascending");
953 endpoint.push_str(&format!("&SortBy={}&SortOrder={}", sort_by, sort_order));
954
955 if let Some(limit) = options.and_then(|o| o.limit) {
956 endpoint.push_str(&format!("&Limit={}", limit));
957 }
958 if let Some(start_index) = options.and_then(|o| o.start_index) {
959 endpoint.push_str(&format!("&StartIndex={}", start_index));
960 }
961
962 endpoint
963 .push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
964 endpoint
965}
966
967#[derive(Debug, Deserialize)]
970#[serde(untagged)]
971enum ImageTags {
972 Map(std::collections::HashMap<String, String>),
974 Structured {
976 #[serde(rename = "Primary")]
977 primary: Option<String>,
978 },
979}
980
981impl ImageTags {
982 fn primary(&self) -> Option<String> {
983 match self {
984 ImageTags::Map(map) => map.get("Primary").cloned(),
985 ImageTags::Structured { primary } => primary.clone(),
986 }
987 }
988}
989
990#[derive(Debug, Deserialize, Clone)]
991#[serde(rename_all = "PascalCase")]
992struct JellyfinMediaStream {
993 #[serde(rename = "Type")]
994 stream_type: String,
995 codec: Option<String>,
996 language: Option<String>,
997 display_title: Option<String>,
998 index: i32,
999 is_default: bool,
1000 #[serde(default)]
1001 is_forced: bool,
1002}
1003
1004#[derive(Debug, Deserialize, Clone)]
1005#[serde(rename_all = "PascalCase")]
1006struct JellyfinMediaSource {
1007 id: String,
1008 name: String,
1009 container: Option<String>,
1010 size: Option<i64>,
1011 bitrate: Option<i32>,
1012 supports_direct_play: bool,
1013 supports_direct_stream: bool,
1014 supports_transcoding: bool,
1015 direct_stream_url: Option<String>,
1016}
1017
1018impl JellyfinItem {
1019 fn into_media_item(self, server_id: String) -> MediaItem {
1020 let primary_tag = self.image_tags.as_ref().and_then(|tags| tags.primary());
1022 let backdrop_tags = self.backdrop_image_tags;
1023
1024 let kind = crate::domain::kind_from_jellyfin(&self.item_type, self.is_folder);
1025
1026 MediaItem {
1027 id: self.id,
1028 name: self.name,
1029 item_type: self.item_type,
1030 kind,
1031 is_folder: self.is_folder,
1032 server_id,
1033 parent_id: self.parent_id,
1034 library_id: None, overview: self.overview,
1036 genres: self.genres,
1037 production_year: self.production_year,
1038 premiere_date: self.premiere_date,
1039 community_rating: self.community_rating,
1040 official_rating: self.official_rating,
1041 runtime_ticks: self.run_time_ticks,
1042 duration_ms: self.run_time_ticks.map(crate::domain::ticks_to_ms),
1043 primary_image_tag: primary_tag.clone(),
1044 image_id: primary_tag,
1045 backdrop_image_tags: backdrop_tags,
1046 parent_backdrop_image_tags: self.parent_backdrop_image_tags,
1047 album_id: self.album_id,
1048 album_name: self.album,
1049 album_artist: self.album_artist,
1050 artists: self.artists,
1051 artist_items: self.artist_items,
1052 index_number: self.index_number,
1053 parent_index_number: self.parent_index_number,
1054 series_id: self.series_id,
1055 series_name: self.series_name,
1056 season_id: self.season_id,
1057 season_name: self.season_name,
1058 user_data: self.user_data.map(UserData::from),
1061 media_streams: self.media_streams.map(|streams| {
1062 streams
1063 .into_iter()
1064 .map(|s| {
1065 let kind = crate::domain::stream_kind_from_jellyfin(&s.stream_type);
1066 let supports_external_delivery =
1070 (kind == crate::domain::StreamKind::Subtitle).then(|| {
1071 super::device_profile::subtitle_supports_external_delivery(
1072 s.codec.as_deref(),
1073 )
1074 });
1075 crate::repository::types::MediaStream {
1076 kind,
1077 stream_type: s.stream_type,
1078 codec: s.codec,
1079 language: s.language,
1080 display_title: s.display_title,
1081 index: s.index,
1082 is_default: s.is_default,
1083 is_forced: s.is_forced,
1084 supports_external_delivery,
1085 }
1086 })
1087 .collect()
1088 }),
1089 media_sources: self.media_sources.map(|sources| {
1090 sources
1091 .into_iter()
1092 .map(|s| crate::repository::types::MediaSource {
1093 id: s.id,
1094 name: s.name,
1095 container: s.container,
1096 size: s.size,
1097 bitrate: s.bitrate,
1098 supports_direct_play: s.supports_direct_play,
1099 supports_direct_stream: s.supports_direct_stream,
1100 supports_transcoding: s.supports_transcoding,
1101 direct_stream_url: s.direct_stream_url,
1102 })
1103 .collect()
1104 }),
1105 people: self.people,
1106 }
1107 }
1108}
1109
1110#[async_trait]
1111impl MediaRepository for OnlineRepository {
1112 async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
1113 #[derive(Debug, Deserialize)]
1114 #[serde(rename_all = "PascalCase")]
1115 struct LibrariesResponse {
1116 items: Vec<JellyfinLibrary>,
1117 }
1118
1119 #[derive(Debug, Deserialize)]
1120 #[serde(rename_all = "PascalCase")]
1121 struct JellyfinLibrary {
1122 id: String,
1123 name: String,
1124 collection_type: Option<String>,
1125 image_tags: Option<ImageTags>,
1126 }
1127
1128 let endpoint = format!("/Users/{}/Views", self.user_id);
1129 let response: LibrariesResponse = self.get_json(&endpoint).await?;
1130
1131 Ok(response
1132 .items
1133 .into_iter()
1134 .map(|lib| {
1135 Library::new(
1136 lib.id,
1137 lib.name,
1138 lib.collection_type.unwrap_or_else(|| "unknown".to_string()),
1139 lib.image_tags.and_then(|tags| tags.primary()),
1140 )
1141 })
1142 .collect())
1143 }
1144
1145 async fn get_items(
1146 &self,
1147 parent_id: &str,
1148 options: Option<GetItemsOptions>,
1149 ) -> Result<SearchResult, RepoError> {
1150 let endpoint = build_get_items_endpoint(&self.user_id, parent_id, options.as_ref());
1151
1152 let response: ItemsResponse = self.get_json(&endpoint).await?;
1153
1154 Ok(SearchResult {
1155 items: response
1156 .items
1157 .into_iter()
1158 .map(|item| item.into_media_item(self.user_id.clone()))
1159 .collect(),
1160 total_record_count: response.total_record_count,
1161 })
1162 }
1163
1164 async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
1176 let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, urlencoding::encode(item_id));
1177
1178 let item: JellyfinItem = self.get_json(&endpoint).await?;
1179 let media_item = item.into_media_item(self.user_id.clone());
1180
1181 Ok(media_item)
1182 }
1183
1184 async fn get_latest_items(
1185 &self,
1186 parent_id: &str,
1187 limit: Option<usize>,
1188 ) -> Result<Vec<MediaItem>, RepoError> {
1189 let endpoint = build_latest_items_endpoint(&self.user_id, parent_id, limit);
1190
1191 let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
1192 Ok(items
1193 .into_iter()
1194 .map(|item| item.into_media_item(self.user_id.clone()))
1195 .collect())
1196 }
1197
1198 async fn get_resume_items(
1207 &self,
1208 parent_id: Option<&str>,
1209 limit: Option<usize>,
1210 ) -> Result<Vec<MediaItem>, RepoError> {
1211 let limit_str = limit.unwrap_or(16);
1212 let mut endpoint = format!(
1213 "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1214 self.user_id, limit_str
1215 );
1216
1217 if let Some(pid) = parent_id {
1218 endpoint.push_str(&format!("&ParentId={}", pid));
1219 }
1220
1221 let response: ItemsResponse = self.get_json(&endpoint).await?;
1222 Ok(response
1223 .items
1224 .into_iter()
1225 .map(|item| item.into_media_item(self.user_id.clone()))
1226 .collect())
1227 }
1228
1229 async fn get_next_up_episodes(
1234 &self,
1235 series_id: Option<&str>,
1236 limit: Option<usize>,
1237 ) -> Result<Vec<MediaItem>, RepoError> {
1238 let endpoint = build_next_up_endpoint(&self.user_id, series_id, limit);
1239
1240 let response: ItemsResponse = self.get_json(&endpoint).await?;
1241 Ok(response
1242 .items
1243 .into_iter()
1244 .map(|item| item.into_media_item(self.user_id.clone()))
1245 .collect())
1246 }
1247
1248 async fn get_recently_played_audio(
1249 &self,
1250 limit: Option<usize>,
1251 ) -> Result<Vec<MediaItem>, RepoError> {
1252 let limit_val = limit.unwrap_or(12);
1253 let fetch_limit = limit_val * 3;
1255 let endpoint = format!(
1256 "/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1257 self.user_id, fetch_limit
1258 );
1259
1260 let response: ItemsResponse = self.get_json(&endpoint).await?;
1261 let items: Vec<MediaItem> = response
1262 .items
1263 .into_iter()
1264 .map(|item| item.into_media_item(self.user_id.clone()))
1265 .collect();
1266
1267 debug!("[get_recently_played_audio] Fetched {} items", items.len());
1268 for item in &items {
1269 debug!("[get_recently_played_audio] Item: name={}, type={}, album_id={:?}, album_name={:?}",
1270 item.name, item.item_type, item.album_id, item.album_name);
1271 }
1272
1273 use std::collections::BTreeMap;
1275 let mut album_map: BTreeMap<String, Vec<MediaItem>> = BTreeMap::new();
1276 let mut ungrouped = Vec::new();
1277
1278 for item in items {
1279 let group_key = item.album_id.clone().or_else(|| item.album_name.clone());
1281
1282 if let Some(key) = group_key {
1283 debug!(
1284 "[get_recently_played_audio] Grouping item '{}' into album '{}'",
1285 item.name, key
1286 );
1287 album_map.entry(key).or_default().push(item);
1288 } else {
1289 debug!(
1290 "[get_recently_played_audio] No album_id or album_name for item: '{}'",
1291 item.name
1292 );
1293 ungrouped.push(item);
1294 }
1295 }
1296
1297 let mut result: Vec<MediaItem> = album_map
1299 .into_iter()
1300 .map(|(album_id, tracks)| {
1301 let first_track = &tracks[0];
1302 let most_recent = tracks
1303 .iter()
1304 .max_by(|a, b| {
1305 let date_a = a
1306 .user_data
1307 .as_ref()
1308 .and_then(|ud| ud.last_played_date.as_deref())
1309 .unwrap_or("");
1310 let date_b = b
1311 .user_data
1312 .as_ref()
1313 .and_then(|ud| ud.last_played_date.as_deref())
1314 .unwrap_or("");
1315 date_b.cmp(date_a)
1316 })
1317 .unwrap_or(first_track);
1318
1319 MediaItem {
1320 id: album_id,
1321 name: first_track
1322 .album_name
1323 .clone()
1324 .unwrap_or_else(|| "Unknown Album".to_string()),
1325 item_type: "MusicAlbum".to_string(),
1326 kind: crate::domain::MediaKind::Album,
1327 is_folder: true,
1328 server_id: first_track.server_id.clone(),
1329 parent_id: None,
1330 library_id: None,
1331 overview: None,
1332 genres: None,
1333 production_year: None,
1334 premiere_date: None,
1335 community_rating: None,
1336 official_rating: None,
1337 runtime_ticks: None,
1338 duration_ms: None,
1339 primary_image_tag: first_track.primary_image_tag.clone(),
1340 image_id: first_track.primary_image_tag.clone(),
1341 backdrop_image_tags: None,
1342 parent_backdrop_image_tags: None,
1343 album_id: None,
1344 album_name: None,
1345 album_artist: None,
1346 artists: first_track.artists.clone(),
1347 artist_items: first_track.artist_items.clone(),
1348 index_number: None,
1349 parent_index_number: None,
1350 series_id: None,
1351 series_name: None,
1352 season_id: None,
1353 season_name: None,
1354 user_data: most_recent.user_data.clone(),
1355 media_streams: None,
1356 media_sources: None,
1357 people: None,
1358 }
1359 })
1360 .collect();
1361
1362 result.extend(ungrouped);
1364
1365 let final_result: Vec<MediaItem> = result.into_iter().take(limit_val).collect();
1367 debug!(
1368 "[get_recently_played_audio] Returning {} items after grouping",
1369 final_result.len()
1370 );
1371 for item in &final_result {
1372 debug!(
1373 "[get_recently_played_audio] Return: name={}, type={}",
1374 item.name, item.item_type
1375 );
1376 }
1377 Ok(final_result)
1378 }
1379
1380 async fn get_rediscover_albums(
1381 &self,
1382 parent_id: Option<&str>,
1383 limit: Option<usize>,
1384 ) -> Result<Vec<MediaItem>, RepoError> {
1385 let limit_val = limit.unwrap_or(12);
1386 let mut endpoint = format!(
1390 "/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1391 self.user_id, limit_val
1392 );
1393
1394 if let Some(pid) = parent_id {
1395 endpoint.push_str(&format!("&ParentId={}", pid));
1396 }
1397
1398 let response: ItemsResponse = self.get_json(&endpoint).await?;
1399 Ok(response
1400 .items
1401 .into_iter()
1402 .map(|item| item.into_media_item(self.user_id.clone()))
1403 .collect())
1404 }
1405
1406 async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
1412 let limit_str = limit.unwrap_or(16);
1413 let endpoint = format!(
1414 "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
1415 self.user_id, limit_str
1416 );
1417
1418 let response: ItemsResponse = self.get_json(&endpoint).await?;
1419 Ok(response
1420 .items
1421 .into_iter()
1422 .map(|item| item.into_media_item(self.user_id.clone()))
1423 .collect())
1424 }
1425
1426 async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
1427 let mut endpoint = format!(
1430 "/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
1431 self.user_id
1432 );
1433
1434 if let Some(pid) = parent_id {
1435 endpoint.push_str(&format!("&ParentId={}", pid));
1436 }
1437
1438 #[derive(Debug, Deserialize)]
1439 #[serde(rename_all = "PascalCase")]
1440 struct GenresResponse {
1441 items: Vec<JellyfinGenre>,
1442 }
1443
1444 #[derive(Debug, Deserialize)]
1445 #[serde(rename_all = "PascalCase")]
1446 struct JellyfinGenre {
1447 id: String,
1448 name: String,
1449 album_count: Option<u32>,
1455 child_count: Option<u32>,
1456 }
1457
1458 let response: GenresResponse = self.get_json(&endpoint).await?;
1459 let genres: Vec<Genre> = response
1460 .items
1461 .into_iter()
1462 .map(|g| Genre {
1463 id: g.id,
1464 name: g.name,
1465 album_count: g.album_count.or(g.child_count),
1466 })
1467 .collect();
1468
1469 let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
1470 log::warn!(
1473 "get_genres: {} genres, {} carry counts. sample: {:?}",
1474 genres.len(),
1475 with_counts,
1476 genres
1477 .iter()
1478 .take(8)
1479 .map(|g| (g.name.as_str(), g.album_count))
1480 .collect::<Vec<_>>()
1481 );
1482
1483 Ok(genres)
1484 }
1485
1486 async fn search(
1495 &self,
1496 query: &str,
1497 options: Option<SearchOptions>,
1498 ) -> Result<SearchResult, RepoError> {
1499 let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50);
1500 let mut endpoint = format!(
1504 "/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
1505 self.user_id,
1506 urlencoding::encode(query),
1507 limit
1508 );
1509
1510 if let Some(opts) = options {
1511 if let Some(types) = opts.include_item_types {
1512 let encoded_types = types
1513 .iter()
1514 .map(|t| urlencoding::encode(t).into_owned())
1515 .collect::<Vec<_>>()
1516 .join(",");
1517 endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
1518 }
1519 }
1520
1521 endpoint.push_str(
1524 "&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData",
1525 );
1526
1527 let response: ItemsResponse = self.get_json(&endpoint).await?;
1528 Ok(SearchResult {
1529 items: response
1530 .items
1531 .into_iter()
1532 .map(|item| item.into_media_item(self.user_id.clone()))
1533 .collect(),
1534 total_record_count: response.total_record_count,
1535 })
1536 }
1537
1538 async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
1539 let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
1540
1541 #[derive(Debug, Serialize)]
1542 #[serde(rename_all = "PascalCase")]
1543 struct PlaybackInfoRequest {
1544 user_id: String,
1545 #[serde(skip_serializing_if = "Option::is_none")]
1549 audio_stream_index: Option<i32>,
1550 #[serde(skip_serializing_if = "Option::is_none")]
1551 subtitle_stream_index: Option<i32>,
1552 start_time_ticks: i64,
1553 is_playback: bool,
1554 auto_open_live_stream: bool,
1555 max_streaming_bitrate: i64,
1556 #[serde(skip_serializing_if = "Option::is_none")]
1557 device_profile: Option<DeviceProfile>,
1558 }
1559
1560 #[derive(Debug, Serialize)]
1561 #[serde(rename_all = "PascalCase")]
1562 struct DeviceProfile {
1563 name: String,
1564 max_streaming_bitrate: i64,
1565 max_static_bitrate: i64,
1566 max_audio_channels: String,
1570 direct_play_profiles: Vec<DirectPlayProfile>,
1571 transcoding_profiles: Vec<TranscodingProfile>,
1572 subtitle_profiles: Vec<SubtitleProfile>,
1573 }
1574
1575 #[derive(Debug, Serialize)]
1576 #[serde(rename_all = "PascalCase")]
1577 struct DirectPlayProfile {
1578 #[serde(rename = "Type")]
1579 profile_type: String,
1580 container: String,
1581 #[serde(skip_serializing_if = "Option::is_none")]
1582 video_codec: Option<String>,
1583 audio_codec: String,
1584 }
1585
1586 #[derive(Debug, Serialize)]
1587 #[serde(rename_all = "PascalCase")]
1588 struct TranscodingProfile {
1589 #[serde(rename = "Type")]
1590 profile_type: String,
1591 context: String,
1592 protocol: String,
1593 container: String,
1594 #[serde(skip_serializing_if = "Option::is_none")]
1595 video_codec: Option<String>,
1596 audio_codec: String,
1597 max_audio_channels: String,
1598 }
1599
1600 #[derive(Debug, Serialize)]
1601 #[serde(rename_all = "PascalCase")]
1602 struct SubtitleProfile {
1603 format: String,
1604 method: String,
1605 }
1606
1607 #[derive(Debug, Deserialize)]
1608 #[serde(rename_all = "PascalCase")]
1609 struct PlaybackInfoResponse {
1610 media_sources: Vec<MediaSource>,
1611 play_session_id: String,
1612 }
1613
1614 #[derive(Debug, Deserialize)]
1615 #[serde(rename_all = "PascalCase")]
1616 struct MediaSource {
1617 id: String,
1618 supports_direct_play: bool,
1619 supports_transcoding: bool,
1620 transcoding_url: Option<String>,
1621 #[serde(default)]
1622 media_streams: Vec<MediaStream>,
1623 }
1624
1625 #[derive(Debug, Deserialize)]
1626 #[serde(rename_all = "PascalCase")]
1627 struct MediaStream {
1628 #[serde(rename = "Type")]
1629 stream_type: String,
1630 #[serde(default)]
1631 index: i32,
1632 #[serde(default)]
1633 codec: Option<String>,
1634 #[serde(default)]
1636 is_default: bool,
1637 }
1638
1639 #[cfg(target_os = "android")]
1641 let (video_codecs, audio_codecs) = crate::player::get_detected_codecs()
1642 .map(|(video, audio, _channels)| (video, audio))
1643 .unwrap_or_else(|| {
1644 warn!("[DeviceProfile] Codec detection not complete, using conservative defaults");
1645 ("h264,hevc".to_string(), "aac,mp3".to_string())
1646 });
1647
1648 #[cfg(all(not(target_os = "android"), target_os = "linux"))]
1655 let (video_codecs, audio_codecs) =
1656 ("h264".to_string(), "aac,mp3,opus,vorbis,flac".to_string());
1657
1658 #[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
1659 let (video_codecs, audio_codecs) = (
1660 "h264,hevc,vp8,vp9,av1,mpeg4".to_string(),
1661 "aac,mp3,opus,vorbis,flac".to_string(),
1662 );
1663
1664 let video_audio_codecs = super::device_profile::video_audio_codecs(&audio_codecs);
1670
1671 info!("[DeviceProfile] Using video codecs: {}", video_codecs);
1672 info!("[DeviceProfile] Using audio codecs: {}", audio_codecs);
1673 info!(
1674 "[DeviceProfile] Audio codecs for video direct play: {}",
1675 video_audio_codecs
1676 );
1677
1678 let max_audio_channels = super::device_profile::max_audio_channels().to_string();
1682 info!("[DeviceProfile] Max audio channels: {}", max_audio_channels);
1683
1684 let quality = streaming_quality();
1693 let negotiated_bitrate = quality.max_bitrate().unwrap_or(999_999_999) as i64;
1694 if let Some(cap) = quality.max_bitrate() {
1695 info!(
1696 "[DeviceProfile] Streaming quality cap active: {} ({} bps)",
1697 quality.label(),
1698 cap
1699 );
1700 }
1701
1702 let device_profile = DeviceProfile {
1704 name: "JellyTau Native Player".to_string(),
1705 max_streaming_bitrate: negotiated_bitrate,
1706 max_static_bitrate: negotiated_bitrate,
1707 max_audio_channels: max_audio_channels.clone(),
1708 direct_play_profiles: vec![
1709 DirectPlayProfile {
1710 profile_type: "Video".to_string(),
1711 container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp".to_string(),
1712 video_codec: Some(video_codecs.clone()),
1713 audio_codec: video_audio_codecs.clone(),
1715 },
1716 DirectPlayProfile {
1717 profile_type: "Audio".to_string(),
1718 container: "mp3,aac,flac,alac,wav,ogg,wma,opus".to_string(),
1719 video_codec: None,
1720 audio_codec: audio_codecs.clone(),
1724 },
1725 ],
1726 transcoding_profiles: vec![
1727 TranscodingProfile {
1728 profile_type: "Video".to_string(),
1729 context: "Streaming".to_string(),
1730 protocol: "hls".to_string(),
1731 container: "ts".to_string(),
1732 video_codec: Some("h264,hevc".to_string()),
1733 audio_codec: "aac,mp3".to_string(),
1734 max_audio_channels: max_audio_channels.clone(),
1735 },
1736 TranscodingProfile {
1737 profile_type: "Audio".to_string(),
1738 context: "Streaming".to_string(),
1739 protocol: "http".to_string(),
1740 container: "mp3".to_string(),
1741 video_codec: None,
1742 audio_codec: "mp3".to_string(),
1743 max_audio_channels: max_audio_channels.clone(),
1744 },
1745 ],
1746 subtitle_profiles: super::device_profile::subtitle_profiles()
1747 .into_iter()
1748 .map(|(format, method)| SubtitleProfile {
1749 format: format.to_string(),
1750 method: method.to_string(),
1751 })
1752 .collect(),
1753 };
1754
1755 let request_body = PlaybackInfoRequest {
1757 user_id: self.user_id.clone(),
1758 audio_stream_index: None, subtitle_stream_index: Some(super::device_profile::playback_subtitle_stream_index()),
1767 start_time_ticks: 0,
1768 is_playback: true,
1769 auto_open_live_stream: true,
1770 max_streaming_bitrate: quality.max_bitrate().unwrap_or(20_000_000) as i64,
1772 device_profile: Some(device_profile), };
1774
1775 let response: PlaybackInfoResponse =
1776 self.post_json_response(&endpoint, &request_body).await?;
1777 let source = response.media_sources.first().ok_or(RepoError::NotFound {
1778 message: "No media sources available".to_string(),
1779 })?;
1780
1781 info!(
1783 "PlaybackInfo MediaSource has {} streams",
1784 source.media_streams.len()
1785 );
1786 for stream in &source.media_streams {
1787 info!(
1788 " Stream type={}, index={}, codec={:?}",
1789 stream.stream_type, stream.index, stream.codec
1790 );
1791 }
1792
1793 for stream in &source.media_streams {
1798 if stream.stream_type == "Subtitle" {
1799 if let Some(codec) = stream.codec.as_deref() {
1800 if super::device_profile::subtitle_forces_burn_in(codec) {
1801 info!(
1802 " 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)",
1803 stream.index, codec
1804 );
1805 }
1806 }
1807 }
1808 }
1809
1810 let audio_streams: Vec<(Option<&str>, bool)> = source
1816 .media_streams
1817 .iter()
1818 .filter(|stream| stream.stream_type == "Audio")
1819 .map(|stream| (stream.codec.as_deref(), stream.is_default))
1820 .collect();
1821 let audio_forces_transcode = super::device_profile::audio_forces_transcode(&audio_streams);
1822
1823 let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
1825 if let Some(previous) = adopt_video_play_session(response.play_session_id.clone()) {
1829 self.stop_transcode(&previous).await;
1830 }
1831 format!(
1837 "{}{}",
1838 self.server_url,
1839 super::device_profile::without_server_chosen_subtitle(transcoding_url)
1840 )
1841 } else if audio_forces_transcode {
1842 warn!(
1843 "[PlaybackInfo] Server offered direct play for audio the webview cannot decode ({:?}) — forcing an HLS transcode",
1844 audio_streams.first().and_then(|(codec, _)| *codec)
1845 );
1846 self.get_video_stream_url(item_id, Some(&source.id), None)
1847 .await?
1848 } else {
1849 format!(
1853 "{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&userId={}",
1854 self.server_url,
1855 item_id,
1856 source.id,
1857 self.access_token,
1858 self.user_id
1859 )
1860 };
1861
1862 info!("Final stream URL: {}", stream_url);
1863
1864 Ok(PlaybackInfo {
1865 media_source_id: source.id.clone(),
1866 play_session_id: response.play_session_id,
1867 stream_url,
1868 direct_play: source.supports_direct_play && !audio_forces_transcode,
1869 needs_transcoding: audio_forces_transcode
1870 || (!source.supports_direct_play && source.supports_transcoding),
1871 })
1872 }
1873
1874 async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
1875 let url = format!(
1877 "{}/Audio/{}/stream?UserId={}&api_key={}&Static=true",
1878 self.server_url, item_id, self.user_id, self.access_token
1879 );
1880 Ok(url)
1881 }
1882
1883 async fn get_audio_only_stream_url_for_video(
1884 &self,
1885 item_id: &str,
1886 media_source_id: Option<&str>,
1887 start_time_seconds: Option<f64>,
1888 audio_stream_index: Option<i32>,
1889 ) -> Result<String, RepoError> {
1890 self.build_audio_only_stream_url_for_video(
1891 item_id,
1892 media_source_id,
1893 start_time_seconds,
1894 audio_stream_index,
1895 )
1896 .await
1897 }
1898
1899 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
1900 let endpoint = format!(
1903 "/LiveTv/Channels?UserId={}&Fields=PrimaryImageAspectRatio,Overview&EnableImageTypes=Primary",
1904 self.user_id
1905 );
1906 let response: ItemsResponse = self.get_json(&endpoint).await?;
1907 Ok(response
1908 .items
1909 .into_iter()
1910 .map(|item| item.into_media_item(self.server_url.clone()))
1911 .collect())
1912 }
1913
1914 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
1915 let endpoint = format!("/Channels?UserId={}", self.user_id);
1918 let response: ItemsResponse = self.get_json(&endpoint).await?;
1919 let total = response.total_record_count;
1920 let items = response
1921 .items
1922 .into_iter()
1923 .map(|item| item.into_media_item(self.server_url.clone()))
1924 .collect();
1925 Ok(SearchResult {
1926 items,
1927 total_record_count: total,
1928 })
1929 }
1930
1931 async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
1932 #[derive(Debug, Serialize)]
1936 #[serde(rename_all = "PascalCase")]
1937 struct OpenLiveStreamRequest {
1938 user_id: String,
1939 #[serde(rename = "AutoOpenLiveStream")]
1940 auto_open_live_stream: bool,
1941 is_playback: bool,
1942 max_streaming_bitrate: u64,
1943 subtitle_stream_index: i32,
1950 }
1951
1952 #[derive(Debug, Deserialize)]
1953 #[serde(rename_all = "PascalCase")]
1954 struct OpenLiveStreamResponse {
1955 #[serde(default)]
1956 media_sources: Vec<LiveMediaSource>,
1957 play_session_id: Option<String>,
1958 }
1959
1960 #[derive(Debug, Deserialize)]
1961 #[serde(rename_all = "PascalCase")]
1962 struct LiveMediaSource {
1963 id: String,
1964 transcoding_url: Option<String>,
1965 live_stream_id: Option<String>,
1966 }
1967
1968 let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
1969 let request = OpenLiveStreamRequest {
1970 user_id: self.user_id.clone(),
1971 auto_open_live_stream: true,
1972 is_playback: true,
1973 max_streaming_bitrate: streaming_quality().max_bitrate().unwrap_or(20_000_000),
1977 subtitle_stream_index: super::device_profile::playback_subtitle_stream_index(),
1978 };
1979
1980 let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?;
1981
1982 let source = response
1983 .media_sources
1984 .into_iter()
1985 .next()
1986 .ok_or(RepoError::NotFound {
1987 message: "No live media source returned".to_string(),
1988 })?;
1989
1990 let stream_url = match source.transcoding_url {
1993 Some(url) => format!(
1996 "{}{}",
1997 self.server_url,
1998 super::device_profile::without_server_chosen_subtitle(&url)
1999 ),
2000 None => format!(
2001 "{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts&SubtitleStreamIndex={}",
2002 self.server_url,
2003 item_id,
2004 self.access_token,
2005 source.id,
2006 source.live_stream_id.clone().unwrap_or_default(),
2007 super::device_profile::playback_subtitle_stream_index(),
2008 ),
2009 };
2010
2011 Ok(LiveStreamInfo {
2012 stream_url,
2013 play_session_id: response.play_session_id,
2014 live_stream_id: source.live_stream_id,
2015 media_source_id: Some(source.id),
2016 })
2017 }
2018
2019 async fn report_playback_start(
2020 &self,
2021 item_id: &str,
2022 position_ticks: i64,
2023 ) -> Result<(), RepoError> {
2024 #[derive(Serialize)]
2025 #[serde(rename_all = "PascalCase")]
2026 struct PlaybackStartRequest {
2027 item_id: String,
2028 position_ticks: i64,
2029 play_command: String,
2030 is_paused: bool,
2031 }
2032
2033 let request = PlaybackStartRequest {
2034 item_id: item_id.to_string(),
2035 position_ticks,
2036 play_command: "PlayNow".to_string(),
2037 is_paused: false,
2038 };
2039
2040 self.post_json("/Sessions/Playing", &request).await
2041 }
2042
2043 async fn report_playback_progress(
2044 &self,
2045 item_id: &str,
2046 position_ticks: i64,
2047 ) -> Result<(), RepoError> {
2048 #[derive(Serialize)]
2049 #[serde(rename_all = "PascalCase")]
2050 struct PlaybackProgressRequest {
2051 item_id: String,
2052 position_ticks: i64,
2053 is_paused: bool,
2054 }
2055
2056 let request = PlaybackProgressRequest {
2057 item_id: item_id.to_string(),
2058 position_ticks,
2059 is_paused: false,
2060 };
2061
2062 self.post_json("/Sessions/Playing/Progress", &request).await
2063 }
2064
2065 async fn report_playback_stopped(
2066 &self,
2067 item_id: &str,
2068 position_ticks: i64,
2069 ) -> Result<(), RepoError> {
2070 #[derive(Serialize)]
2071 #[serde(rename_all = "PascalCase")]
2072 struct PlaybackStoppedRequest {
2073 item_id: String,
2074 position_ticks: i64,
2075 }
2076
2077 let request = PlaybackStoppedRequest {
2078 item_id: item_id.to_string(),
2079 position_ticks,
2080 };
2081
2082 self.post_json("/Sessions/Playing/Stopped", &request).await
2083 }
2084
2085 fn get_image_url(
2086 &self,
2087 item_id: &str,
2088 image_type: ImageType,
2089 options: Option<ImageOptions>,
2090 ) -> String {
2091 let mut url = format!(
2092 "{}/Items/{}/Images/{}",
2093 self.server_url,
2094 item_id,
2095 image_type.as_str()
2096 );
2097
2098 let mut params: Vec<String> = Vec::new();
2102
2103 if let Some(opts) = options {
2104 if let Some(width) = opts.max_width {
2105 params.push(format!("maxWidth={}", width));
2106 }
2107 if let Some(height) = opts.max_height {
2108 params.push(format!("maxHeight={}", height));
2109 }
2110 if let Some(quality) = opts.quality {
2111 params.push(format!("quality={}", quality));
2112 }
2113 if let Some(tag) = opts.tag {
2114 params.push(format!("tag={}", tag));
2115 }
2116 }
2117
2118 if !params.is_empty() {
2119 url.push('?');
2120 url.push_str(¶ms.join("&"));
2121 }
2122
2123 url
2124 }
2125
2126 fn get_subtitle_url(
2127 &self,
2128 item_id: &str,
2129 media_source_id: &str,
2130 stream_index: i32,
2131 format: &str,
2132 ) -> String {
2133 format!(
2134 "{}/Videos/{}/{}/Subtitles/{}/{}",
2135 self.server_url, item_id, media_source_id, stream_index, format
2136 )
2137 }
2138
2139 fn get_video_download_url(
2141 &self,
2142 item_id: &str,
2143 quality: &str,
2144 media_source_id: Option<&str>,
2145 source_audio_codec: Option<&str>,
2146 ) -> String {
2147 let mut url = format!("{}/Videos/{}/stream.mp4", self.server_url, item_id);
2153 let mut params = vec![format!("api_key={}", self.access_token)];
2154
2155 match quality {
2172 "high" => {
2173 params.push("videoBitRate=8000000".to_string());
2174 params.push("maxHeight=1080".to_string());
2175 params.push("audioBitRate=384000".to_string());
2176 params.push("videoCodec=h264".to_string());
2177 params.push("audioCodec=aac".to_string());
2178 params.push("allowVideoStreamCopy=false".to_string());
2179 }
2180 "medium" => {
2181 params.push("videoBitRate=4000000".to_string());
2182 params.push("maxHeight=720".to_string());
2183 params.push("audioBitRate=256000".to_string());
2184 params.push("videoCodec=h264".to_string());
2185 params.push("audioCodec=aac".to_string());
2186 params.push("allowVideoStreamCopy=false".to_string());
2187 }
2188 "low" => {
2189 params.push("videoBitRate=1500000".to_string());
2190 params.push("maxHeight=480".to_string());
2191 params.push("audioBitRate=128000".to_string());
2192 params.push("videoCodec=h264".to_string());
2193 params.push("audioCodec=aac".to_string());
2194 params.push("allowVideoStreamCopy=false".to_string());
2195 }
2196 _ => match source_audio_codec {
2218 Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
2219 params.push("videoCodec=h264".to_string());
2220 params.push("allowVideoStreamCopy=true".to_string());
2221 params.push("audioCodec=aac".to_string());
2222 params.push("audioBitRate=384000".to_string());
2223 }
2224 _ => params.push("Static=true".to_string()),
2228 },
2229 }
2230
2231 if let Some(source_id) = media_source_id {
2233 params.push(format!("mediaSourceId={}", source_id));
2234 }
2235
2236 url.push('?');
2237 url.push_str(¶ms.join("&"));
2238
2239 url
2240 }
2241
2242 async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
2243 let endpoint = format!(
2244 "/Users/{}/FavoriteItems/{}",
2245 self.user_id,
2246 urlencoding::encode(item_id)
2247 );
2248 self.post_json(&endpoint, &serde_json::json!({})).await
2249 }
2250
2251 async fn get_favorites(
2253 &self,
2254 scope: SearchScope,
2255 options: Option<GetItemsOptions>,
2256 ) -> Result<SearchResult, RepoError> {
2257 let endpoint = build_favorites_endpoint(&self.user_id, scope, options.as_ref());
2258 let response: ItemsResponse = self.get_json(&endpoint).await?;
2259
2260 Ok(SearchResult {
2261 items: response
2262 .items
2263 .into_iter()
2264 .map(|item| item.into_media_item(self.user_id.clone()))
2265 .collect(),
2266 total_record_count: response.total_record_count,
2267 })
2268 }
2269
2270 async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
2277 let endpoint = format!(
2278 "/Users/{}/FavoriteItems/{}",
2279 self.user_id,
2280 urlencoding::encode(item_id)
2281 );
2282 let url = format!("{}{}", self.server_url, endpoint);
2283
2284 let result = async {
2285 let request = self
2286 .http_client
2287 .client
2288 .delete(&url)
2289 .header("X-Emby-Authorization", self.auth_header())
2290 .build()
2291 .map_err(|e| RepoError::Network {
2292 message: format!("Failed to build request: {}", e),
2293 })?;
2294
2295 let response = self
2296 .http_client
2297 .request_with_retry(request)
2298 .await
2299 .map_err(|e| RepoError::Network {
2300 message: e.to_string(),
2301 })?;
2302
2303 if !response.status().is_success() {
2304 return Err(RepoError::Server {
2305 message: format!("HTTP {}", response.status()),
2306 });
2307 }
2308
2309 Ok(())
2310 }
2311 .await;
2312
2313 self.report_outcome(&result).await;
2314 result
2315 }
2316
2317 async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
2323 let endpoint = format!(
2324 "/Users/{}/PlayedItems/{}",
2325 self.user_id,
2326 urlencoding::encode(item_id)
2327 );
2328 let url = format!("{}{}", self.server_url, endpoint);
2329
2330 let result = async {
2331 let request = self
2332 .http_client
2333 .client
2334 .delete(&url)
2335 .header("X-Emby-Authorization", self.auth_header())
2336 .build()
2337 .map_err(|e| RepoError::Network {
2338 message: format!("Failed to build request: {}", e),
2339 })?;
2340
2341 let response = self
2342 .http_client
2343 .request_with_retry(request)
2344 .await
2345 .map_err(|e| RepoError::Network {
2346 message: e.to_string(),
2347 })?;
2348
2349 if !response.status().is_success() {
2350 return Err(RepoError::Server {
2351 message: format!("HTTP {}", response.status()),
2352 });
2353 }
2354
2355 Ok(())
2356 }
2357 .await;
2358
2359 self.report_outcome(&result).await;
2360 result
2361 }
2362
2363 async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
2368 let endpoint = format!(
2369 "/Users/{}/PlayedItems/{}",
2370 self.user_id,
2371 urlencoding::encode(item_id)
2372 );
2373 let url = format!("{}{}", self.server_url, endpoint);
2374
2375 let result = async {
2376 let request = self
2377 .http_client
2378 .client
2379 .post(&url)
2380 .header("X-Emby-Authorization", self.auth_header())
2381 .header("Content-Length", "0")
2382 .build()
2383 .map_err(|e| RepoError::Network {
2384 message: format!("Failed to build request: {}", e),
2385 })?;
2386
2387 let response = self
2388 .http_client
2389 .request_with_retry(request)
2390 .await
2391 .map_err(|e| RepoError::Network {
2392 message: e.to_string(),
2393 })?;
2394
2395 if !response.status().is_success() {
2396 return Err(RepoError::Server {
2397 message: format!("HTTP {}", response.status()),
2398 });
2399 }
2400
2401 Ok(())
2402 }
2403 .await;
2404
2405 self.report_outcome(&result).await;
2406 result
2407 }
2408
2409 async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
2417 let endpoint = format!(
2418 "/Users/{}/Items/{}",
2419 self.user_id,
2420 urlencoding::encode(person_id)
2421 );
2422 let item: JellyfinItem = self.get_json(&endpoint).await?;
2423 Ok(item.into_media_item(self.user_id.clone()))
2424 }
2425
2426 async fn get_items_by_person(
2430 &self,
2431 person_id: &str,
2432 options: Option<GetItemsOptions>,
2433 ) -> Result<SearchResult, RepoError> {
2434 let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
2435
2436 let mut endpoint = format!(
2437 "/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
2438 self.user_id, person_id, limit
2439 );
2440
2441 if let Some(ref opts) = options {
2443 if let Some(ref include_types) = opts.include_item_types {
2444 if !include_types.is_empty() {
2445 let types_param = include_types.join(",");
2446 endpoint.push_str(&format!("&IncludeItemTypes={}", types_param));
2447 }
2448 }
2449 }
2450
2451 let response: ItemsResponse = self.get_json(&endpoint).await?;
2452 Ok(SearchResult {
2453 items: response
2454 .items
2455 .into_iter()
2456 .map(|item| item.into_media_item(self.user_id.clone()))
2457 .collect(),
2458 total_record_count: response.total_record_count,
2459 })
2460 }
2461
2462 async fn get_similar_items(
2463 &self,
2464 item_id: &str,
2465 limit: Option<usize>,
2466 ) -> Result<SearchResult, RepoError> {
2467 let limit_str = limit.unwrap_or(20);
2468
2469 let endpoint = format!(
2471 "/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
2472 item_id, self.user_id, limit_str
2473 );
2474
2475 let response: ItemsResponse = self.get_json(&endpoint).await?;
2476 Ok(SearchResult {
2477 items: response
2478 .items
2479 .into_iter()
2480 .map(|item| item.into_media_item(self.user_id.clone()))
2481 .collect(),
2482 total_record_count: response.total_record_count,
2483 })
2484 }
2485
2486 async fn create_playlist(
2489 &self,
2490 name: &str,
2491 item_ids: &[String],
2492 ) -> Result<PlaylistCreatedResult, RepoError> {
2493 info!(
2494 "[OnlineRepo] Creating playlist '{}' with {} items",
2495 name,
2496 item_ids.len()
2497 );
2498 let body = serde_json::json!({
2499 "Name": name,
2500 "Ids": item_ids,
2501 "MediaType": "Audio",
2502 "UserId": self.user_id,
2503 });
2504 let response: CreatePlaylistResponse = self.post_json_response("/Playlists", &body).await?;
2505 Ok(PlaylistCreatedResult { id: response.id })
2506 }
2507
2508 async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
2509 info!("[OnlineRepo] Deleting playlist {}", playlist_id);
2510 let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
2511 let url = format!("{}{}", self.server_url, endpoint);
2512
2513 let request = self
2514 .http_client
2515 .client
2516 .delete(&url)
2517 .header("X-Emby-Authorization", self.auth_header())
2518 .build()
2519 .map_err(|e| RepoError::Network {
2520 message: format!("Failed to build request: {}", e),
2521 })?;
2522
2523 let response = self
2524 .http_client
2525 .request_with_retry(request)
2526 .await
2527 .map_err(|e| RepoError::Network {
2528 message: e.to_string(),
2529 })?;
2530
2531 if !response.status().is_success() {
2532 return Err(RepoError::Server {
2533 message: format!("HTTP {}", response.status()),
2534 });
2535 }
2536
2537 Ok(())
2538 }
2539
2540 async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
2541 info!(
2542 "[OnlineRepo] Renaming playlist {} to '{}'",
2543 playlist_id, name
2544 );
2545 let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
2546 self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
2547 .await
2548 }
2549
2550 async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
2551 let endpoint = format!(
2552 "/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000",
2553 playlist_id, self.user_id
2554 );
2555
2556 let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
2557 debug!(
2558 "[OnlineRepo] Got {} playlist items for {}",
2559 response.items.len(),
2560 playlist_id
2561 );
2562
2563 Ok(response
2564 .items
2565 .into_iter()
2566 .map(|pi| PlaylistEntry {
2567 playlist_item_id: pi.playlist_item_id,
2568 item: pi.item.into_media_item(self.user_id.clone()),
2569 })
2570 .collect())
2571 }
2572
2573 async fn add_to_playlist(
2574 &self,
2575 playlist_id: &str,
2576 item_ids: &[String],
2577 ) -> Result<(), RepoError> {
2578 info!(
2579 "[OnlineRepo] Adding {} items to playlist {}",
2580 item_ids.len(),
2581 playlist_id
2582 );
2583 let ids_param = item_ids
2585 .iter()
2586 .map(|id| urlencoding::encode(id).into_owned())
2587 .collect::<Vec<_>>()
2588 .join(",");
2589 let endpoint = format!(
2590 "/Playlists/{}/Items?Ids={}",
2591 urlencoding::encode(playlist_id),
2592 ids_param
2593 );
2594 self.post_json(&endpoint, &serde_json::json!({})).await
2595 }
2596
2597 async fn remove_from_playlist(
2598 &self,
2599 playlist_id: &str,
2600 entry_ids: &[String],
2601 ) -> Result<(), RepoError> {
2602 info!(
2603 "[OnlineRepo] Removing {} entries from playlist {}",
2604 entry_ids.len(),
2605 playlist_id
2606 );
2607 let ids_param = entry_ids
2608 .iter()
2609 .map(|id| urlencoding::encode(id).into_owned())
2610 .collect::<Vec<_>>()
2611 .join(",");
2612 let endpoint = format!(
2613 "/Playlists/{}/Items?EntryIds={}",
2614 urlencoding::encode(playlist_id),
2615 ids_param
2616 );
2617 let url = format!("{}{}", self.server_url, endpoint);
2618
2619 let request = self
2620 .http_client
2621 .client
2622 .delete(&url)
2623 .header("X-Emby-Authorization", self.auth_header())
2624 .build()
2625 .map_err(|e| RepoError::Network {
2626 message: format!("Failed to build request: {}", e),
2627 })?;
2628
2629 let response = self
2630 .http_client
2631 .request_with_retry(request)
2632 .await
2633 .map_err(|e| RepoError::Network {
2634 message: e.to_string(),
2635 })?;
2636
2637 if !response.status().is_success() {
2638 return Err(RepoError::Server {
2639 message: format!("HTTP {}", response.status()),
2640 });
2641 }
2642
2643 Ok(())
2644 }
2645
2646 async fn move_playlist_item(
2647 &self,
2648 playlist_id: &str,
2649 item_id: &str,
2650 new_index: u32,
2651 ) -> Result<(), RepoError> {
2652 info!(
2653 "[OnlineRepo] Moving item {} in playlist {} to index {}",
2654 item_id, playlist_id, new_index
2655 );
2656 let endpoint = format!(
2657 "/Playlists/{}/Items/{}/Move/{}",
2658 playlist_id, item_id, new_index
2659 );
2660 self.post_json(&endpoint, &serde_json::json!({})).await
2661 }
2662}
2663
2664#[cfg(test)]
2665mod tests {
2666 use super::*;
2667 use crate::utils::lock::MutexSafe;
2668 use std::sync::Arc;
2669
2670 fn create_test_repository() -> OnlineRepository {
2671 let http_config = crate::jellyfin::HttpConfig::default();
2672 let http_client =
2673 Arc::new(HttpClient::new(http_config).expect("Failed to create HTTP client for test"));
2674 OnlineRepository::new(
2675 http_client,
2676 "https://test.server.com".to_string(),
2677 "test-user-id".to_string(),
2678 "test-access-token".to_string(),
2679 )
2680 }
2681
2682 fn create_test_repository_with_connectivity(
2686 ) -> (OnlineRepository, crate::connectivity::ConnectivityReporter) {
2687 let monitor_http = HttpClient::new(crate::jellyfin::HttpConfig::default())
2688 .expect("Failed to create HTTP client for monitor");
2689 let monitor = crate::connectivity::ConnectivityMonitor::new(monitor_http);
2690 let reporter = monitor.reporter();
2691 let repo = create_test_repository().with_connectivity(reporter.clone());
2692 (repo, reporter)
2693 }
2694
2695 #[tokio::test]
2704 async fn test_report_outcome_classifies_server_answered_as_reachable() {
2705 let (repo, reporter) = create_test_repository_with_connectivity();
2706
2707 for err in [
2709 RepoError::Authentication {
2710 message: "401".into(),
2711 },
2712 RepoError::NotFound {
2713 message: "404".into(),
2714 },
2715 RepoError::Server {
2716 message: "500".into(),
2717 },
2718 ] {
2719 reporter.mark_unreachable_for_test().await;
2720 assert!(!reporter.is_reachable().await, "precondition: offline");
2721
2722 let result: Result<(), RepoError> = Err(err);
2723 repo.report_outcome(&result).await;
2724
2725 assert!(
2726 reporter.is_reachable().await,
2727 "a server that answers should be reported reachable"
2728 );
2729 }
2730
2731 reporter.mark_unreachable_for_test().await;
2733 let ok: Result<(), RepoError> = Ok(());
2734 repo.report_outcome(&ok).await;
2735 assert!(reporter.is_reachable().await, "Ok ⇒ reachable");
2736 }
2737
2738 #[tokio::test]
2741 async fn test_report_outcome_ignores_local_errors() {
2742 let (repo, reporter) = create_test_repository_with_connectivity();
2743
2744 reporter.mark_unreachable_for_test().await;
2747 for err in [
2748 RepoError::Database {
2749 message: "cache".into(),
2750 },
2751 RepoError::Offline,
2752 ] {
2753 let result: Result<(), RepoError> = Err(err);
2754 repo.report_outcome(&result).await;
2755 assert!(
2756 !reporter.is_reachable().await,
2757 "local-side error must not change reachability"
2758 );
2759 }
2760 }
2761
2762 #[tokio::test]
2768 async fn test_get_json_fast_fails_when_offline() {
2769 let (repo, reporter) = create_test_repository_with_connectivity();
2770 reporter.mark_unreachable_for_test().await;
2771 assert!(!reporter.is_reachable().await, "precondition: offline");
2772
2773 let result: Result<serde_json::Value, RepoError> = repo.get_json("/System/Info").await;
2774 assert!(
2775 matches!(result, Err(RepoError::Offline)),
2776 "known-offline get_json should return Offline immediately, got {:?}",
2777 result
2778 );
2779 }
2780
2781 #[tokio::test]
2784 async fn test_report_outcome_network_error_is_debounced() {
2785 let (repo, reporter) = create_test_repository_with_connectivity();
2786 assert!(reporter.is_reachable().await, "starts online");
2787
2788 let result: Result<(), RepoError> = Err(RepoError::Network {
2789 message: "timeout".into(),
2790 });
2791 repo.report_outcome(&result).await;
2792
2793 assert!(
2794 reporter.is_reachable().await,
2795 "a single network failure stays online (debounced)"
2796 );
2797 }
2798
2799 #[tokio::test]
2800 async fn test_get_audio_stream_url_formats_correctly() {
2801 let repo = create_test_repository();
2802 let item_id = "test-track-123";
2803
2804 let result = repo.get_audio_stream_url(item_id).await;
2805
2806 assert!(result.is_ok());
2807 let url = result.unwrap();
2808 assert_eq!(
2809 url,
2810 "https://test.server.com/Audio/test-track-123/stream?UserId=test-user-id&api_key=test-access-token&Static=true"
2811 );
2812 }
2813
2814 static QUALITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2820
2821 struct QualityFixture(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
2822
2823 impl QualityFixture {
2824 fn set(quality: StreamingQuality) -> Self {
2825 let guard = QUALITY_LOCK.lock_safe();
2826 set_streaming_quality(quality);
2827 Self(guard)
2828 }
2829 }
2830
2831 impl Drop for QualityFixture {
2832 fn drop(&mut self) {
2833 set_streaming_quality(StreamingQuality::Original);
2834 }
2835 }
2836
2837 #[tokio::test]
2844 async fn test_video_stream_url_applies_bitrate_cap() {
2845 let _fixture = QualityFixture::set(StreamingQuality::Mbps2);
2846 let repo = create_test_repository();
2847
2848 let url = repo
2849 .get_video_stream_url("vid-1", None, None)
2850 .await
2851 .unwrap();
2852
2853 assert!(url.contains("MaxStreamingBitrate=2000000"), "url: {url}");
2854 assert!(url.contains("VideoBitrate=1808000"), "url: {url}");
2857 assert!(url.contains("AudioBitrate=192000"), "url: {url}");
2858 assert!(url.contains("MaxHeight=720"), "url: {url}");
2859 }
2860
2861 #[tokio::test]
2866 async fn test_video_stream_url_uncapped_keeps_legacy_allowance() {
2867 let _fixture = QualityFixture::set(StreamingQuality::Original);
2868 let repo = create_test_repository();
2869
2870 let url = repo
2871 .get_video_stream_url("vid-1", None, None)
2872 .await
2873 .unwrap();
2874
2875 assert!(url.contains("MaxStreamingBitrate=20000000"), "url: {url}");
2876 assert!(url.contains("VideoBitrate=18000000"), "url: {url}");
2877 assert!(url.contains("AudioBitrate=384000"), "url: {url}");
2878 assert!(
2879 !url.contains("MaxHeight"),
2880 "uncapped must not scale the picture down: {url}"
2881 );
2882 }
2883
2884 #[tokio::test]
2889 async fn test_audio_only_stream_url_takes_the_lower_of_cap_and_default() {
2890 {
2891 let _fixture = QualityFixture::set(StreamingQuality::Kbps720);
2892 let repo = create_test_repository();
2893 let url = repo
2894 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
2895 .await
2896 .unwrap();
2897 assert!(url.contains("MaxStreamingBitrate=96000"), "url: {url}");
2898 }
2899
2900 let _fixture = QualityFixture::set(StreamingQuality::Original);
2901 let repo = create_test_repository();
2902 let url = repo
2903 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
2904 .await
2905 .unwrap();
2906 assert!(url.contains("MaxStreamingBitrate=384000"), "url: {url}");
2907 }
2908
2909 #[tokio::test]
2922 async fn test_get_video_stream_url_returns_an_hls_master_playlist() {
2923 let _fixture = QualityFixture::set(StreamingQuality::Original);
2924 let repo = create_test_repository();
2925
2926 let url = repo
2927 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
2928 .await
2929 .unwrap();
2930
2931 assert!(
2932 url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"),
2933 "expected HLS master playlist, got: {url}"
2934 );
2935 assert!(url.contains("VideoCodec=h264"));
2936 assert!(url.contains("MediaSourceId=source-1"));
2937 assert!(url.contains("AudioStreamIndex=1"));
2938 assert!(!url.contains("stream.mp4"));
2939 }
2940
2941 #[tokio::test]
2966 async fn test_video_stream_url_never_carries_start_time_ticks() {
2967 let _fixture = QualityFixture::set(StreamingQuality::Original);
2968 let repo = create_test_repository();
2969
2970 let url = repo
2971 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
2972 .await
2973 .unwrap();
2974
2975 assert!(
2976 !url.contains("StartTimeTicks"),
2977 "an HLS playlist must never carry StartTimeTicks — the server copies it \
2978 onto every segment URI and then rejects each one with 400: {url}"
2979 );
2980 }
2981
2982 #[tokio::test]
2983 async fn test_get_video_stream_url_omits_position_when_absent() {
2984 let _fixture = QualityFixture::set(StreamingQuality::Original);
2985 let repo = create_test_repository();
2986
2987 let url = repo
2988 .get_video_stream_url("vid-1", None, None)
2989 .await
2990 .unwrap();
2991
2992 assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
2993 assert!(!url.contains("StartTimeTicks"));
2994 assert!(!url.contains("MediaSourceId"));
2995 assert!(
3000 !url.contains("AudioStreamIndex"),
3001 "must not pin an audio index when none was chosen: {url}"
3002 );
3003 }
3004
3005 #[tokio::test]
3016 async fn test_video_stream_url_carries_a_play_session_id() {
3017 let _fixture = QualityFixture::set(StreamingQuality::Original);
3018 let repo = create_test_repository();
3019
3020 let url = repo
3021 .get_video_stream_url("vid-1", None, None)
3022 .await
3023 .unwrap();
3024
3025 assert!(
3026 url.contains("PlaySessionId="),
3027 "every transcode must be openable as its own job: {url}"
3028 );
3029 }
3030
3031 #[tokio::test]
3045 async fn test_video_stream_url_asks_for_no_subtitle_stream() {
3046 let _fixture = QualityFixture::set(StreamingQuality::Original);
3047 let repo = create_test_repository();
3048
3049 let url = repo
3050 .get_video_stream_url("vid-1", Some("source-1"), Some(1))
3051 .await
3052 .unwrap();
3053
3054 assert!(
3055 url.contains("SubtitleStreamIndex=-1"),
3056 "the stream URL must ask for no subtitle, not leave the choice open: {url}"
3057 );
3058 }
3059
3060 #[test]
3068 fn test_media_streams_carry_whether_the_app_can_render_them() {
3069 let item: JellyfinItem = serde_json::from_value(serde_json::json!({
3070 "Id": "ep-1",
3071 "Name": "Partings",
3072 "Type": "Episode",
3073 "MediaStreams": [
3074 { "Type": "Video", "Index": 0, "Codec": "hevc", "IsDefault": true },
3075 { "Type": "Audio", "Index": 1, "Codec": "eac3", "IsDefault": true },
3076 { "Type": "Subtitle", "Index": 2, "Codec": "PGSSUB", "IsDefault": true },
3077 { "Type": "Subtitle", "Index": 3, "Codec": "subrip", "IsDefault": false },
3078 { "Type": "Subtitle", "Index": 4, "Codec": null, "IsDefault": false },
3079 ],
3080 }))
3081 .expect("fixture must deserialize");
3082
3083 let streams = item.into_media_item("server-1".to_string()).media_streams;
3084 let streams = streams.expect("the item carries streams");
3085 let deliverable = |index: i32| {
3086 streams
3087 .iter()
3088 .find(|s| s.index == index)
3089 .unwrap_or_else(|| panic!("stream {index} missing"))
3090 .supports_external_delivery
3091 };
3092
3093 assert_eq!(deliverable(2), Some(false));
3095 assert_eq!(deliverable(3), Some(true));
3097 assert_eq!(deliverable(4), Some(false));
3100 assert_eq!(deliverable(0), None);
3103 assert_eq!(deliverable(1), None);
3104 }
3105
3106 #[test]
3112 fn test_each_stream_open_gets_a_fresh_session_and_reports_the_previous() {
3113 let _lock = QUALITY_LOCK.lock_safe();
3114
3115 let (first, _) = begin_video_play_session();
3116 let (second, replaced) = begin_video_play_session();
3117
3118 assert_ne!(first, second, "each open needs its own job identity");
3119 assert_eq!(
3120 replaced,
3121 Some(first),
3122 "the open must hand back the job it superseded so it can be stopped"
3123 );
3124
3125 let replaced_by_adoption = adopt_video_play_session("server-named-session".to_string());
3129 assert_eq!(replaced_by_adoption, Some(second));
3130
3131 let (_, after_adoption) = begin_video_play_session();
3132 assert_eq!(
3133 after_adoption,
3134 Some("server-named-session".to_string()),
3135 "the adopted job must be the one the next open stops"
3136 );
3137 }
3138
3139 #[tokio::test]
3140 async fn test_get_audio_only_stream_url_for_video_carries_track_and_position() {
3141 let repo = create_test_repository();
3146
3147 let url = repo
3148 .get_audio_only_stream_url_for_video("vid-1", Some("source-1"), Some(193.0), Some(2))
3149 .await
3150 .unwrap();
3151
3152 assert!(
3153 url.starts_with("https://test.server.com/Audio/vid-1/universal?"),
3154 "expected audio-only universal endpoint, got: {url}"
3155 );
3156 assert!(
3158 !url.contains("/Videos/"),
3159 "url must not hit the video endpoint: {url}"
3160 );
3161 assert!(
3162 !url.contains("master.m3u8"),
3163 "url must not be a video HLS playlist: {url}"
3164 );
3165 assert!(url.contains("AudioStreamIndex=2"));
3166 assert!(url.contains("MediaSourceId=source-1"));
3167 assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
3169 assert!(url.contains("TranscodingProtocol=http"), "url: {url}");
3172 assert!(url.contains("TranscodingContainer=mp3"), "url: {url}");
3173 assert!(
3174 !url.contains("TranscodingProtocol=hls"),
3175 "url must not be HLS: {url}"
3176 );
3177 assert!(!url.contains("Container=ts"), "url must not be ts: {url}");
3178 }
3179
3180 #[tokio::test]
3181 async fn test_get_audio_only_stream_url_for_video_omits_position_when_absent() {
3182 let repo = create_test_repository();
3184
3185 let url = repo
3186 .get_audio_only_stream_url_for_video("vid-1", None, None, None)
3187 .await
3188 .unwrap();
3189
3190 assert!(url.starts_with("https://test.server.com/Audio/vid-1/universal?"));
3191 assert!(!url.contains("StartTimeTicks"));
3192 assert!(!url.contains("MediaSourceId"));
3193 assert!(
3196 !url.contains("AudioStreamIndex"),
3197 "must not pin an audio index when none was chosen: {url}"
3198 );
3199 }
3200
3201 #[tokio::test]
3202 async fn test_get_audio_stream_url_with_special_characters() {
3203 let repo = create_test_repository();
3204 let item_id = "track-with-special-chars-!@#";
3205
3206 let result = repo.get_audio_stream_url(item_id).await;
3207
3208 assert!(result.is_ok());
3209 let url = result.unwrap();
3210 assert!(url.contains("track-with-special-chars-!@#"));
3211 assert!(url.starts_with("https://test.server.com/Audio/"));
3212 }
3213
3214 #[test]
3215 fn test_image_tags_deserialize_hashmap_format() {
3216 let json = r#"{"Primary":"abc123","Banner":"def456","Backdrop":"ghi789"}"#;
3218 let result: Result<ImageTags, _> = serde_json::from_str(json);
3219
3220 assert!(result.is_ok());
3221 let tags = result.unwrap();
3222 assert_eq!(tags.primary(), Some("abc123".to_string()));
3223 }
3224
3225 #[test]
3226 fn test_image_tags_deserialize_structured_format() {
3227 let json = r#"{"Primary":"xyz789"}"#;
3229 let result: Result<ImageTags, _> = serde_json::from_str(json);
3230
3231 assert!(result.is_ok());
3232 let tags = result.unwrap();
3233 assert_eq!(tags.primary(), Some("xyz789".to_string()));
3234 }
3235
3236 #[test]
3237 fn test_image_tags_deserialize_missing_primary() {
3238 let json = r#"{"Banner":"def456","Backdrop":"ghi789"}"#;
3240 let result: Result<ImageTags, _> = serde_json::from_str(json);
3241
3242 assert!(result.is_ok());
3243 let tags = result.unwrap();
3244 assert_eq!(tags.primary(), None);
3245 }
3246
3247 #[test]
3248 fn test_image_tags_deserialize_empty_map() {
3249 let json = r#"{}"#;
3251 let result: Result<ImageTags, _> = serde_json::from_str(json);
3252
3253 assert!(result.is_ok());
3254 let tags = result.unwrap();
3255 assert_eq!(tags.primary(), None);
3256 }
3257
3258 #[test]
3269 fn test_video_download_url_uses_stream_not_download_endpoint() {
3270 let repo = create_test_repository();
3271 let url = repo.get_video_download_url("item123", "original", None, None);
3272
3273 assert!(
3275 !url.contains("/download"),
3276 "download URL must not use the broken /Videos/{{id}}/download endpoint: {url}"
3277 );
3278 assert!(
3280 url.contains("/Videos/item123/stream.mp4"),
3281 "download URL must target /Videos/{{id}}/stream.mp4: {url}"
3282 );
3283 assert!(url.contains("api_key=test-access-token"), "url: {url}");
3284 }
3285
3286 #[test]
3287 fn test_video_download_url_original_is_static_direct_copy() {
3288 let repo = create_test_repository();
3289 let url = repo.get_video_download_url("item123", "original", None, None);
3290
3291 assert!(url.contains("Static=true"), "url: {url}");
3294 assert!(
3295 !url.contains("videoBitRate"),
3296 "original must not transcode: {url}"
3297 );
3298 assert!(
3299 !url.contains("maxHeight"),
3300 "original must not transcode: {url}"
3301 );
3302 }
3303
3304 #[test]
3305 fn test_video_download_url_quality_presets_transcode() {
3306 let repo = create_test_repository();
3307
3308 for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
3309 let url = repo.get_video_download_url("item123", quality, None, None);
3310 assert!(
3311 url.contains("/Videos/item123/stream.mp4"),
3312 "{quality} must use stream.mp4: {url}"
3313 );
3314 assert!(
3315 url.contains("videoBitRate="),
3316 "{quality} must set bitrate: {url}"
3317 );
3318 assert!(
3319 url.contains(&format!("maxHeight={height}")),
3320 "{quality} must cap height at {height}: {url}"
3321 );
3322 assert!(url.contains("videoCodec=h264"), "{quality}: {url}");
3323 assert!(
3325 !url.contains("Static=true"),
3326 "{quality} must not be Static: {url}"
3327 );
3328 }
3329 }
3330
3331 #[test]
3338 fn test_video_download_url_bitrate_params_use_capital_r_spelling() {
3339 let repo = create_test_repository();
3340
3341 for quality in ["high", "medium", "low"] {
3342 let url = repo.get_video_download_url("item123", quality, None, None);
3343
3344 assert!(
3345 url.contains("videoBitRate="),
3346 "{quality} must spell it videoBitRate (capital R): {url}"
3347 );
3348 assert!(
3349 url.contains("audioBitRate="),
3350 "{quality} must spell it audioBitRate (capital R): {url}"
3351 );
3352
3353 assert!(
3356 !url.contains("videoBitrate="),
3357 "{quality} emits the unbindable lowercase-r spelling: {url}"
3358 );
3359 assert!(
3360 !url.contains("audioBitrate="),
3361 "{quality} emits the unbindable lowercase-r spelling: {url}"
3362 );
3363 }
3364 }
3365
3366 #[test]
3372 fn test_video_download_url_transcode_presets_forbid_video_stream_copy() {
3373 let repo = create_test_repository();
3374
3375 for quality in ["high", "medium", "low"] {
3376 let url = repo.get_video_download_url("item123", quality, None, None);
3377 assert!(
3378 url.contains("allowVideoStreamCopy=false"),
3379 "{quality} must forbid video stream copy: {url}"
3380 );
3381 }
3382
3383 let original = repo.get_video_download_url("item123", "original", None, None);
3385 assert!(
3386 !original.contains("allowVideoStreamCopy=false"),
3387 "original must remain a direct copy: {original}"
3388 );
3389 }
3390
3391 #[test]
3404 fn test_video_download_url_original_transcodes_undecodable_audio() {
3405 let repo = create_test_repository();
3406
3407 for codec in ["eac3", "ac3", "dts", "truehd", "EAC3"] {
3408 let url = repo.get_video_download_url("item123", "original", None, Some(codec));
3409 assert!(
3410 !url.contains("Static=true"),
3411 "{codec} cannot be decoded here, so the source must not be copied verbatim: {url}"
3412 );
3413 assert!(
3414 url.contains("audioCodec=aac"),
3415 "{codec} must be re-encoded to aac on the way down: {url}"
3416 );
3417 assert!(
3420 url.contains("allowVideoStreamCopy=true"),
3421 "the video stream must still be copied where possible: {url}"
3422 );
3423 assert!(
3424 !url.contains("videoBitRate") && !url.contains("maxHeight"),
3425 "original must not degrade the picture to fix the audio: {url}"
3426 );
3427 }
3428 }
3429
3430 #[test]
3436 fn test_video_download_url_original_keeps_static_copy_for_playable_audio() {
3437 let repo = create_test_repository();
3438
3439 for codec in ["aac", "mp3", "opus", "vorbis", "flac", "AAC"] {
3440 let url = repo.get_video_download_url("item123", "original", None, Some(codec));
3441 assert!(
3442 url.contains("Static=true"),
3443 "{codec} plays here — the download must stay a direct copy: {url}"
3444 );
3445 assert!(
3446 !url.contains("audioCodec="),
3447 "{codec} needs no transcode: {url}"
3448 );
3449 }
3450
3451 let unknown = repo.get_video_download_url("item123", "original", None, None);
3454 assert!(unknown.contains("Static=true"), "url: {unknown}");
3455 }
3456
3457 #[test]
3462 fn test_video_download_url_presets_ignore_the_audio_policy() {
3463 let repo = create_test_repository();
3464
3465 for quality in ["high", "medium", "low"] {
3466 let with = repo.get_video_download_url("item123", quality, None, Some("eac3"));
3467 let without = repo.get_video_download_url("item123", quality, None, None);
3468 assert_eq!(with, without, "{quality} must not vary with source audio");
3469 assert!(with.contains("audioCodec=aac"), "url: {with}");
3470 }
3471 }
3472
3473 #[test]
3474 fn test_video_download_url_passes_media_source_id() {
3475 let repo = create_test_repository();
3476 let url = repo.get_video_download_url("item123", "original", Some("src-42"), None);
3477 assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
3478 }
3479
3480 #[test]
3481 fn test_jellyfin_item_deserialize_with_image_tags() {
3482 let json = r#"{
3484 "Id": "album123",
3485 "Name": "Test Album",
3486 "Type": "MusicAlbum",
3487 "ImageTags": {"Primary": "tag123"},
3488 "ArtistItems": [
3489 {"Id": "artist1", "Name": "Artist One"},
3490 {"Id": "artist2", "Name": "Artist Two"}
3491 ]
3492 }"#;
3493
3494 let result: Result<JellyfinItem, _> = serde_json::from_str(json);
3495 assert!(result.is_ok());
3496
3497 let item = result.unwrap();
3498 assert_eq!(item.id, "album123");
3499 assert_eq!(item.name, "Test Album");
3500 assert_eq!(item.item_type, "MusicAlbum");
3501 assert!(item.image_tags.is_some());
3502 assert_eq!(
3503 item.image_tags.unwrap().primary(),
3504 Some("tag123".to_string())
3505 );
3506 }
3507
3508 #[test]
3512 fn test_build_favorites_endpoint_scopes_and_filters() {
3513 let movies = build_favorites_endpoint("u1", SearchScope::Movies, None);
3514 assert!(movies.starts_with("/Users/u1/Items?Filters=IsFavorite&Recursive=true"));
3515 assert!(movies.contains("&IncludeItemTypes=Movie"));
3516 assert!(movies.contains("&SortBy=SortName&SortOrder=Ascending"));
3518 assert!(movies.contains("UserData"));
3520
3521 let tv = build_favorites_endpoint("u1", SearchScope::Tv, None);
3523 assert!(tv.contains("&IncludeItemTypes=Series,Episode"));
3524
3525 let music = build_favorites_endpoint("u1", SearchScope::Music, None);
3526 assert!(music.contains("&IncludeItemTypes=MusicAlbum,MusicArtist,Audio,Playlist"));
3527 }
3528
3529 #[test]
3534 fn test_build_favorites_endpoint_all_scope_omits_type_filter() {
3535 let all = build_favorites_endpoint("u1", SearchScope::All, None);
3536 assert!(!all.contains("IncludeItemTypes"));
3537 }
3538
3539 #[test]
3543 fn test_build_favorites_endpoint_honours_paging_and_sort() {
3544 let endpoint = build_favorites_endpoint(
3545 "u1",
3546 SearchScope::All,
3547 Some(&GetItemsOptions {
3548 limit: Some(20),
3549 start_index: Some(40),
3550 sort_by: Some("Random".to_string()),
3551 sort_order: Some("Descending".to_string()),
3552 ..Default::default()
3553 }),
3554 );
3555 assert!(endpoint.contains("&Limit=20"));
3556 assert!(endpoint.contains("&StartIndex=40"));
3557 assert!(endpoint.contains("&SortBy=Random&SortOrder=Descending"));
3558 }
3559
3560 #[test]
3565 fn test_get_items_endpoint_applies_favorites_only() {
3566 let plain = build_get_items_endpoint("u1", "lib-1", None);
3567 assert!(!plain.contains("Filters=IsFavorite"));
3568
3569 let filtered = build_get_items_endpoint(
3570 "u1",
3571 "lib-1",
3572 Some(&GetItemsOptions {
3573 favorites_only: Some(true),
3574 include_item_types: Some(vec!["Movie".to_string()]),
3575 ..Default::default()
3576 }),
3577 );
3578 assert!(filtered.contains("&Filters=IsFavorite"));
3579 assert!(filtered.contains("&IncludeItemTypes=Movie"));
3581 assert!(filtered.contains("ParentId=lib-1"));
3582
3583 let off = build_get_items_endpoint(
3585 "u1",
3586 "lib-1",
3587 Some(&GetItemsOptions {
3588 favorites_only: Some(false),
3589 ..Default::default()
3590 }),
3591 );
3592 assert!(!off.contains("Filters=IsFavorite"));
3593 }
3594
3595 #[test]
3604 fn test_get_items_endpoint_encodes_query_values() {
3605 let endpoint = build_get_items_endpoint(
3606 "u1",
3607 "lib 1&Filters=IsFavorite",
3608 Some(&GetItemsOptions {
3609 include_item_types: Some(vec!["Movie&x=1".to_string()]),
3610 sort_by: Some("Sort Name".to_string()),
3611 sort_order: Some("Ascending&y=2".to_string()),
3612 ..Default::default()
3613 }),
3614 );
3615 assert!(
3616 endpoint.contains("ParentId=lib%201%26Filters%3DIsFavorite"),
3617 "{endpoint}"
3618 );
3619 assert!(
3620 endpoint.contains("&IncludeItemTypes=Movie%26x%3D1"),
3621 "{endpoint}"
3622 );
3623 assert!(endpoint.contains("&SortBy=Sort%20Name"), "{endpoint}");
3624 assert!(
3625 endpoint.contains("&SortOrder=Ascending%26y%3D2"),
3626 "{endpoint}"
3627 );
3628 assert!(!endpoint.contains("&Filters=IsFavorite"), "{endpoint}");
3630 assert!(!endpoint.contains("&x=1"), "{endpoint}");
3631 assert!(!endpoint.contains("&y=2"), "{endpoint}");
3632 }
3633
3634 #[test]
3640 fn test_get_items_endpoint_keeps_list_separators() {
3641 let endpoint = build_get_items_endpoint(
3642 "u1",
3643 "lib-1",
3644 Some(&GetItemsOptions {
3645 sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
3646 include_item_types: Some(vec!["Movie".to_string(), "Series".to_string()]),
3647 ..Default::default()
3648 }),
3649 );
3650 assert!(
3651 endpoint.contains("&SortBy=ParentIndexNumber,IndexNumber,SortName"),
3652 "{endpoint}"
3653 );
3654 assert!(
3655 endpoint.contains("&IncludeItemTypes=Movie,Series"),
3656 "{endpoint}"
3657 );
3658 assert!(endpoint.contains("ParentId=lib-1"), "{endpoint}");
3660 }
3661
3662 #[test]
3669 fn test_latest_items_endpoint_groups_children_into_containers() {
3670 let endpoint = build_latest_items_endpoint("u1", "lib-1", Some(16));
3671
3672 assert!(
3673 endpoint.contains("GroupItems=true"),
3674 "latest items must be grouped so an album counts once, got: {}",
3675 endpoint
3676 );
3677 assert!(endpoint.contains("ParentId=lib-1"));
3678 assert!(endpoint.contains("Limit=16"));
3679 }
3680
3681 #[test]
3690 fn test_build_next_up_endpoint_excludes_resumable() {
3691 let endpoint = build_next_up_endpoint("u1", None, Some(12));
3692
3693 assert!(
3694 endpoint.contains("EnableResumable=false"),
3695 "next up must exclude in-progress episodes, got: {}",
3696 endpoint
3697 );
3698 assert!(endpoint.contains("UserId=u1"));
3699 assert!(endpoint.contains("Limit=12"));
3700 assert!(
3701 !endpoint.contains("SeriesId"),
3702 "no series filter when none was requested, got: {}",
3703 endpoint
3704 );
3705 }
3706
3707 #[test]
3711 fn test_build_next_up_endpoint_scopes_to_series() {
3712 let endpoint = build_next_up_endpoint("u1", Some("series-a"), None);
3713
3714 assert!(endpoint.contains("SeriesId=series-a"));
3715 assert!(endpoint.contains("EnableResumable=false"));
3716 assert!(
3717 endpoint.contains("Limit=16"),
3718 "default limit, got: {}",
3719 endpoint
3720 );
3721 }
3722
3723 #[test]
3730 fn test_jellyfin_item_maps_user_data_favorite() {
3731 let json = r#"{
3732 "Id": "movie123",
3733 "Name": "Test Movie",
3734 "Type": "Movie",
3735 "UserData": {
3736 "PlaybackPositionTicks": 6000000000,
3737 "Played": false,
3738 "IsFavorite": true,
3739 "PlayCount": 2,
3740 "LastPlayedDate": "2026-08-01T12:00:00Z"
3741 }
3742 }"#;
3743
3744 let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
3745 let media = item.into_media_item("server1".to_string());
3746
3747 let user_data = media.user_data.expect("user data should be mapped");
3748 assert_eq!(user_data.is_favorite, Some(true));
3749 assert_eq!(user_data.is_played, Some(false));
3750 assert_eq!(user_data.play_count, Some(2));
3751 assert_eq!(user_data.playback_position_ticks, Some(6_000_000_000));
3752 assert_eq!(user_data.playback_position_ms, Some(600_000));
3754 }
3755
3756 #[test]
3761 fn test_jellyfin_item_without_user_data_maps_to_none() {
3762 let json = r#"{"Id": "x", "Name": "No User Data", "Type": "Movie"}"#;
3763
3764 let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
3765 let media = item.into_media_item("server1".to_string());
3766
3767 assert!(media.user_data.is_none());
3768 }
3769
3770 #[test]
3771 fn test_jellyfin_item_deserialize_with_artist_items() {
3772 let json = r#"{
3774 "Id": "track123",
3775 "Name": "Test Track",
3776 "Type": "Audio",
3777 "ArtistItems": [
3778 {"Id": "artist1", "Name": "Bob Dylan"},
3779 {"Id": "artist2", "Name": "Johnny Cash"}
3780 ]
3781 }"#;
3782
3783 let result: Result<JellyfinItem, _> = serde_json::from_str(json);
3784 assert!(result.is_ok());
3785
3786 let item = result.unwrap();
3787 let artist_items = item.artist_items.expect("Expected artist items");
3788 assert_eq!(artist_items.len(), 2);
3789 assert_eq!(artist_items[0].id, "artist1");
3790 assert_eq!(artist_items[0].name, "Bob Dylan");
3791 assert_eq!(artist_items[1].id, "artist2");
3792 assert_eq!(artist_items[1].name, "Johnny Cash");
3793 }
3794
3795 #[test]
3796 fn test_jellyfin_item_to_media_item_conversion() {
3797 let json = r#"{
3799 "Id": "album456",
3800 "Name": "Love and Theft",
3801 "Type": "MusicAlbum",
3802 "ImageTags": {"Primary": "7ebab4f6a80cd09d"},
3803 "Artists": ["Bob Dylan"],
3804 "ArtistItems": [{"Id": "0b2a6e969a27f22aba97f9f0e69fa849", "Name": "Bob Dylan"}],
3805 "RunTimeTicks": 33900137190
3806 }"#;
3807
3808 let jellyfin_item: JellyfinItem = serde_json::from_str(json).expect("Failed to parse");
3809 let media_item = jellyfin_item.into_media_item("test-server-id".to_string());
3810
3811 assert_eq!(media_item.id, "album456");
3812 assert_eq!(media_item.name, "Love and Theft");
3813 assert_eq!(media_item.item_type, "MusicAlbum");
3814 assert_eq!(
3815 media_item.primary_image_tag,
3816 Some("7ebab4f6a80cd09d".to_string())
3817 );
3818 assert_eq!(media_item.server_id, "test-server-id");
3819 }
3820
3821 #[test]
3822 fn test_items_response_deserialize() {
3823 let json = r#"{
3825 "Items": [
3826 {
3827 "Id": "item1",
3828 "Name": "Item One",
3829 "Type": "MusicAlbum",
3830 "ImageTags": {"Primary": "tag1"}
3831 },
3832 {
3833 "Id": "item2",
3834 "Name": "Item Two",
3835 "Type": "Audio",
3836 "ImageTags": {"Primary": "tag2"}
3837 }
3838 ],
3839 "TotalRecordCount": 2
3840 }"#;
3841
3842 let result: Result<ItemsResponse, _> = serde_json::from_str(json);
3843 assert!(result.is_ok());
3844
3845 let response = result.unwrap();
3846 assert_eq!(response.total_record_count, 2);
3847 assert_eq!(response.items.len(), 2);
3848 assert_eq!(response.items[0].id, "item1");
3849 assert_eq!(response.items[1].id, "item2");
3850 }
3851
3852 #[test]
3853 fn test_search_term_is_url_encoded() {
3854 assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
3858 assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
3859 }
3860
3861 #[test]
3862 fn test_jray_context_deserializes_actors() {
3863 let json = r#"{
3865 "actors": [
3866 { "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
3867 ]
3868 }"#;
3869 let ctx: JRayContext = serde_json::from_str(json).expect("should parse");
3870 assert_eq!(ctx.actors.len(), 1);
3871 assert_eq!(ctx.actors[0].name, "Tom Hanks");
3872 assert_eq!(ctx.actors[0].jellyfin_id, "abc123-guid");
3873 }
3874
3875 #[test]
3876 fn test_jray_context_ignores_unknown_keys_and_missing_ids() {
3877 let json = r#"{
3880 "actors": [ { "name": "Extra" } ],
3881 "locations": ["Beach"],
3882 "trivia": "filmed in 1994"
3883 }"#;
3884 let ctx: JRayContext = serde_json::from_str(json).expect("should tolerate extra keys");
3885 assert_eq!(ctx.actors.len(), 1);
3886 assert_eq!(ctx.actors[0].name, "Extra");
3887 assert_eq!(ctx.actors[0].imdb_id, "");
3888 assert_eq!(ctx.actors[0].jellyfin_id, "");
3889 }
3890}