keep distinct item identity and expose posters (#937)

Media-server items shared one UUID in duplicate_filter, so Jellyfin
catalogs collapsed to a single entry. Derive the UUID from the stable
media-server id. Set header.logo for movies, series and episodes so
Xtream and M3U clients show posters.
This commit is contained in:
euzu
2026-10-27 09:37:31 +01:00
committed by GitHub
parent d65409246c
commit ed264f0f4d
8 changed files with 202 additions and 8 deletions
+14
View File
@@ -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
+27 -6
View File
@@ -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::<str>::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::<str>::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::<str>::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");
};
@@ -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);
}
}
@@ -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]
+3 -1
View File
@@ -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()
@@ -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());
}
+18 -1
View File
@@ -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<str>> {
(!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 }
+15
View File
@@ -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);
}
}