diff --git a/backend/src/media_enrichment/facts.rs b/backend/src/media_enrichment/facts.rs new file mode 100644 index 000000000..d0686b691 --- /dev/null +++ b/backend/src/media_enrichment/facts.rs @@ -0,0 +1,156 @@ +use std::sync::Arc; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MediaItemKind { + Movie, + Series, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MediaItemFacts { + pub kind: MediaItemKind, + pub tmdb_id: Option, + pub release_date: Option>, +} + +impl MediaItemFacts { + pub fn new(kind: MediaItemKind, tmdb_id: Option, release_date: Option>) -> Self { + Self { kind, tmdb_id: valid_tmdb_id(tmdb_id), release_date } + } + + pub fn movie(tmdb_id: Option, release_date: Option>) -> Self { + Self::new(MediaItemKind::Movie, tmdb_id, release_date) + } + + pub fn series(tmdb_id: Option, release_date: Option>) -> Self { + Self::new(MediaItemKind::Series, tmdb_id, release_date) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SuppliedMediaFacts { + pub kind: MediaItemKind, + pub tmdb_id: Option, + pub release_date: Option, + pub release_year: Option, +} + +impl SuppliedMediaFacts { + pub fn new( + kind: MediaItemKind, + tmdb_id: Option, + release_date: Option, + release_year: Option, + ) -> Self { + Self { kind, tmdb_id: valid_tmdb_id(tmdb_id), release_date, release_year: valid_year(release_year) } + } + + pub fn from_release_year(kind: MediaItemKind, year: u32) -> Self { Self::new(kind, None, None, Some(year)) } + + fn resolved_release_date(&self) -> Option { + self.release_date.clone().or_else(|| self.release_year.map(synthetic_release_date_from_year)) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MediaFactPatch { + pub tmdb_id: Option, + pub release_date: Option, +} + +impl MediaFactPatch { + pub fn is_empty(&self) -> bool { self.tmdb_id.is_none() && self.release_date.is_none() } +} + +pub fn build_missing_fact_patch(current: &MediaItemFacts, supplied: &SuppliedMediaFacts) -> MediaFactPatch { + if current.kind != supplied.kind { + return MediaFactPatch::default(); + } + + MediaFactPatch { + tmdb_id: current.tmdb_id.is_none().then_some(supplied.tmdb_id).flatten(), + release_date: current.release_date.is_none().then(|| supplied.resolved_release_date()).flatten(), + } +} + +pub fn synthetic_release_date_from_year(year: u32) -> String { format!("{year:04}-01-01") } + +fn valid_tmdb_id(tmdb_id: Option) -> Option { tmdb_id.filter(|id| *id > 0) } + +fn valid_year(year: Option) -> Option { year.filter(|year| (1..=9999).contains(year)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_fact_patch_fills_tmdb_and_synthetic_release_date() { + let current = MediaItemFacts::movie(None, None); + let supplied = SuppliedMediaFacts::new(MediaItemKind::Movie, Some(603), None, Some(1999)); + + let patch = build_missing_fact_patch(¤t, &supplied); + + assert_eq!(patch.tmdb_id, Some(603)); + assert_eq!(patch.release_date.as_deref(), Some("1999-01-01")); + } + + #[test] + fn missing_fact_patch_does_not_overwrite_current_facts() { + let current = MediaItemFacts::series(Some(1396), Some(Arc::::from("2008-01-20"))); + let supplied = SuppliedMediaFacts::new(MediaItemKind::Series, Some(999), None, Some(2009)); + + let patch = build_missing_fact_patch(¤t, &supplied); + + assert!(patch.is_empty()); + } + + #[test] + fn missing_fact_patch_ignores_wrong_media_kind() { + let current = MediaItemFacts::movie(None, None); + let supplied = SuppliedMediaFacts::new(MediaItemKind::Series, Some(1396), None, Some(2008)); + + let patch = build_missing_fact_patch(¤t, &supplied); + + assert!(patch.is_empty()); + } + + #[test] + fn supplied_explicit_release_date_wins_over_year() { + let current = MediaItemFacts::series(None, None); + let supplied = SuppliedMediaFacts::new( + MediaItemKind::Series, + None, + Some("2008-01-20".to_string()), + Some(2009), + ); + + let patch = build_missing_fact_patch(¤t, &supplied); + + assert_eq!(patch.release_date.as_deref(), Some("2008-01-20")); + } + + #[test] + fn zero_ids_and_years_are_not_trusted_facts() { + let current = MediaItemFacts::movie(None, None); + let supplied = SuppliedMediaFacts::new(MediaItemKind::Movie, Some(0), None, Some(0)); + + let patch = build_missing_fact_patch(¤t, &supplied); + + assert!(patch.is_empty()); + } + + #[test] + fn synthetic_release_date_is_zero_padded() { + assert_eq!(synthetic_release_date_from_year(99), "0099-01-01"); + } + + #[test] + fn years_over_9999_are_not_trusted_facts() { + let current = MediaItemFacts::movie(None, None); + let supplied = SuppliedMediaFacts::new(MediaItemKind::Movie, None, None, Some(10000)); + + let patch = build_missing_fact_patch(¤t, &supplied); + + assert!(patch.is_empty()); + } +} diff --git a/backend/src/media_enrichment/mod.rs b/backend/src/media_enrichment/mod.rs new file mode 100644 index 000000000..2c22cb138 --- /dev/null +++ b/backend/src/media_enrichment/mod.rs @@ -0,0 +1,5 @@ +pub mod facts; +pub mod parsed_title; +pub mod policy; +pub mod tmdb; +pub mod xtream; diff --git a/backend/src/media_enrichment/parsed_title.rs b/backend/src/media_enrichment/parsed_title.rs new file mode 100644 index 000000000..bf7ef90ac --- /dev/null +++ b/backend/src/media_enrichment/parsed_title.rs @@ -0,0 +1,30 @@ +use crate::{ + media_enrichment::facts::{MediaItemKind, SuppliedMediaFacts}, + ptt::ptt_parse_title, +}; + +pub fn supplied_release_year_from_title(kind: MediaItemKind, title: &str) -> Option<(u32, SuppliedMediaFacts)> { + let year = ptt_parse_title(title).year?; + Some((year, SuppliedMediaFacts::from_release_year(kind, year))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn supplies_release_year_from_parseable_title() { + let Some((year, supplied)) = supplied_release_year_from_title(MediaItemKind::Movie, "The Matrix 1999") else { + panic!("expected parsed year"); + }; + + assert_eq!(year, 1999); + assert_eq!(supplied.kind, MediaItemKind::Movie); + assert_eq!(supplied.release_year, Some(1999)); + } + + #[test] + fn returns_none_when_title_has_no_year() { + assert!(supplied_release_year_from_title(MediaItemKind::Series, "Breaking Bad").is_none()); + } +} diff --git a/backend/src/media_enrichment/policy.rs b/backend/src/media_enrichment/policy.rs new file mode 100644 index 000000000..e1df8a9a9 --- /dev/null +++ b/backend/src/media_enrichment/policy.rs @@ -0,0 +1,65 @@ +/// Source-neutral policy for fill-only media fact enrichment. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MissingFactEnrichmentPolicy { + missing_facts_enabled: bool, + parsed_title_supplier_enabled: bool, + tmdb_supplier_enabled: bool, +} + +impl MissingFactEnrichmentPolicy { + /// Builds a missing-fact policy from an already translated boundary decision. + // TODO Presa this doesnt makes sense, why do we set all three booleans. This means we only need one + pub fn fill_missing(enabled: bool) -> Self { + Self { + missing_facts_enabled: enabled, + parsed_title_supplier_enabled: enabled, + tmdb_supplier_enabled: enabled, + } + } + + /// Returns true when at least one supported fact family is missing and the policy is enabled. + pub fn should_resolve_missing_facts(&self, missing_tmdb_id: bool, missing_release_date: bool) -> bool { + self.missing_facts_enabled && (missing_tmdb_id || missing_release_date) + } + + /// Returns true when the local parsed-title supplier may contribute temporal facts. + pub fn should_try_parsed_title_supplier(&self, missing_release_date: bool) -> bool { + self.parsed_title_supplier_enabled && missing_release_date + } + + /// Returns true when the TMDB supplier may contribute identity or temporal facts. + pub fn should_try_tmdb_supplier(&self, missing_tmdb_id: bool, missing_release_date: bool) -> bool { + self.tmdb_supplier_enabled && (missing_tmdb_id || missing_release_date) + } + + /// Returns whether calls to the TMDB supplier are allowed by the policy. + pub fn tmdb_supplier_enabled(&self) -> bool { self.tmdb_supplier_enabled } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn disables_missing_fact_resolution_when_policy_is_off() { + let policy = MissingFactEnrichmentPolicy::fill_missing(false); + + assert!(!policy.should_resolve_missing_facts(true, true)); + assert!(!policy.should_try_parsed_title_supplier(true)); + assert!(!policy.should_try_tmdb_supplier(true, true)); + } + + #[test] + fn allows_suppliers_only_for_missing_fact_families() { + let policy = MissingFactEnrichmentPolicy::fill_missing(true); + + assert!(policy.should_resolve_missing_facts(true, false)); + assert!(policy.should_resolve_missing_facts(false, true)); + assert!(!policy.should_resolve_missing_facts(false, false)); + assert!(policy.should_try_parsed_title_supplier(true)); + assert!(!policy.should_try_parsed_title_supplier(false)); + assert!(policy.should_try_tmdb_supplier(true, false)); + assert!(policy.should_try_tmdb_supplier(false, true)); + assert!(!policy.should_try_tmdb_supplier(false, false)); + } +} diff --git a/backend/src/media_enrichment/tmdb.rs b/backend/src/media_enrichment/tmdb.rs new file mode 100644 index 000000000..03685b616 --- /dev/null +++ b/backend/src/media_enrichment/tmdb.rs @@ -0,0 +1,45 @@ +use crate::{ + library::MediaMetadata, + media_enrichment::facts::{MediaItemKind, SuppliedMediaFacts}, +}; + +pub fn supplied_facts_from_metadata(metadata: &MediaMetadata) -> SuppliedMediaFacts { + let kind = if metadata.is_movie() { MediaItemKind::Movie } else { MediaItemKind::Series }; + SuppliedMediaFacts::new(kind, metadata.tmdb_id(), None, metadata.year()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::library::{MediaMetadata, MovieMetadata, SeriesMetadata}; + + #[test] + fn maps_movie_metadata_to_supplied_facts() { + let metadata = MediaMetadata::Movie(MovieMetadata { + tmdb_id: Some(603), + year: Some(1999), + ..MovieMetadata::default() + }); + + let supplied = supplied_facts_from_metadata(&metadata); + + assert_eq!(supplied.kind, MediaItemKind::Movie); + assert_eq!(supplied.tmdb_id, Some(603)); + assert_eq!(supplied.release_year, Some(1999)); + } + + #[test] + fn maps_series_metadata_to_supplied_facts() { + let metadata = MediaMetadata::Series(SeriesMetadata { + tmdb_id: Some(1396), + year: Some(2008), + ..SeriesMetadata::default() + }); + + let supplied = supplied_facts_from_metadata(&metadata); + + assert_eq!(supplied.kind, MediaItemKind::Series); + assert_eq!(supplied.tmdb_id, Some(1396)); + assert_eq!(supplied.release_year, Some(2008)); + } +} diff --git a/backend/src/media_enrichment/xtream.rs b/backend/src/media_enrichment/xtream.rs new file mode 100644 index 000000000..0988aedea --- /dev/null +++ b/backend/src/media_enrichment/xtream.rs @@ -0,0 +1,318 @@ +use crate::{ + library::MediaMetadata, + media_enrichment::{ + facts::{build_missing_fact_patch, MediaFactPatch, MediaItemFacts}, + parsed_title::supplied_release_year_from_title, + tmdb::supplied_facts_from_metadata, + }, +}; +use shared::model::{SeriesStreamProperties, VideoStreamDetailProperties, VideoStreamProperties}; +use std::sync::Arc; + +trait FactSource { + fn current_facts(&self) -> MediaItemFacts; +} + +impl FactSource for VideoStreamProperties { + fn current_facts(&self) -> MediaItemFacts { video_current_facts(self) } +} + +impl FactSource for SeriesStreamProperties { + fn current_facts(&self) -> MediaItemFacts { series_current_facts(self) } +} + +fn fact_patch_from_metadata(properties: &S, metadata: &MediaMetadata) -> MediaFactPatch { + build_missing_fact_patch(&properties.current_facts(), &supplied_facts_from_metadata(metadata)) +} + +fn fact_patch_from_title(properties: &S, title: &str) -> Option<(u32, MediaFactPatch)> { + let current = properties.current_facts(); + let (year, supplied) = supplied_release_year_from_title(current.kind, title)?; + Some((year, build_missing_fact_patch(¤t, &supplied))) +} + +fn fact_patch_from_title_candidates<'a, S, I>(properties: &S, titles: I) -> Option<(&'a str, u32, MediaFactPatch)> +where + S: FactSource, + I: IntoIterator, +{ + titles.into_iter().filter(|title| !title.is_empty()).find_map(|title| { + let (year, patch) = fact_patch_from_title(properties, title)?; + (!patch.is_empty()).then_some((title, year, patch)) + }) +} + +pub fn distinct_non_empty_title_candidates<'a, I>(titles: I) -> Vec<&'a str> +where + I: IntoIterator>, +{ + let mut result = Vec::new(); + for title in titles.into_iter().flatten().filter(|title| !title.is_empty()) { + if !result.contains(&title) { + result.push(title); + } + } + result +} + +pub fn video_fact_patch_from_metadata( + properties: &VideoStreamProperties, + metadata: &MediaMetadata, +) -> MediaFactPatch { + fact_patch_from_metadata(properties, metadata) +} + +pub fn video_fact_patch_from_title( + properties: &VideoStreamProperties, + title: &str, +) -> Option<(u32, MediaFactPatch)> { + fact_patch_from_title(properties, title) +} + +pub fn video_fact_patch_from_title_candidates<'a, I>( + properties: &VideoStreamProperties, + titles: I, +) -> Option<(&'a str, u32, MediaFactPatch)> +where + I: IntoIterator, +{ + fact_patch_from_title_candidates(properties, titles) +} + +pub fn series_fact_patch_from_metadata( + properties: &SeriesStreamProperties, + metadata: &MediaMetadata, +) -> MediaFactPatch { + fact_patch_from_metadata(properties, metadata) +} + +pub fn series_fact_patch_from_title( + properties: &SeriesStreamProperties, + title: &str, +) -> Option<(u32, MediaFactPatch)> { + fact_patch_from_title(properties, title) +} + +pub fn series_fact_patch_from_title_candidates<'a, I>( + properties: &SeriesStreamProperties, + titles: I, +) -> Option<(&'a str, u32, MediaFactPatch)> +where + I: IntoIterator, +{ + fact_patch_from_title_candidates(properties, titles) +} + +pub fn apply_fact_patch_to_video(properties: &mut VideoStreamProperties, patch: &MediaFactPatch) -> bool { + let mut changed = false; + + if properties.tmdb.is_none() { + if let Some(tmdb_id) = patch.tmdb_id { + properties.tmdb = Some(tmdb_id); + changed = true; + } + } + + if let Some(release_date) = patch.release_date.as_deref() { + match properties.details.as_mut() { + Some(details) if details.release_date.is_none() => { + details.release_date = Some(Arc::::from(release_date)); + changed = true; + } + None => { + properties.details = Some(VideoStreamDetailProperties { + release_date: Some(Arc::::from(release_date)), + ..VideoStreamDetailProperties::default() + }); + changed = true; + } + Some(_) => {} + } + } + + changed +} + +pub fn apply_fact_patch_to_series(properties: &mut SeriesStreamProperties, patch: &MediaFactPatch) -> bool { + let mut changed = false; + + if properties.tmdb.is_none() { + if let Some(tmdb_id) = patch.tmdb_id { + properties.tmdb = Some(tmdb_id); + changed = true; + } + } + + if properties.release_date.is_none() { + if let Some(release_date) = patch.release_date.as_deref() { + properties.release_date = Some(Arc::::from(release_date)); + changed = true; + } + } + + changed +} + +fn video_current_facts(properties: &VideoStreamProperties) -> MediaItemFacts { + MediaItemFacts::movie( + properties.tmdb, + properties.details.as_ref().and_then(|details| details.release_date.as_ref()).map(Arc::clone), + ) +} + +fn series_current_facts(properties: &SeriesStreamProperties) -> MediaItemFacts { + MediaItemFacts::series(properties.tmdb, properties.release_date.as_ref().map(Arc::clone)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn applies_video_patch_without_overwriting_existing_facts() { + let mut properties = VideoStreamProperties { + tmdb: Some(603), + details: Some(VideoStreamDetailProperties { + release_date: Some("1999-03-31".into()), + ..VideoStreamDetailProperties::default() + }), + ..VideoStreamProperties::default() + }; + let patch = MediaFactPatch { tmdb_id: Some(999), release_date: Some("2000-01-01".to_string()) }; + + assert!(!apply_fact_patch_to_video(&mut properties, &patch)); + assert_eq!(properties.tmdb, Some(603)); + assert_eq!( + properties.details.as_ref().and_then(|details| details.release_date.as_deref()), + Some("1999-03-31") + ); + } + + #[test] + fn builds_video_patch_from_parseable_title() { + let properties = VideoStreamProperties::default(); + let Some((year, patch)) = video_fact_patch_from_title(&properties, "The Matrix 1999") else { + panic!("expected title year patch"); + }; + + assert_eq!(year, 1999); + assert_eq!(patch.release_date.as_deref(), Some("1999-01-01")); + } + + #[test] + fn builds_video_patch_from_first_parseable_title_candidate() { + let properties = VideoStreamProperties { tmdb: Some(603), ..VideoStreamProperties::default() }; + + let Some((title, year, patch)) = + video_fact_patch_from_title_candidates(&properties, ["No year", "The Matrix 1999"]) + else { + panic!("expected fallback title year patch"); + }; + + assert_eq!(title, "The Matrix 1999"); + assert_eq!(year, 1999); + assert_eq!(patch.release_date.as_deref(), Some("1999-01-01")); + assert!(patch.tmdb_id.is_none()); + } + + #[test] + fn title_candidates_drop_empty_values_and_preserve_first_distinct_names() { + let candidates = distinct_non_empty_title_candidates([ + Some(""), + Some("The Matrix 1999"), + Some("The Matrix 1999"), + Some("Matrix 1999"), + ]); + + assert_eq!(candidates, vec!["The Matrix 1999", "Matrix 1999"]); + } + + #[test] + fn video_title_candidates_skip_empty_patches_after_current_facts_are_complete() { + let properties = VideoStreamProperties { + tmdb: Some(603), + details: Some(VideoStreamDetailProperties { + release_date: Some("1999-03-31".into()), + ..VideoStreamDetailProperties::default() + }), + ..VideoStreamProperties::default() + }; + + assert!(video_fact_patch_from_title_candidates(&properties, ["The Matrix 1999"]).is_none()); + } + + #[test] + fn applies_video_patch_to_missing_facts() { + let mut properties = VideoStreamProperties::default(); + let patch = MediaFactPatch { tmdb_id: Some(603), release_date: Some("1999-01-01".to_string()) }; + + assert!(apply_fact_patch_to_video(&mut properties, &patch)); + assert_eq!(properties.tmdb, Some(603)); + assert_eq!( + properties.details.as_ref().and_then(|details| details.release_date.as_deref()), + Some("1999-01-01") + ); + } + + #[test] + fn applies_series_patch_without_overwriting_existing_facts() { + let mut properties = SeriesStreamProperties { + tmdb: Some(1396), + release_date: Some("2008-01-20".into()), + ..SeriesStreamProperties::default() + }; + let patch = MediaFactPatch { tmdb_id: Some(999), release_date: Some("2009-01-01".to_string()) }; + + assert!(!apply_fact_patch_to_series(&mut properties, &patch)); + assert_eq!(properties.tmdb, Some(1396)); + assert_eq!(properties.release_date.as_deref(), Some("2008-01-20")); + } + + #[test] + fn builds_series_patch_from_parseable_title() { + let properties = SeriesStreamProperties::default(); + let Some((year, patch)) = series_fact_patch_from_title(&properties, "Breaking Bad 2008") else { + panic!("expected title year patch"); + }; + + assert_eq!(year, 2008); + assert_eq!(patch.release_date.as_deref(), Some("2008-01-01")); + } + + #[test] + fn builds_series_patch_from_first_parseable_title_candidate() { + let properties = SeriesStreamProperties { tmdb: Some(1396), ..SeriesStreamProperties::default() }; + + let Some((title, year, patch)) = + series_fact_patch_from_title_candidates(&properties, ["No year", "Breaking Bad 2008"]) + else { + panic!("expected fallback title year patch"); + }; + + assert_eq!(title, "Breaking Bad 2008"); + assert_eq!(year, 2008); + assert_eq!(patch.release_date.as_deref(), Some("2008-01-01")); + assert!(patch.tmdb_id.is_none()); + } + + #[test] + fn series_title_candidates_skip_empty_patches_after_current_facts_are_complete() { + let properties = SeriesStreamProperties { + tmdb: Some(1396), + release_date: Some("2008-01-20".into()), + ..SeriesStreamProperties::default() + }; + + assert!(series_fact_patch_from_title_candidates(&properties, ["Breaking Bad 2008"]).is_none()); + } + + #[test] + fn applies_series_patch_to_missing_facts() { + let mut properties = SeriesStreamProperties::default(); + let patch = MediaFactPatch { tmdb_id: Some(1396), release_date: Some("2008-01-01".to_string()) }; + + assert!(apply_fact_patch_to_series(&mut properties, &patch)); + assert_eq!(properties.tmdb, Some(1396)); + assert_eq!(properties.release_date.as_deref(), Some("2008-01-01")); + } +} diff --git a/backend/src/modules.rs b/backend/src/modules.rs index 7b49c7f2a..6b28ba5a8 100644 --- a/backend/src/modules.rs +++ b/backend/src/modules.rs @@ -7,6 +7,7 @@ macro_rules! include_modules { pub mod api; pub mod auth; pub mod library; + pub mod media_enrichment; pub mod media_server; pub mod messaging; pub mod model; diff --git a/backend/src/processing/processor/xtream_series.rs b/backend/src/processing/processor/xtream_series.rs index 7787b829f..c0409c9a4 100644 --- a/backend/src/processing/processor/xtream_series.rs +++ b/backend/src/processing/processor/xtream_series.rs @@ -1,6 +1,10 @@ use crate::api::model::UpdateTask; use crate::api::model::{ActiveProviderManager, ProviderHandle, ProviderIdType, ResolveReason, ResolveReasonSet}; use crate::library::{MetadataResolver, MetadataStorage}; +use crate::media_enrichment::policy::MissingFactEnrichmentPolicy; +use crate::media_enrichment::xtream::{ + apply_fact_patch_to_series, series_fact_patch_from_metadata, series_fact_patch_from_title_candidates, +}; use crate::model::FetchedPlaylist; use crate::model::{AppConfig, ConfigTarget, MetadataUpdateConfig}; use crate::model::{ConfigInput, ConfigInputFlags, InputSource}; @@ -13,7 +17,6 @@ use crate::processing::processor::{ ResolveOptions, ResolveOptionsFlags, FOREGROUND_BATCH_SIZE as BATCH_SIZE, FOREGROUND_MIN_RETRY_DELAY_SECS, FOREGROUND_RETRY_BATCH_MAX_SIZE as RETRY_BATCH_MAX_SIZE, }; -use crate::ptt::ptt_parse_title; use crate::repository::persists_input_series_info; use crate::repository::{ get_input_storage_path, persist_input_series_info_batch, MemoryPlaylistSource, PlaylistSource, @@ -551,7 +554,7 @@ async fn update_series_info_immediate( probe_settings: SeriesProbeSettings, ) -> Result, TuliproxError> { let fetch_info = reasons.contains(ResolveReason::Info); - let resolve_tmdb = reasons.contains(ResolveReason::Tmdb) || reasons.contains(ResolveReason::Date); + let resolve_missing_facts = reasons.contains(ResolveReason::Tmdb) || reasons.contains(ResolveReason::Date); update_series_metadata( &ctx.config, @@ -563,7 +566,7 @@ async fn update_series_info_immediate( Some(&pli.header.title), false, // save (we batch in caller) fetch_info, - resolve_tmdb, + resolve_missing_facts, reasons.contains(ResolveReason::Probe), probe_settings, db_query, @@ -648,7 +651,7 @@ fn check_needs_probe(pli: &PlaylistItem, reasons: &mut ResolveReasonSet) { /// * `save` - If true, persists changes to the input database immediately (Instant strategy). /// If false, returns the properties so the caller can batch persist them (Bundled strategy). /// * `fetch_info` - If true, fetches details from Provider API. If false, uses existing/dummy data. -/// * `resolve_tmdb` - If true, resolves missing TMDB/date metadata from available titles. +/// * `resolve_missing_facts` - If true, resolves missing TMDB/date facts from available titles. /// * `db_query` - Optional pre-opened DB handle to avoid re-opening file. #[allow(clippy::too_many_arguments, clippy::too_many_lines, clippy::fn_params_excessive_bools)] pub async fn update_series_metadata( @@ -661,7 +664,7 @@ pub async fn update_series_metadata( playlist_title: Option<&str>, save: bool, fetch_info: bool, - resolve_tmdb: bool, + resolve_missing_facts: bool, do_probe: bool, probe_settings: SeriesProbeSettings, db_query: Option>>>, @@ -819,69 +822,78 @@ pub async fn update_series_metadata( shared::error::TuliproxError::Config(format!("No Series properties available after fallback creation for {display_id}")) })?; - let resolve_tmdb_enabled = input.has_flag(ConfigInputFlags::ResolveTmdb); + // Xtream's legacy ResolveTmdb input flag currently gates missing identity/temporal fact enrichment. + let missing_fact_policy = + MissingFactEnrichmentPolicy::fill_missing(resolve_missing_facts && input.has_flag(ConfigInputFlags::ResolveTmdb)); - // 2. Resolve TMDB/Date if missing - if resolve_tmdb - && resolve_tmdb_enabled - && (properties.tmdb.is_none() || properties.release_date.is_none()) - && !properties.name.is_empty() + // 2. Resolve missing TMDB/date facts if allowed for this input + let title_candidate = playlist_title.or_else(|| existing_item.as_ref().map(|i| i.title.as_ref())); + let has_title_candidate = title_candidate.is_some_and(|title| !title.is_empty()) || !properties.name.is_empty(); + + if missing_fact_policy.should_resolve_missing_facts(properties.tmdb.is_none(), properties.release_date.is_none()) + && has_title_candidate { - let config = app_config.config.load(); - let library_config = config.library.as_ref(); - let metadata_update_config = config.metadata_update.as_ref(); - let tmdb_storage = metadata_update_config - .filter(|cfg| cfg.tmdb.enabled) - .map(|cfg| MetadataStorage::new(std::path::PathBuf::from(&cfg.cache_path))); - let meta_resolver = - MetadataResolver::from_config(library_config, metadata_update_config, client.clone(), tmdb_storage); - - let mut meta = None; - let mut tried_title = false; - // Try extracting year locally first - if properties.release_date.is_none() { - let meta_parse = ptt_parse_title(&properties.name); - if let Some(year) = meta_parse.year { - properties.release_date = Some(format!("{year}-01-01").into()); - properties_updated = true; - debug_if_enabled!("Parsed local year for Series '{}': {}", properties.name, year); + if missing_fact_policy.should_try_parsed_title_supplier(properties.release_date.is_none()) { + let local_patch = { + let candidates = [title_candidate, (!properties.name.is_empty()).then_some(properties.name.as_ref())]; + series_fact_patch_from_title_candidates(&properties, candidates.into_iter().flatten()) + }; + if let Some((title, year, patch)) = local_patch { + debug_if_enabled!("Parsed local year for Series '{}': {}", title, year); + if apply_fact_patch_to_series(&mut properties, &patch) { + properties_updated = true; + } } } - // 1. & 2. Playlist Title - let title_candidate = playlist_title.or_else(|| existing_item.as_ref().map(|i| i.title.as_ref())); - if let Some(title) = title_candidate { - if !title.is_empty() { - trace!("Resolving TMDB for Series using Playlist Title '{title}' (ID: {display_id})..."); - meta = meta_resolver.resolve_from_title(title, properties.tmdb, false, resolve_tmdb_enabled).await; - tried_title = true; - } - } + if missing_fact_policy.should_try_tmdb_supplier(properties.tmdb.is_none(), properties.release_date.is_none()) { + let config = app_config.config.load(); + let library_config = config.library.as_ref(); + let metadata_update_config = config.metadata_update.as_ref(); + let tmdb_storage = metadata_update_config + .filter(|cfg| cfg.tmdb.enabled) + .map(|cfg| MetadataStorage::new(std::path::PathBuf::from(&cfg.cache_path))); + let meta_resolver = + MetadataResolver::from_config(library_config, metadata_update_config, client.clone(), tmdb_storage); - // 3. API Name (fallback) - if (meta.is_none() || (meta.as_ref().is_some_and(|m| m.tmdb_id().is_none()))) && !properties.name.is_empty() { - let title_already_tried = if let Some(t) = title_candidate { t == properties.name.as_ref() } else { false }; - if !tried_title || !title_already_tried { - debug!("Fallback to API Name '{}'...", properties.name); - meta = meta_resolver - .resolve_from_title(&properties.name, properties.tmdb, false, resolve_tmdb_enabled) - .await; - } - } + let mut meta = None; + let mut tried_title = false; - if let Some(m) = meta { - if properties.tmdb.is_none() { - properties.tmdb = m.tmdb_id(); - properties_updated = true; + // 1. & 2. Playlist Title + if let Some(title) = title_candidate { + if !title.is_empty() { + trace!("Resolving TMDB for Series using Playlist Title '{title}' (ID: {display_id})..."); + meta = meta_resolver + .resolve_from_title(title, properties.tmdb, false, true) + .await; + tried_title = true; + } } - if properties.release_date.is_none() { - properties.release_date = m.year().map(|y| format!("{y}-01-01").into()); - properties_updated = true; + + // 3. API Name (fallback) + if (meta.is_none() || (meta.as_ref().is_some_and(|m| m.tmdb_id().is_none()))) && !properties.name.is_empty() { + let title_already_tried = if let Some(t) = title_candidate { t == properties.name.as_ref() } else { false }; + if !tried_title || !title_already_tried { + debug!("Fallback to API Name '{}'...", properties.name); + meta = meta_resolver + .resolve_from_title( + &properties.name, + properties.tmdb, + false, + true, + ) + .await; + } } - if properties_updated { - let id_display = properties.tmdb.map_or("None".to_string(), |id| id.to_string()); - debug_if_enabled!("Resolved TMDB for Series ID {}: {}", display_id, id_display); + + if let Some(m) = meta { + let patch = series_fact_patch_from_metadata(&properties, &m); + if apply_fact_patch_to_series(&mut properties, &patch) { + properties_updated = true; + let id_display = properties.tmdb.map_or("None".to_string(), |id| id.to_string()); + debug_if_enabled!("Resolved TMDB for Series ID {}: {}", display_id, id_display); + } } } } @@ -1067,7 +1079,7 @@ pub async fn update_series_metadata( out.store(probe_pending, Ordering::Relaxed); } - let probe_only_unresolved = do_probe && !fetch_info && !resolve_tmdb && !properties_updated && !fetched_new; + let probe_only_unresolved = do_probe && !fetch_info && !resolve_missing_facts && !properties_updated && !fetched_new; if probe_only_unresolved { if let Some(kind) = probe_failure { let err = match kind { diff --git a/backend/src/processing/processor/xtream_vod.rs b/backend/src/processing/processor/xtream_vod.rs index eb2b63234..d5645d98c 100644 --- a/backend/src/processing/processor/xtream_vod.rs +++ b/backend/src/processing/processor/xtream_vod.rs @@ -1,6 +1,11 @@ use crate::api::model::{ActiveProviderManager, ProviderHandle}; use crate::api::model::{ProviderIdType, ResolveReason, ResolveReasonSet, UpdateTask}; -use crate::library::{MetadataResolver, MetadataStorage}; +use crate::library::{MediaMetadata, MetadataResolver, MetadataStorage}; +use crate::media_enrichment::policy::MissingFactEnrichmentPolicy; +use crate::media_enrichment::xtream::{ + apply_fact_patch_to_video, distinct_non_empty_title_candidates, video_fact_patch_from_metadata, + video_fact_patch_from_title_candidates, +}; use crate::model::FetchedPlaylist; use crate::model::InputSource; use crate::model::{AppConfig, ConfigTarget}; @@ -13,7 +18,6 @@ use crate::processing::processor::{ ResolveOptions, ResolveOptionsFlags, FOREGROUND_BATCH_SIZE as BATCH_SIZE, FOREGROUND_MIN_RETRY_DELAY_SECS, FOREGROUND_RETRY_BATCH_MAX_SIZE as RETRY_BATCH_MAX_SIZE, }; -use crate::ptt::ptt_parse_title; use crate::repository::persist_input_vod_info; use crate::repository::persist_input_vod_info_batch; use crate::repository::{xtream_get_file_path, BPlusTreeQuery}; @@ -510,7 +514,7 @@ async fn update_vod_info_immediate( db_query: Option>>>, ) -> Result, TuliproxError> { let fetch_info = reasons.contains(ResolveReason::Info); - let resolve_tmdb = + let resolve_missing_facts = fetch_info || reasons.contains(ResolveReason::Tmdb) || reasons.contains(ResolveReason::Date); update_vod_metadata( @@ -523,7 +527,7 @@ async fn update_vod_info_immediate( Some(&pli.header.title), false, fetch_info, - resolve_tmdb, + resolve_missing_facts, reasons.contains(ResolveReason::Probe), db_query, None, @@ -538,7 +542,7 @@ async fn update_vod_info_immediate( /// * `save` - If true, persists changes to the input database immediately (Instant strategy). /// If false, returns the properties so the caller can batch persist them (Bundled strategy). /// * `fetch_info` - If true, fetches details from Provider API. If false, uses existing/dummy data. -/// * `resolve_tmdb` - If true, resolves missing TMDB/date metadata from available titles. +/// * `resolve_missing_facts` - If true, resolves missing TMDB/date facts from available titles. /// * `db_query` - Optional pre-opened DB handle to avoid re-opening file. #[allow(clippy::too_many_arguments, clippy::too_many_lines, clippy::fn_params_excessive_bools)] pub async fn update_vod_metadata( @@ -551,7 +555,7 @@ pub async fn update_vod_metadata( playlist_title: Option<&str>, save: bool, fetch_info: bool, - resolve_tmdb: bool, + resolve_missing_facts: bool, do_probe: bool, db_query: Option>>>, tmdb_resolved_out: Option<&AtomicBool>, @@ -700,24 +704,31 @@ pub async fn update_vod_metadata( return Err(shared::error::TuliproxError::Config(format!("No VOD properties available after fallback creation for {display_id}"))); }; - let resolve_tmdb_enabled = input.has_flag(ConfigInputFlags::ResolveTmdb); + // Xtream's legacy ResolveTmdb input flag currently gates missing identity/temporal fact enrichment. + let missing_fact_policy = + MissingFactEnrichmentPolicy::fill_missing(resolve_missing_facts && input.has_flag(ConfigInputFlags::ResolveTmdb)); - // 2. Resolve TMDB/Date if missing and explicitly enabled for this input + // 2. Resolve missing TMDB/date facts if allowed for this input let missing_tmdb = properties.tmdb.is_none(); let missing_date = properties.details.as_ref().and_then(|d| d.release_date.as_ref()).is_none(); - if resolve_tmdb && resolve_tmdb_enabled && (missing_tmdb || missing_date) { + if missing_fact_policy.should_resolve_missing_facts(missing_tmdb, missing_date) { // Try local parsing first - if missing_date && !properties.name.is_empty() { - let meta_parse = ptt_parse_title(&properties.name); - if let Some(year) = meta_parse.year { - if properties.details.is_none() { - properties.details = Some(VideoStreamDetailProperties::default()); - } - if let Some(details) = properties.details.as_mut() { - details.release_date = Some(format!("{year}-01-01").into()); + if missing_fact_policy.should_try_parsed_title_supplier(missing_date) { + let local_patch = { + let original_name_candidate = properties.details.as_ref().and_then(|d| d.o_name.as_deref()); + let candidates = distinct_non_empty_title_candidates([ + playlist_title, + existing_item.as_ref().map(|i| i.title.as_ref()), + (!properties.name.is_empty()).then_some(properties.name.as_ref()), + original_name_candidate, + ]); + video_fact_patch_from_title_candidates(&properties, candidates) + }; + if let Some((title, year, patch)) = local_patch { + debug_if_enabled!("Parsed local year for '{}': {}", title, year); + if apply_fact_patch_to_video(&mut properties, &patch) { properties_updated = true; - debug_if_enabled!("Parsed local year for '{}': {}", properties.name, year); } } } @@ -725,7 +736,7 @@ pub async fn update_vod_metadata( // Re-check missing date after local parse let still_missing_date = properties.details.as_ref().and_then(|d| d.release_date.as_ref()).is_none(); - if missing_tmdb || still_missing_date { + if missing_fact_policy.should_try_tmdb_supplier(missing_tmdb, still_missing_date) { let config = app_config.config.load(); let library_config = config.library.as_ref(); let metadata_update_config = config.metadata_update.as_ref(); @@ -735,61 +746,33 @@ pub async fn update_vod_metadata( let meta_resolver = MetadataResolver::from_config(library_config, metadata_update_config, client.clone(), tmdb_storage); - let mut meta = None; - let mut tried_title = false; + let mut meta: Option = None; + let original_name_candidate = properties.details.as_ref().and_then(|d| d.o_name.as_deref()); + let candidates = distinct_non_empty_title_candidates([ + playlist_title, + existing_item.as_ref().map(|i| i.title.as_ref()), + (!properties.name.is_empty()).then_some(properties.name.as_ref()), + original_name_candidate, + ]); - // 1. & 2. Playlist Title - let title_candidate = playlist_title.or_else(|| existing_item.as_ref().map(|i| i.title.as_ref())); - if let Some(title) = title_candidate { - if !title.is_empty() { + for title in candidates { + if meta.as_ref().is_some_and(|m| m.tmdb_id().is_some()) { + break; + } + if Some(title) == playlist_title || existing_item.as_ref().is_some_and(|item| item.title.as_ref() == title) { debug!("Resolving TMDB for VOD using Playlist Title '{title}' (ID: {display_id})..."); - meta = meta_resolver.resolve_from_title(title, properties.tmdb, true, resolve_tmdb_enabled).await; - tried_title = true; - } - } - - // 3. API Name (fallback) - if (meta.is_none() || (meta.as_ref().is_some_and(|m| m.tmdb_id().is_none()))) && !properties.name.is_empty() - { - let title_already_tried = - if let Some(t) = title_candidate { t == properties.name.as_ref() } else { false }; - - if !tried_title || !title_already_tried { + } else if title == properties.name.as_ref() { trace!("Fallback to API Name '{}'...", properties.name); - meta = meta_resolver - .resolve_from_title(&properties.name, properties.tmdb, true, resolve_tmdb_enabled) - .await; - } - } - - // 4. API Original Name (fallback) - if meta.is_none() || (meta.as_ref().is_some_and(|m| m.tmdb_id().is_none())) { - if let Some(o_name) = properties.details.as_ref().and_then(|d| d.o_name.as_deref()) { - if !o_name.is_empty() && o_name != properties.name.as_ref() { - trace!("Fallback to API Original Name '{o_name}'..."); - meta = - meta_resolver.resolve_from_title(o_name, properties.tmdb, true, resolve_tmdb_enabled).await; - } + } else { + trace!("Fallback to API Original Name '{title}'..."); } + meta = meta_resolver.resolve_from_title(title, properties.tmdb, true, true).await; } if let Some(m) = meta { - if properties.tmdb.is_none() { - properties.tmdb = m.tmdb_id(); + let patch = video_fact_patch_from_metadata(&properties, &m); + if apply_fact_patch_to_video(&mut properties, &patch) { properties_updated = true; - } - if properties.details.is_none() { - properties.details = Some(VideoStreamDetailProperties::default()); - } - if let Some(details) = properties.details.as_mut() { - if details.release_date.is_none() { - if let Some(year) = m.year() { - details.release_date = Some(format!("{year}-01-01").into()); - properties_updated = true; - } - } - } - if properties_updated { let id_display = properties.tmdb.map_or("None".to_string(), |id| id.to_string()); trace_if_enabled!("Resolved TMDB for '{}' (ID: {}): {}", display_title, display_id, id_display); } @@ -963,7 +946,7 @@ pub async fn update_vod_metadata( out.store(probe_pending, Ordering::Relaxed); } - let probe_only_unresolved = do_probe && !fetch_info && !resolve_tmdb && !properties_updated && !fetched_new; + let probe_only_unresolved = do_probe && !fetch_info && !resolve_missing_facts && !properties_updated && !fetched_new; if probe_only_unresolved { if let Some(kind) = probe_failure { let err = match kind { diff --git a/shared/src/model/playlist_info_document.rs b/shared/src/model/playlist_info_document.rs index fd78657bc..6a99f111e 100644 --- a/shared/src/model/playlist_info_document.rs +++ b/shared/src/model/playlist_info_document.rs @@ -212,6 +212,8 @@ pub struct XtreamSeriesEpisodeInfoData { pub rating: f64, #[serde(rename = "id")] pub tmdb_id: u32, + #[serde(default, with = "arc_str_option_serde")] + pub plot: Option>, #[serde(with = "arc_str_serde")] pub movie_image: Arc, pub duration_secs: u32, @@ -521,6 +523,7 @@ impl StreamProperties { tmdb_id: ep.tmdb.unwrap_or_default(), air_date: Arc::clone(&ep.release_date), crew: ep.crew.as_ref().map(Arc::clone), + plot: ep.plot.as_ref().map(Arc::clone), rating: ep.rating.unwrap_or_default(), movie_image: options .get_resource_url( @@ -600,3 +603,67 @@ impl Default for XtreamVideoInfoDoc { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{PlaylistItemTypeSet, SeriesStreamDetailProperties}; + + fn sample_options() -> XtreamMappingOptions { + XtreamMappingOptions { + flags: XtreamMappingFlags::RewriteResourceUrl.into(), + force_redirect: None, + reverse_item_types: PlaylistItemTypeSet::empty(), + resource_proxy_item_types: PlaylistItemTypeSet::empty(), + username: "user".to_string(), + password: "pass".to_string(), + base_url: "http://proxy.example".to_string(), + web_ui_request: false, + encrypt_secret: [0u8; 16], + } + } + + #[test] + fn series_episode_info_document_includes_episode_plot() { + let properties = StreamProperties::Series(Box::new(SeriesStreamProperties { + name: "Example Show".into(), + series_id: 1, + details: Some(SeriesStreamDetailProperties { + year: None, + seasons: None, + episodes: Some(vec![SeriesStreamDetailEpisodeProperties { + id: 101, + episode_num: 1, + season: 1, + title: "Pilot".into(), + container_extension: "mkv".into(), + custom_sid: None, + added: "1700000000".into(), + direct_source: "".into(), + tmdb: None, + release_date: "2024-01-01".into(), + series_release_date: None, + plot: Some("Episode plot".into()), + crew: None, + duration_secs: 0, + duration: "".into(), + movie_image: "".into(), + bitrate: 0, + rating: None, + video: None, + audio: None, + }]), + }), + ..SeriesStreamProperties::default() + })); + + let XtreamInfoDocument::Series(doc) = + properties.to_info_document(&sample_options(), PlaylistItemType::SeriesInfo, 42, 7) + else { + panic!("expected series info document"); + }; + + let episode = doc.episodes.get("1").and_then(|episodes| episodes.first()).expect("episode missing"); + assert_eq!(episode.info.plot.as_deref(), Some("Episode plot")); + } +} diff --git a/shared/src/model/stream_properties.rs b/shared/src/model/stream_properties.rs index 72c61c17b..362f46bbd 100644 --- a/shared/src/model/stream_properties.rs +++ b/shared/src/model/stream_properties.rs @@ -784,7 +784,7 @@ impl SeriesStreamProperties { tmdb: info.info.tmdb, release_date: e.info.as_ref().map(|i| i.air_date.clone()).unwrap_or_default(), series_release_date: None, - plot: None, + plot: e.info.as_ref().and_then(|i| i.plot.clone()), crew: e.info.as_ref().map(|i| i.crew.clone()), duration_secs: e.info.as_ref().map(|i| i.duration_secs).unwrap_or_default(), duration: e.info.as_ref().map(|i| i.duration.clone()).unwrap_or_default(), @@ -885,7 +885,7 @@ impl SeriesStreamProperties { tmdb, release_date: e.info.air_date.clone(), series_release_date: None, - plot: None, + plot: e.info.plot.clone(), crew: e.info.crew.clone(), duration_secs: e.info.duration_secs, duration: e.info.duration.clone(), @@ -1023,6 +1023,39 @@ mod tests { assert_eq!(props.is_adult, 1); } + #[test] + fn from_series_info_preserves_episode_plot() { + let info: XtreamSeriesInfo = serde_json::from_value(json!({ + "info": { + "name": "Example Show" + }, + "episodes": { + "1": [{ + "id": 101, + "episode_num": 1, + "season": 1, + "title": "Pilot", + "container_extension": "mkv", + "info": { + "air_date": "2024-01-01", + "plot": "Episode plot", + "movie_image": "https://img.example/still.jpg" + } + }] + } + })) + .expect("sample XtreamSeriesInfo should deserialize"); + + let props = SeriesStreamProperties::from_info_without_existing(&info, 7); + let episode = props + .details + .and_then(|details| details.episodes) + .and_then(|episodes| episodes.first().cloned()) + .expect("episode should be mapped"); + + assert_eq!(episode.plot.as_deref(), Some("Episode plot")); + } + #[test] fn normalize_episode_title_injects_missing_episode_code() { let normalized = normalize_episode_title(&"Pilot".into(), &"Example Show".into(), 1, 2); diff --git a/shared/src/model/xtream.rs b/shared/src/model/xtream.rs index 394a93249..e0a4cff17 100644 --- a/shared/src/model/xtream.rs +++ b/shared/src/model/xtream.rs @@ -193,6 +193,8 @@ pub struct XtreamSeriesInfoEpisodeInfo { pub air_date: Arc, #[serde(default, with = "arc_str_serde")] pub crew: Arc, + #[serde(default, with = "arc_str_option_serde")] + pub plot: Option>, #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] pub rating: f64, #[serde(default, deserialize_with = "deserialize_number_from_string")]