TMDB retry fixed and added cooldown. (#613)

New Features

Background probing for Live streams with configurable interval; new input options to trigger probing and toggle background resolution.
Configuration Changes

metadata_update reorganized into grouped sections: log, resolve, probe, ffprobe, tmdb.
FFprobe controls moved under ffprobe.* and now require explicit time/size units; new live ffprobe settings.
Added metadata_update.tmdb.cooldown (default 7d) and grouped TMDB settings.
Bug Fixes

TMDB no-match cooldown to prevent endless requeues.
Documentation

README and UI help updated to reflect grouped metadata_update fields and explanations.
This commit is contained in:
euzu
2026-02-26 14:41:02 +01:00
committed by GitHub
parent e54cb63634
commit 96a5512b65
27 changed files with 2563 additions and 1124 deletions
@@ -3,6 +3,7 @@ use crate::model::ConfigInput;
use crate::model::{AppConfig};
use crate::repository::{get_input_m3u_playlist_file_path, get_input_storage_path, get_input_local_library_playlist_file_path, xtream_get_file_path, BPlusTreeUpdate};
use crate::utils::{debug_if_enabled, ffmpeg};
use crate::utils::ffmpeg::{ProbeFailureKind, ProbeUrlOutcome};
use log::{info, warn};
use shared::error::TuliproxError;
use shared::model::{EpisodeStreamProperties, InputType, PlaylistItemType, StreamProperties, VideoStreamDetailProperties, VideoStreamProperties, LiveStreamProperties, M3uPlaylistItem, XtreamCluster, XtreamPlaylistItem};
@@ -98,17 +99,17 @@ pub async fn update_generic_stream_metadata(
let probe_url = stream_url.to_string();
let config = app_config.config.load();
let metadata_update = config.metadata_update.clone().unwrap_or_default();
let ffprobe_timeout = metadata_update.ffprobe_timeout.unwrap_or(60);
let ffprobe_timeout = metadata_update.ffprobe.timeout.unwrap_or(60);
let user_agent = config.default_user_agent.clone();
let (analyze_duration, probe_size) = if item_type.is_live() {
(
metadata_update.ffprobe_live_analyze_duration_micros,
metadata_update.ffprobe_live_probe_size_bytes,
metadata_update.ffprobe.live_analyze_duration_micros,
metadata_update.ffprobe.live_probe_size_bytes,
)
} else {
(
metadata_update.ffprobe_analyze_duration_micros,
metadata_update.ffprobe_probe_size_bytes,
metadata_update.ffprobe.analyze_duration_micros,
metadata_update.ffprobe.probe_size_bytes,
)
};
@@ -126,9 +127,16 @@ pub async fn update_generic_stream_metadata(
active_provider.release_handle(&handle).await;
}
let Some((_quality, raw_video, raw_audio)) = probe_data else {
warn!("Probe failed or timed out for generic stream: {unique_id}");
return Ok(GenericProbeOutcome::ProbeFailed);
let (raw_video, raw_audio) = match probe_data {
ProbeUrlOutcome::Success(_quality, raw_video, raw_audio) => (raw_video, raw_audio),
ProbeUrlOutcome::Failed(ProbeFailureKind::NotFound) => {
warn!("Probe target not found (404) for generic stream: {unique_id}");
return Err(shared::error::info_err!("Probe target returned 404 Not Found for stream {unique_id}"));
}
ProbeUrlOutcome::Failed(ProbeFailureKind::Other) => {
warn!("Probe failed or timed out for generic stream: {unique_id}");
return Ok(GenericProbeOutcome::ProbeFailed);
}
};
// Hold the async file lock while the blocking DB update runs in a blocking thread.
+31 -17
View File
@@ -5,6 +5,7 @@ use crate::model::{AppConfig, ConfigInput, ConfigInputFlags};
use shared::model::{LiveStreamProperties, StreamProperties, XtreamCluster, XtreamPlaylistItem};
use crate::repository::{get_input_storage_path, persist_input_live_info, BPlusTreeQuery, xtream_get_file_path};
use crate::utils::{debug_if_enabled};
use crate::utils::ffmpeg::{ProbeFailureKind, ProbeUrlOutcome};
use log::{debug, warn};
use crate::processing::parser::xtream::create_xtream_url;
use crate::api::model::{ActiveProviderManager, ProviderHandle, ProviderIdType};
@@ -117,10 +118,10 @@ pub async fn update_live_stream_metadata(
);
let config = app_config.config.load();
let metadata_update = config.metadata_update.clone().unwrap_or_default();
let ffprobe_timeout = metadata_update.ffprobe_timeout.unwrap_or(60);
let ffprobe_timeout = metadata_update.ffprobe.timeout.unwrap_or(60);
let user_agent = config.default_user_agent.clone();
let analyze_duration = metadata_update.ffprobe_live_analyze_duration_micros;
let probe_size = metadata_update.ffprobe_live_probe_size_bytes;
let analyze_duration = metadata_update.ffprobe.live_analyze_duration_micros;
let probe_size = metadata_update.ffprobe.live_probe_size_bytes;
let display_id = stream_id_opt.map_or_else(|| "StringID".to_string(), |v| v.to_string());
debug!("Probing Live Stream ID {} for input {}", display_id, input.name);
@@ -131,27 +132,37 @@ pub async fn update_live_stream_metadata(
properties.last_probed_timestamp = Some(now);
let mut success = false;
if let Some((_quality, raw_video, raw_audio)) = crate::utils::ffmpeg::probe_url(
let mut not_found = false;
match crate::utils::ffmpeg::probe_url(
&stream_url,
user_agent.as_deref(),
analyze_duration,
probe_size,
ffprobe_timeout,
).await {
// 3. Update properties on success
if let Some(v) = raw_video {
properties.video = Some(v.to_string().into());
)
.await
{
ProbeUrlOutcome::Success(_quality, raw_video, raw_audio) => {
// 3. Update properties on success
if let Some(v) = raw_video {
properties.video = Some(v.to_string().into());
}
if let Some(a) = raw_audio {
properties.audio = Some(a.to_string().into());
}
properties.last_success_timestamp = Some(now);
success = true;
debug_if_enabled!("Successfully probed Live Stream ID {}", display_id);
}
if let Some(a) = raw_audio {
properties.audio = Some(a.to_string().into());
ProbeUrlOutcome::Failed(ProbeFailureKind::NotFound) => {
warn!("Live stream probe target returned 404 for ID {} (Input: {})", display_id, input.name);
not_found = true;
}
ProbeUrlOutcome::Failed(ProbeFailureKind::Other) => {
warn!("Probe failed for Live Stream ID {} (Input: {})", display_id, input.name);
// We still persist the updated last_probed_timestamp so we don't retry immediately
}
properties.last_success_timestamp = Some(now);
success = true;
debug_if_enabled!("Successfully probed Live Stream ID {}", display_id);
} else {
warn!("Probe failed for Live Stream ID {} (Input: {})", display_id, input.name);
// We still persist the updated last_probed_timestamp so we don't retry immediately
}
// 4. Persist
@@ -164,6 +175,9 @@ pub async fn update_live_stream_metadata(
}
if !success {
if not_found {
return Err(shared::error::info_err!("Probe failed with 404 Not Found for stream {display_id}"));
}
// Return error to propagate failure up to task manager/logs
return Err(shared::error::info_err!("Probe failed for stream {display_id}"));
}
@@ -1,6 +1,6 @@
use crate::api::model::UpdateTask;
use crate::api::model::{ActiveProviderManager, ProviderHandle, ProviderIdType, ResolveReason, ResolveReasonSet};
use crate::library::MetadataResolver;
use crate::library::{MetadataResolver, MetadataStorage};
use crate::model::FetchedPlaylist;
use crate::model::{AppConfig, ConfigTarget, MetadataUpdateConfig};
use crate::model::{ConfigInput, ConfigInputFlags, InputSource};
@@ -18,6 +18,7 @@ use crate::repository::{
get_input_storage_path, persist_input_series_info_batch, MemoryPlaylistSource, PlaylistSource,
};
use crate::repository::{xtream_get_file_path, BPlusTreeQuery};
use crate::utils::ffmpeg::{ProbeFailureKind, ProbeUrlOutcome};
use crate::utils::{debug_if_enabled, xtream};
use log::{debug, error, info, log_enabled, trace, warn, Level};
use parking_lot::Mutex;
@@ -45,11 +46,11 @@ impl SeriesProbeSettings {
pub fn from_metadata_update(metadata_update: Option<&MetadataUpdateConfig>) -> Self {
let defaults = MetadataUpdateConfig::default();
Self {
timeout_secs: metadata_update.and_then(|cfg| cfg.ffprobe_timeout).unwrap_or(60),
timeout_secs: metadata_update.and_then(|cfg| cfg.ffprobe.timeout).unwrap_or(60),
analyze_duration_micros: metadata_update
.map_or(defaults.ffprobe_analyze_duration_micros, |cfg| cfg.ffprobe_analyze_duration_micros),
.map_or(defaults.ffprobe.analyze_duration_micros, |cfg| cfg.ffprobe.analyze_duration_micros),
probe_size_bytes: metadata_update
.map_or(defaults.ffprobe_probe_size_bytes, |cfg| cfg.ffprobe_probe_size_bytes),
.map_or(defaults.ffprobe.probe_size_bytes, |cfg| cfg.ffprobe.probe_size_bytes),
}
}
}
@@ -695,6 +696,7 @@ pub async fn update_series_metadata(
let mut fetched_new = false;
let mut properties_updated = false;
let mut probe_failure: Option<ProbeFailureKind> = None;
let display_id = series_id_opt.map_or_else(|| "StringID".to_string(), |v| v.to_string());
@@ -759,7 +761,12 @@ pub async fn update_series_metadata(
{
let config = app_config.config.load();
let library_config = config.library.as_ref();
let meta_resolver = MetadataResolver::new(library_config, client.clone());
let metadata_update_config = config.metadata_update.as_ref();
let tmdb_storage = metadata_update_config
.filter(|cfg| cfg.tmdb.enabled)
.map(|_| MetadataStorage::new(storage_path.clone()));
let meta_resolver =
MetadataResolver::from_config(library_config, metadata_update_config, client.clone(), tmdb_storage);
let mut meta = None;
let mut tried_title = false;
@@ -856,7 +863,7 @@ pub async fn update_series_metadata(
ep.title, ep.season, ep.episode_num, missing_reason
);
if let Some((_quality, raw_video, raw_audio)) = crate::utils::ffmpeg::probe_url(
match crate::utils::ffmpeg::probe_url(
&episode_url,
user_agent.as_deref(),
probe_settings.analyze_duration_micros,
@@ -865,15 +872,25 @@ pub async fn update_series_metadata(
)
.await
{
if let Some(v) = raw_video {
ep.video = Some(v.to_string().into());
properties_updated = true;
ProbeUrlOutcome::Success(_quality, raw_video, raw_audio) => {
if let Some(v) = raw_video {
ep.video = Some(v.to_string().into());
properties_updated = true;
}
if let Some(a) = raw_audio {
ep.audio = Some(a.to_string().into());
properties_updated = true;
}
probed_count += 1;
}
if let Some(a) = raw_audio {
ep.audio = Some(a.to_string().into());
properties_updated = true;
ProbeUrlOutcome::Failed(ProbeFailureKind::NotFound) => {
probe_failure = Some(ProbeFailureKind::NotFound);
}
ProbeUrlOutcome::Failed(ProbeFailureKind::Other) => {
if probe_failure.is_none() {
probe_failure = Some(ProbeFailureKind::Other);
}
}
probed_count += 1;
}
if let Some(h) = temp_handle {
@@ -897,6 +914,19 @@ pub async fn update_series_metadata(
}
}
let probe_only_unresolved = do_probe && !fetch_info && !resolve_tmdb && !properties_updated && !fetched_new;
if probe_only_unresolved {
if let Some(kind) = probe_failure {
let err = match kind {
ProbeFailureKind::NotFound => {
shared::error::info_err!("Probe failed with 404 Not Found for Series {display_id}")
}
ProbeFailureKind::Other => shared::error::info_err!("Probe failed for Series {display_id}"),
};
return Err(err);
}
}
// 4. Persist
if properties_updated || fetched_new {
if save {
+54 -24
View File
@@ -1,6 +1,6 @@
use crate::api::model::{ActiveProviderManager, ProviderHandle};
use crate::api::model::{ProviderIdType, ResolveReason, ResolveReasonSet, UpdateTask};
use crate::library::MetadataResolver;
use crate::library::{MetadataResolver, MetadataStorage};
use crate::model::FetchedPlaylist;
use crate::model::InputSource;
use crate::model::{AppConfig, ConfigTarget};
@@ -16,6 +16,7 @@ use crate::repository::get_input_storage_path;
use crate::repository::persist_input_vod_info;
use crate::repository::persist_input_vod_info_batch;
use crate::repository::{xtream_get_file_path, BPlusTreeQuery};
use crate::utils::ffmpeg::{ProbeFailureKind, ProbeUrlOutcome};
use crate::utils::{debug_if_enabled, trace_if_enabled, xtream};
use log::{debug, error, info, log_enabled, trace, warn, Level};
use parking_lot::Mutex;
@@ -577,6 +578,7 @@ pub async fn update_vod_metadata(
let mut fetched_new = false;
let mut properties_updated = false;
let mut probe_failure: Option<ProbeFailureKind> = None;
// Determine the title to use for logging and fallback.
// Cloning here to satisfy borrow checker when 'props' is mutated later.
@@ -676,7 +678,12 @@ pub async fn update_vod_metadata(
if missing_tmdb || still_missing_date {
let config = app_config.config.load();
let library_config = config.library.as_ref();
let meta_resolver = MetadataResolver::new(library_config, client.clone());
let metadata_update_config = config.metadata_update.as_ref();
let tmdb_storage = metadata_update_config
.filter(|cfg| cfg.tmdb.enabled)
.map(|_| MetadataStorage::new(storage_path.clone()));
let meta_resolver =
MetadataResolver::from_config(library_config, metadata_update_config, client.clone(), tmdb_storage);
let mut meta = None;
let mut tried_title = false;
@@ -767,10 +774,10 @@ pub async fn update_vod_metadata(
let config = app_config.config.load();
let metadata_update = config.metadata_update.clone().unwrap_or_default();
let ffprobe_timeout = metadata_update.ffprobe_timeout.unwrap_or(60);
let ffprobe_timeout = metadata_update.ffprobe.timeout.unwrap_or(60);
let user_agent = config.default_user_agent.clone();
let analyze_duration = metadata_update.ffprobe_analyze_duration_micros;
let probe_size = metadata_update.ffprobe_probe_size_bytes;
let analyze_duration = metadata_update.ffprobe.analyze_duration_micros;
let probe_size = metadata_update.ffprobe.probe_size_bytes;
// Acquire Connection logic
let temp_handle = if active_handle.is_some() {
@@ -779,28 +786,38 @@ pub async fn update_vod_metadata(
active_provider.acquire_connection_for_probe(&input.name).await
};
if active_handle.is_some() || temp_handle.is_some() {
debug_if_enabled!("Probing VOD '{}' (ID: {})", display_title, display_id);
if let Some((_quality, raw_video, raw_audio)) = crate::utils::ffmpeg::probe_url(
&stream_url,
user_agent.as_deref(),
analyze_duration,
probe_size,
ffprobe_timeout,
)
.await
{
if let Some(details) = properties.details.as_mut() {
if let Some(v) = raw_video {
details.video = Some(v.to_string().into());
properties_updated = true;
if active_handle.is_some() || temp_handle.is_some() {
debug_if_enabled!("Probing VOD '{}' (ID: {})", display_title, display_id);
match crate::utils::ffmpeg::probe_url(
&stream_url,
user_agent.as_deref(),
analyze_duration,
probe_size,
ffprobe_timeout,
)
.await
{
ProbeUrlOutcome::Success(_quality, raw_video, raw_audio) => {
if let Some(details) = properties.details.as_mut() {
if let Some(v) = raw_video {
details.video = Some(v.to_string().into());
properties_updated = true;
}
if let Some(a) = raw_audio {
details.audio = Some(a.to_string().into());
properties_updated = true;
}
}
}
if let Some(a) = raw_audio {
details.audio = Some(a.to_string().into());
properties_updated = true;
ProbeUrlOutcome::Failed(ProbeFailureKind::NotFound) => {
probe_failure = Some(ProbeFailureKind::NotFound);
}
ProbeUrlOutcome::Failed(ProbeFailureKind::Other) => {
if probe_failure.is_none() {
probe_failure = Some(ProbeFailureKind::Other);
}
}
}
}
if let Some(h) = temp_handle {
active_provider.release_handle(&h).await;
}
@@ -810,6 +827,19 @@ pub async fn update_vod_metadata(
}
}
let probe_only_unresolved = do_probe && !fetch_info && !resolve_tmdb && !properties_updated && !fetched_new;
if probe_only_unresolved {
if let Some(kind) = probe_failure {
let err = match kind {
ProbeFailureKind::NotFound => {
shared::error::info_err!("Probe failed with 404 Not Found for VOD {display_id}")
}
ProbeFailureKind::Other => shared::error::info_err!("Probe failed for VOD {display_id}"),
};
return Err(err);
}
}
// 4. Persist if updated
if properties_updated || fetched_new {
if save {