From 667cc2193cb60dff927fc819a57d7a46f93ececd Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:58:39 +0100 Subject: [PATCH] Feature/local series fix (#676) * Local series episodes fix --- backend/src/repository/playlist_repository.rs | 184 +++++++++++++++++- 1 file changed, 181 insertions(+), 3 deletions(-) diff --git a/backend/src/repository/playlist_repository.rs b/backend/src/repository/playlist_repository.rs index b99e60174..a5aa18a60 100644 --- a/backend/src/repository/playlist_repository.rs +++ b/backend/src/repository/playlist_repository.rs @@ -186,9 +186,17 @@ fn assign_provider_series_info_episode_key(provider_series: &mut HashMap, Vec>) { let header = &mut pli.header; - // the local_library_series key is the id of the SeriesInfo. The episodes have their parent SeriesInfo id as parent_code. - // When we populate local_library_series, we use the episodes.parent_code. Here we need to use the SeriesInfo.id to get the assigned episodes. - if let Some(episode_keys) = local_library_series.get(&*header.id) { + // local_library_series key is the Series UUID. + // For LocalSeriesInfo items (series description), header.id is the Series UUID. + // For LocalSeries items (episodes), header.parent_code is the Series UUID. + // We need to use the correct key based on item type. + let lookup_key = if header.item_type == PlaylistItemType::LocalSeries { + header.parent_code.clone() + } else { + header.id.clone() + }; + + if let Some(episode_keys) = local_library_series.get(&*lookup_key) { if let Some(StreamProperties::Series(series)) = header.additional_properties.as_mut() { if let Some(episodes) = series.details.as_mut().and_then(|d| d.episodes.as_mut()) @@ -460,3 +468,173 @@ pub fn get_input_local_library_playlist_file_path(storage_path: &Path, input_nam .collect(); storage_path.join(format!("lib_{sanitized_input_name}.{FILE_SUFFIX_DB}")) } + +#[cfg(test)] +mod tests { + use super::{ + assign_local_series_info_episode_key, rewrite_local_series_info_episode_virtual_id, + rewrite_series_info_episode_virtual_id, LocalEpisodeKey, ProviderEpisodeKey, + }; + use shared::model::{ + PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, + SeriesStreamDetailProperties, SeriesStreamProperties, StreamProperties, XtreamCluster, + }; + use shared::utils::Internable; + use std::{collections::HashMap, sync::Arc}; + + fn make_local_series_info(series_uuid: &str, episodes: Vec<(u32, &str, &str)>) -> PlaylistItem { + let episode_props = episodes + .into_iter() + .map(|(id, title, direct_source)| SeriesStreamDetailEpisodeProperties { + id, + episode_num: 0, + season: 0, + title: title.intern(), + container_extension: "".intern(), + custom_sid: None, + added: "".intern(), + direct_source: direct_source.intern(), + tmdb: None, + release_date: "".intern(), + series_release_date: None, + plot: None, + crew: None, + duration_secs: 0, + duration: "".intern(), + movie_image: "".intern(), + bitrate: 0, + rating: None, + video: None, + audio: None, + }) + .collect(); + + PlaylistItem { + header: PlaylistItemHeader { + id: series_uuid.intern(), + item_type: PlaylistItemType::LocalSeriesInfo, + xtream_cluster: XtreamCluster::Series, + additional_properties: Some(StreamProperties::Series(Box::new(SeriesStreamProperties { + name: "Series".intern(), + details: Some(SeriesStreamDetailProperties { + year: None, + seasons: None, + episodes: Some(episode_props), + }), + ..SeriesStreamProperties::default() + }))), + ..PlaylistItemHeader::default() + }, + } + } + + fn make_local_series_episode(series_uuid: &str, direct_source: &str, virtual_id: u32) -> PlaylistItemHeader { + PlaylistItemHeader { + parent_code: series_uuid.intern(), + url: direct_source.intern(), + item_type: PlaylistItemType::LocalSeries, + xtream_cluster: XtreamCluster::Series, + virtual_id, + ..PlaylistItemHeader::default() + } + } + + #[test] + fn rewrite_local_series_info_uses_series_uuid_lookup_and_updates_episode_virtual_ids() { + let series_uuid = "series-uuid"; + let mut series_info = make_local_series_info( + series_uuid, + vec![(101, "Episode 1", "/library/episode1.mkv"), (202, "Episode 2", "/library/episode2.mkv")], + ); + let mut local_library_series = HashMap::, Vec>::new(); + local_library_series.insert( + series_uuid.intern(), + vec![ + LocalEpisodeKey { path: "/library/episode1.mkv".intern(), virtual_id: 7001 }, + LocalEpisodeKey { path: "/library/episode2.mkv".intern(), virtual_id: 7002 }, + ], + ); + + rewrite_local_series_info_episode_virtual_id(&mut series_info, &local_library_series); + + let Some(StreamProperties::Series(series)) = series_info.header.additional_properties.as_ref() else { + panic!("missing series properties"); + }; + let episodes = series.details.as_ref().and_then(|details| details.episodes.as_ref()).expect("missing episodes"); + assert_eq!(episodes[0].id, 7001); + assert_eq!(episodes[1].id, 7002); + } + + #[test] + fn rewrite_series_info_updates_local_episode_ids_before_parent_code_is_cleared() { + let series_uuid = "series-uuid"; + let mut episode_one = make_local_series_episode(series_uuid, "/library/episode1.mkv", 7001); + let mut episode_two = make_local_series_episode(series_uuid, "/library/episode2.mkv", 7002); + + let mut local_library_series = HashMap::, Vec>::new(); + assign_local_series_info_episode_key(&mut local_library_series, &mut episode_one, PlaylistItemType::LocalSeries); + assign_local_series_info_episode_key(&mut local_library_series, &mut episode_two, PlaylistItemType::LocalSeries); + + let series_info = make_local_series_info( + series_uuid, + vec![(101, "Episode 1", "/library/episode1.mkv"), (202, "Episode 2", "/library/episode2.mkv")], + ); + let local_episode_one = PlaylistItem { header: episode_one }; + let local_episode_two = PlaylistItem { header: episode_two }; + let mut playlist = vec![PlaylistGroup { + id: 1, + title: "Series".intern(), + channels: vec![series_info, local_episode_one, local_episode_two], + xtream_cluster: XtreamCluster::Series, + }]; + + rewrite_series_info_episode_virtual_id(&mut playlist, &local_library_series, &HashMap::, Vec>::new()); + + let Some(StreamProperties::Series(series)) = playlist[0].channels[0].header.additional_properties.as_ref() else { + panic!("missing series properties"); + }; + let episodes = series.details.as_ref().and_then(|details| details.episodes.as_ref()).expect("missing episodes"); + assert_eq!(episodes[0].id, 7001); + assert_eq!(episodes[1].id, 7002); + assert!(playlist[0].channels[1].header.parent_code.is_empty()); + assert!(playlist[0].channels[2].header.parent_code.is_empty()); + } + + #[test] + fn rewrite_series_info_updates_local_episode_ids_when_episodes_come_first() { + // Test with episodes BEFORE series_info to verify iteration-order doesn't matter + let series_uuid = "series-uuid"; + let mut episode_one = make_local_series_episode(series_uuid, "/library/episode1.mkv", 7001); + let mut episode_two = make_local_series_episode(series_uuid, "/library/episode2.mkv", 7002); + + let mut local_library_series = HashMap::, Vec>::new(); + assign_local_series_info_episode_key(&mut local_library_series, &mut episode_one, PlaylistItemType::LocalSeries); + assign_local_series_info_episode_key(&mut local_library_series, &mut episode_two, PlaylistItemType::LocalSeries); + + let series_info = make_local_series_info( + series_uuid, + vec![(101, "Episode 1", "/library/episode1.mkv"), (202, "Episode 2", "/library/episode2.mkv")], + ); + let local_episode_one = PlaylistItem { header: episode_one }; + let local_episode_two = PlaylistItem { header: episode_two }; + + // Episodes FIRST, then series_info (reversed order) + let mut playlist = vec![PlaylistGroup { + id: 1, + title: "Series".intern(), + channels: vec![local_episode_one, local_episode_two, series_info], + xtream_cluster: XtreamCluster::Series, + }]; + + rewrite_series_info_episode_virtual_id(&mut playlist, &local_library_series, &HashMap::, Vec>::new()); + + let Some(StreamProperties::Series(series)) = playlist[0].channels[2].header.additional_properties.as_ref() else { + panic!("missing series properties"); + }; + let episodes = series.details.as_ref().and_then(|details| details.episodes.as_ref()).expect("missing episodes"); + assert_eq!(episodes[0].id, 7001); + assert_eq!(episodes[1].id, 7002); + assert!(playlist[0].channels[0].header.parent_code.is_empty()); + assert!(playlist[0].channels[1].header.parent_code.is_empty()); + } +}