diff --git a/CHANGELOG.md b/CHANGELOG.md index 77175b0a6..ed5d7536c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1195,6 +1195,20 @@ ## 🐛 Fixes +- **Jellyfin catalogs no longer collapse to one item on a repeated update.** Jellyfin items lost their stable id + when the input cache was read back, so all of them shared one UUID and the duplicate filter kept a single item. + Any update that reused the cached input (new mapping rule, added source) published a catalog of one entry. The + stable id now survives the cache. Existing Jellyfin input caches are rebuilt by the next full update. + +- **Plex and Emby items keep the same Xtream id whether the input comes fresh or from the cache.** The cache restored + a different identity than the fresh download, so a series could receive a new Xtream id once an update reused the + cached input. Both now use the stable media-server id. Items that were first published from a cached input get a + new Xtream id once. + +- **Plex, Jellyfin and Emby movies show their poster in Xtream clients.** `get_vod_streams` returned an empty + `stream_icon` for media-server movies because the image reference was stored only in the stream properties. The + poster is now exposed through the resource proxy like the series cover. + - **HLS segments free the provider slot when the upstream download ends.** A proxied HLS segment kept its provider slot until the client had read the last buffered byte, or until the socket idle deadline closed the stream. Players that request the next segment while the previous one is still being read (mpv, ffmpeg) were answered with the diff --git a/backend/media-server/src/playlist_mapper.rs b/backend/media-server/src/playlist_mapper.rs index de857edc7..714bd2c95 100644 --- a/backend/media-server/src/playlist_mapper.rs +++ b/backend/media-server/src/playlist_mapper.rs @@ -171,25 +171,29 @@ fn media_server_movie_to_playlist_item(movie: &MediaServerMovie) -> PlaylistItem media_server_stream_ref_to_internal_url, ); let uuid = generate_provider_playlist_uuid(&movie.input_name, &stable_id, PlaylistItemType::Video); + let interned_id = stable_id.intern(); let release_date = movie.release_date.clone().or_else(|| release_date_from_year(movie.year)); let image_url = movie.image_ref.as_ref().map(media_server_image_ref_to_internal_url).map(Arc::::from); let details = movie_details(movie, release_date, image_url.clone()); + let logo = image_url.unwrap_or_else(|| "".intern()); let rating = media_server_rating(movie.descriptive_facts.as_ref()); PlaylistItem { header: PlaylistItemHeader { uuid, - id: stable_id.intern(), + input_stream_id: Arc::clone(&interned_id), + id: interned_id, name: movie.title.clone(), title: movie.title.clone(), url: url.intern(), input_name: movie.input_name.clone(), + logo: Arc::clone(&logo), xtream_cluster: XtreamCluster::Video, item_type: PlaylistItemType::Video, additional_properties: Some(StreamProperties::Video(Box::new(VideoStreamProperties { name: movie.title.clone(), stream_id: 0, - stream_icon: image_url.clone().unwrap_or_else(|| "".intern()), + stream_icon: logo, direct_source: "".intern(), category_id: 0, custom_sid: None, @@ -217,24 +221,28 @@ fn media_server_series_to_playlist_item(series: &MediaServerSeries, seasons: &[& encode_url_path_segment(&series.item_id) ); let uuid = generate_provider_playlist_uuid(&series.input_name, &stable_id, PlaylistItemType::SeriesInfo); + let interned_id = stable_id.intern(); let release_date = series.release_date.clone().or_else(|| release_date_from_year(series.year)); let rating = media_server_rating(series.descriptive_facts.as_ref()).unwrap_or_default(); let image_url = series.image_ref.as_ref().map(media_server_image_ref_to_internal_url).map(Arc::::from); + let logo = image_url.unwrap_or_else(|| "".intern()); PlaylistItem { header: PlaylistItemHeader { uuid, - id: stable_id.intern(), + input_stream_id: Arc::clone(&interned_id), + id: interned_id, name: series.title.clone(), title: series.title.clone(), url: url.intern(), input_name: series.input_name.clone(), + logo: Arc::clone(&logo), xtream_cluster: XtreamCluster::Series, item_type: PlaylistItemType::SeriesInfo, additional_properties: Some(StreamProperties::Series(Box::new(SeriesStreamProperties { name: series.title.clone(), series_id: 0, - cover: image_url.unwrap_or_else(|| "".intern()), + cover: logo, backdrop_path: None, category_id: 0, cast: joined_values(series.descriptive_facts.as_ref().map(|facts| facts.cast.as_slice())) @@ -271,6 +279,7 @@ fn media_server_episode_to_playlist_item(episode: &MediaServerEpisode, parent_co media_server_stream_ref_to_internal_url, ); let uuid = generate_provider_playlist_uuid(&episode.input_name, &stable_id, PlaylistItemType::Series); + let interned_id = stable_id.intern(); let title = if episode.title.is_empty() { episode.series_title.clone().unwrap_or_else(|| "Media Server Episode".intern()) } else { @@ -279,16 +288,19 @@ fn media_server_episode_to_playlist_item(episode: &MediaServerEpisode, parent_co let technical = episode.technical_facts.as_ref(); let plot = episode.descriptive_facts.as_ref().and_then(|facts| facts.summary.clone()); let image_url = episode.image_ref.as_ref().map(media_server_image_ref_to_internal_url).map(Arc::::from); + let logo = image_url.unwrap_or_else(|| "".intern()); PlaylistItem { header: PlaylistItemHeader { uuid, - id: stable_id.intern(), + input_stream_id: Arc::clone(&interned_id), + id: interned_id, name: title.clone(), title, parent_code, url: url.intern(), input_name: episode.input_name.clone(), + logo: Arc::clone(&logo), xtream_cluster: XtreamCluster::Series, item_type: PlaylistItemType::Series, additional_properties: Some(StreamProperties::Episode(Box::new(EpisodeStreamProperties { @@ -300,7 +312,7 @@ fn media_server_episode_to_playlist_item(episode: &MediaServerEpisode, parent_co series_release_date: None, plot, tmdb: provider_tmdb_id(&episode.provider_hints), - movie_image: image_url.unwrap_or_else(|| "".intern()), + movie_image: logo, container_extension: media_server_container_extension(technical), video: technical.and_then(media_server_video_json), audio: technical.and_then(media_server_audio_json), @@ -945,6 +957,15 @@ mod tests { ..MediaServerCatalogSnapshot::default() }); + // Xtream VOD output reads `stream_icon` from `header.logo`, not from the properties. + assert_eq!( + groups[0].channels[0].header.logo.as_ref(), + "media-server://image/plex/media_server/server%2Fone/rating?image_path=%2Flibrary%2Fmetadata%2Frating%2Fthumb%2Fredacted" + ); + assert_eq!( + groups[1].channels[0].header.logo.as_ref(), + "media-server://image/jellyfin/media_server/server/series?image_kind=Primary&tag=tag-redacted" + ); let Some(StreamProperties::Video(video)) = &groups[0].channels[0].header.additional_properties else { panic!("expected video properties"); }; diff --git a/backend/repository/src/library_repository.rs b/backend/repository/src/library_repository.rs index 38533732b..39fb5736f 100644 --- a/backend/repository/src/library_repository.rs +++ b/backend/repository/src/library_repository.rs @@ -361,4 +361,40 @@ mod tests { _ => panic!("expected episode properties"), } } + + #[tokio::test] + async fn media_server_items_keep_distinct_identity_after_persist_and_load() { + let dir = tempfile::tempdir().expect("tempdir"); + let app_config = crate::test_config::app_config_in(dir.path()); + let path = dir.path().join("media_server_input.db"); + let item = |stable_id: &str| { + let mut item = PlaylistItem::from(&video_item( + &format!("media-server://jellyfin/server/{stable_id}"), + "1", + None, + None, + None, + 0, + )); + item.header.item_type = shared::model::PlaylistItemType::Video; + item.header.id = stable_id.intern(); + item.header.input_stream_id = stable_id.intern(); + item.header.gen_uuid(); + item + }; + let items = vec![item("media-server:s:l:movie:aaaa"), item("media-server:s:l:movie:bbbb")]; + let mut expected: Vec<_> = items.iter().map(|item| *item.header.get_uuid()).collect(); + let group = + PlaylistGroup { id: 1, title: "Movies".intern(), channels: items, xtream_cluster: XtreamCluster::Video }; + + let (_, result) = persist_input_library_playlist(&app_config, &path, vec![group]).await; + result.expect("persist"); + let loaded = load_input_local_library_playlist(&app_config, &path).await.expect("load"); + + let mut loaded_uuids: Vec<_> = + loaded.iter().flat_map(|group| &group.channels).map(|item| *item.header.get_uuid()).collect(); + loaded_uuids.sort(); + expected.sort(); + assert_eq!(loaded_uuids, expected); + } } diff --git a/backend/repository/src/m3u_playlist_iterator.rs b/backend/repository/src/m3u_playlist_iterator.rs index a835ed643..f2346477f 100644 --- a/backend/repository/src/m3u_playlist_iterator.rs +++ b/backend/repository/src/m3u_playlist_iterator.rs @@ -972,6 +972,43 @@ mod tests { assert!(!line.contains("192.168.1.20"), "{line}"); } + #[tokio::test] + async fn m3u_text_output_never_leaks_media_server_logo_urls() { + const MEDIA_SERVER_LOGO: &str = "media-server://image/plex/plex/server/14692?image_path=%2Fthumb"; + for resource_rewrite in [false, true] { + let mut item = m3u_item("media-server://plex/server/14692"); + item.logo = MEDIA_SERVER_LOGO.intern(); + let rewrite = Arc::new(ResourceRewrite { + base_url: "https://proxy.example".to_string(), + username: "user".to_string(), + password: "pass".to_string(), + }); + let mut flags = M3uPlaylistIteratorFlagsSet::new(); + if resource_rewrite { + flags.set(M3uPlaylistIteratorFlags::RewriteResource); + } + let item = apply_rewrite( + item, + &UrlRewriteContext { base_url: "https://proxy.example", username: "user", password: "pass" }, + 1, + &[7u8; 16], + &HashMap::new(), + None, + flags, + ProxyType::Redirect, + ); + + let lines: Vec<_> = + m3u_text_stream(futures::stream::iter(vec![Ok((item, false))]), None, rewrite, resource_rewrite) + .collect() + .await; + + let line = lines[0].as_ref().expect("item line"); + assert!(line.contains("tvg-logo=\"https://proxy.example/resource/m3u/user/pass/"), "{line}"); + assert!(!line.contains("media-server://image"), "resource_rewrite={resource_rewrite}: {line}"); + } + } + // Regression lock: never forward the provider's source `url-tvg`. Without // configured Tuliprox server information, the header stays bare. #[tokio::test] diff --git a/shared/src/model/playlist/item.rs b/shared/src/model/playlist/item.rs index a6e5c9dc6..f81f1b866 100644 --- a/shared/src/model/playlist/item.rs +++ b/shared/src/model/playlist/item.rs @@ -248,7 +248,9 @@ impl From<&XtreamPlaylistItem> for PlaylistItem { let header = PlaylistItemHeader { uuid: item.get_uuid(), virtual_id: item.virtual_id, - id: if item.provider_id == 0 && input_stream_id.is_none() { + id: if let Some(id) = item.media_server_identity() { + Arc::clone(id) + } else if item.provider_id == 0 && input_stream_id.is_none() { "".intern() } else { item.provider_id.to_string().intern() diff --git a/shared/src/model/playlist/tests/identity.rs b/shared/src/model/playlist/tests/identity.rs index 51de82924..eaed76d58 100644 --- a/shared/src/model/playlist/tests/identity.rs +++ b/shared/src/model/playlist/tests/identity.rs @@ -177,3 +177,55 @@ fn legacy_playlist_items_fall_back_to_provider_id_without_virtual_id() { assert_eq!(identified_xtream_item.input_stream_id.as_ref(), "explicit-origin-alpha"); assert_eq!(identified_xtream_item.get_input_stream_id().as_deref(), Some("explicit-origin-alpha")); } + +#[test] +fn media_server_item_identity_survives_xtream_round_trip() { + let media_server_item = |id: &str| { + let mut item = PlaylistItem { + header: PlaylistItemHeader { + id: id.intern(), + url: format!("media-server://jellyfin/server/item-{id}").intern(), + input_name: "jellyfin".intern(), + item_type: PlaylistItemType::Video, + xtream_cluster: XtreamCluster::Video, + ..PlaylistItemHeader::default() + }, + }; + item.header.freeze_input_stream_id(); + item.header.gen_uuid(); + item + }; + let first = media_server_item("media-server:server:lib:movie:aaaa"); + let second = media_server_item("media-server:server:lib:movie:bbbbbb"); + + let first_back = PlaylistItem::from(&XtreamPlaylistItem::from(&first)); + let second_back = PlaylistItem::from(&XtreamPlaylistItem::from(&second)); + + assert_eq!(first_back.header.id, first.header.id); + assert_eq!(first_back.header.get_uuid(), first.header.get_uuid()); + assert_ne!(first_back.header.get_uuid(), second_back.header.get_uuid()); +} + +#[test] +fn media_server_item_with_numeric_url_keeps_stable_id_after_xtream_round_trip() { + let mut item = PlaylistItem { + header: PlaylistItemHeader { + id: "media-server:server:lib:movie:14692".intern(), + url: "media-server://plex/server/14692?part_key=%2Fparts%2F1".intern(), + input_name: "plex".intern(), + item_type: PlaylistItemType::Video, + xtream_cluster: XtreamCluster::Video, + ..PlaylistItemHeader::default() + }, + }; + item.header.freeze_input_stream_id(); + item.header.gen_uuid(); + + let xtream_item = XtreamPlaylistItem::from(&item); + let back = PlaylistItem::from(&xtream_item); + + assert_eq!(xtream_item.provider_id, 14692); + assert_eq!(xtream_item.get_uuid(), *item.header.get_uuid()); + assert_eq!(back.header.id, item.header.id); + assert_eq!(back.header.get_uuid(), item.header.get_uuid()); +} diff --git a/shared/src/model/playlist/xtream.rs b/shared/src/model/playlist/xtream.rs index d5d8f3bbe..a20402507 100644 --- a/shared/src/model/playlist/xtream.rs +++ b/shared/src/model/playlist/xtream.rs @@ -325,6 +325,15 @@ impl XtreamPlaylistItem { std::borrow::Cow::Owned(value) => Arc::from(value), }) } + + /// Media-server items are identified by the stable id frozen at input time, not by a provider id. + /// Their URL may end in a numeric segment (Plex rating key, Emby item id) that parses as a provider + /// id, or in none (Jellyfin GUID) that leaves it `0`; the UUID must be the same either way, and + /// without the frozen id all items of the second kind would share one UUID and be removed as + /// duplicates of each other. + pub(crate) fn media_server_identity(&self) -> Option<&Arc> { + (!self.input_stream_id.is_empty() && self.url.starts_with("media-server://")).then_some(&self.input_stream_id) + } } impl PlaylistEntry for XtreamPlaylistItem { @@ -348,7 +357,15 @@ impl PlaylistEntry for XtreamPlaylistItem { #[inline] fn get_uuid(&self) -> UUIDType { - generate_runtime_playlist_uuid(&self.input_name, &self.provider_id.to_string(), self.item_type, &self.url) + match self.media_server_identity() { + Some(id) => generate_runtime_playlist_uuid(&self.input_name, id, self.item_type, &self.url), + None => generate_runtime_playlist_uuid( + &self.input_name, + &self.provider_id.to_string(), + self.item_type, + &self.url, + ), + } } #[inline] fn get_item_type(&self) -> PlaylistItemType { self.item_type } diff --git a/shared/src/model/playlist_document.rs b/shared/src/model/playlist_document.rs index 5eda29766..90df2608c 100644 --- a/shared/src/model/playlist_document.rs +++ b/shared/src/model/playlist_document.rs @@ -550,4 +550,19 @@ mod tests { let item = video_item("http://provider.example/movie/u/p/813563.mkv", None); assert_eq!(video_info_doc_extension(&item).as_ref(), "mkv"); } + + #[test] + fn media_server_movie_logo_becomes_a_resource_proxy_stream_icon() { + let mut item = video_item("media-server://plex/server/14692", Some(video_properties("mp4", false))); + item.logo = + "media-server://image/plex/plex/server/14692?image_path=%2Flibrary%2Fmetadata%2F14692%2Fthumb".intern(); + + let XtreamDocument::Video(doc) = item.to_document(&sample_options()) else { + panic!("expected a video document"); + }; + + assert!(doc.stream_icon.starts_with("http://proxy.example/"), "{}", doc.stream_icon); + assert!(doc.stream_icon.ends_with("/logo"), "{}", doc.stream_icon); + assert!(!doc.stream_icon.contains("media-server"), "{}", doc.stream_icon); + } }