mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-30 18:52:31 +01:00
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:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user