mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-23 08:12:34 +02:00
feat: add media enrichment fact patch core (#752)
* feat: add media enrichment fact patch core
This commit is contained in:
@@ -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<Option<SeriesStreamProperties>, 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<Arc<Mutex<BPlusTreeQuery<u32, XtreamPlaylistItem>>>>,
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Arc<Mutex<BPlusTreeQuery<u32, XtreamPlaylistItem>>>>,
|
||||
) -> Result<Option<VideoStreamProperties>, 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<Arc<Mutex<BPlusTreeQuery<u32, XtreamPlaylistItem>>>>,
|
||||
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<MediaMetadata> = 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 {
|
||||
|
||||
Reference in New Issue
Block a user