M3u catchup fix (#794)

* **New Features**
  * Catch-up streams can now auto-detect whether to serve direct video or an HLS playlist.
  * Added archive-aware HLS session/identity handling and improved upstream user-agent propagation for manifests.
  * Enhanced M3U timeshift/archiving, including nested handling and native Flussonic archive support; Short EPG can advertise archive availability.

* **Bug Fixes**
  * Provider session headers now persist across retries and deferred-open scenarios.
  * Legacy HLS routing is now cache-aware; seek detection improvements.
  * Stalker errors now better redact sensitive URL details.
  * Fix: Preserve VOD client range requests

* **Tests**
  * Expanded coverage for probing, archive/timeshift resolution, header overlay rules, and related routing.
This commit is contained in:
euzu
2026-07-27 15:13:10 +02:00
committed by GitHub
parent bcc1badcfe
commit 4d8df8f534
73 changed files with 4346 additions and 890 deletions
+194 -3
View File
@@ -2,7 +2,7 @@ use crate::model::ProxyUserCredentials;
use shared::concat_string;
use shared::{
utils::{deobfuscate_text, extract_extension_from_url, obfuscate_text, CONSTANTS},
defaults::{HLS_PREFIX}
defaults::{HLS_EXT, HLS_PREFIX}
};
use std::borrow::Cow;
use std::str;
@@ -63,10 +63,71 @@ pub struct RewriteHlsProps<'a> {
pub user_token: Option<&'a str>,
}
fn is_direct_archive_start_query_key(key: &str) -> bool {
key.eq_ignore_ascii_case("utc") || key.eq_ignore_ascii_case("utcstart")
}
fn is_contextual_archive_start_query_key(key: &str) -> bool {
key.eq_ignore_ascii_case("start") || key.eq_ignore_ascii_case("timestamp")
}
fn is_archive_start_context_query_key(key: &str) -> bool {
key.eq_ignore_ascii_case("end")
|| key.eq_ignore_ascii_case("duration")
|| key.eq_ignore_ascii_case("lutc")
|| key.eq_ignore_ascii_case("offset")
}
fn has_archive_start_query(url: &Url) -> bool {
let has_context = url.query_pairs().any(|(key, _)| is_archive_start_context_query_key(&key));
url.query_pairs().any(|(key, _)| {
is_direct_archive_start_query_key(&key) || (has_context && is_contextual_archive_start_query_key(&key))
})
}
fn preserve_archive_start_query(base: &Url, mut target: Url) -> Url {
let has_context = base.query_pairs().any(|(key, _)| is_archive_start_context_query_key(&key));
if !has_archive_start_query(base) {
return target;
}
for (key, value) in base.query_pairs() {
let is_start = is_direct_archive_start_query_key(&key)
|| (has_context && is_contextual_archive_start_query_key(&key));
let already_present = if is_start {
has_archive_start_query(&target)
} else {
target.query_pairs().any(|(target_key, _)| target_key.eq_ignore_ascii_case(&key))
};
if (is_start || is_archive_start_context_query_key(&key)) && !already_present {
target.query_pairs_mut().append_pair(&key, &value);
}
}
target
}
fn preserve_token_query(base: &Url, mut target: Url) -> Url {
let target_has_token = target.query_pairs().any(|(key, _)| key.eq_ignore_ascii_case("token"));
if target_has_token {
return target;
}
if let Some((key, value)) = base.query_pairs().find(|(key, _)| key.eq_ignore_ascii_case("token")) {
target.query_pairs_mut().append_pair(&key, &value);
}
target
}
fn has_same_origin(left: &Url, right: &Url) -> bool {
matches!(left.scheme(), "ftp" | "http" | "https" | "ws" | "wss")
&& left.scheme() == right.scheme()
&& left.host() == right.host()
&& left.port_or_known_default() == right.port_or_known_default()
}
/// Rewrites an HLS URI relative to a base playlist URL.
/// Absolute URIs are returned unchanged.
pub fn rewrite_hls_url<'a>(base: &'a str, reference: &'a str) -> Cow<'a, str> {
// absolute URI -> passthrough
if Url::parse(reference).is_ok() {
return Cow::Borrowed(reference);
}
@@ -75,7 +136,23 @@ pub fn rewrite_hls_url<'a>(base: &'a str, reference: &'a str) -> Cow<'a, str> {
return Cow::Borrowed(reference);
};
base_url.join(reference).map_or_else(|_| Cow::Borrowed(reference), |u| Cow::Owned(u.to_string()))
base_url.join(reference).map_or_else(
|_| Cow::Borrowed(reference),
|mut target| {
let is_same_origin = has_same_origin(&target, &base_url);
if is_same_origin {
target = preserve_token_query(&base_url, target);
}
let is_same_origin_child_playlist = is_same_origin
&& extract_extension_from_url(target.as_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case(HLS_EXT));
Cow::Owned(if is_same_origin_child_playlist {
preserve_archive_start_query(&base_url, target).into()
} else {
target.into()
})
},
)
}
fn rewrite_uri_attrib<'a>(line: &'a str, props: &RewriteHlsProps, user: &ProxyUserCredentials) -> Cow<'a, str> {
@@ -193,6 +270,113 @@ mod test {
assert_eq!(out, "http://example.com/hls/seg001.ts");
}
#[test]
fn rewrite_relative_hls_resources_inherit_token() {
let base = "https://cdn.example/hls/channel/timeshift_abs-1785136500.m3u8?token=secret";
for (reference, expected) in [
(
"tracks-v1a1/timeshift_abs-1785136500.m3u8",
"https://cdn.example/hls/channel/tracks-v1a1/timeshift_abs-1785136500.m3u8?token=secret",
),
(
"tracks-v1a1/dvr-2026/07/27/12/00/00-06000.ts",
"https://cdn.example/hls/channel/tracks-v1a1/dvr-2026/07/27/12/00/00-06000.ts?token=secret",
),
("key.bin?version=2", "https://cdn.example/hls/channel/key.bin?version=2&token=secret"),
("init.mp4", "https://cdn.example/hls/channel/init.mp4?token=secret"),
] {
assert_eq!(rewrite_hls_url(base, reference), expected);
}
}
#[test]
fn rewrite_relative_hls_resource_keeps_its_own_token() {
let base = "https://cdn.example/hls/channel/index.m3u8?TOKEN=base";
assert_eq!(
rewrite_hls_url(base, "child.m3u8?token=child"),
"https://cdn.example/hls/channel/child.m3u8?token=child"
);
}
#[test]
fn rewrite_does_not_leak_token_to_cross_origin_resource() {
let base = "https://cdn.example/hls/channel/index.m3u8?token=secret";
assert_eq!(rewrite_hls_url(base, "https://media.example/child.m3u8"), "https://media.example/child.m3u8");
assert_eq!(rewrite_hls_url(base, "//media.example/child.m3u8"), "https://media.example/child.m3u8");
}
#[test]
fn rewrite_relative_variant_preserves_archive_start_query() {
let base = "https://cdn.example/hls/channel/index.m3u8?offset=-10752&utcstart=1785072000&useseq=t";
let uri = "variant/playlist.m3u8?offset=-10752&useseq=t";
let out = rewrite_hls_url(base, uri);
assert_eq!(
out,
"https://cdn.example/hls/channel/variant/playlist.m3u8?offset=-10752&useseq=t&utcstart=1785072000"
);
}
#[test]
fn rewrite_relative_variant_preserves_all_archive_context_queries() {
let base = "https://cdn.example/hls/channel/index.m3u8?utcstart=1785072000&offset=-3600&end=1785075600&duration=3600";
let uri = "variant/playlist.m3u8?offset=-1800";
let out = rewrite_hls_url(base, uri);
assert_eq!(
out,
"https://cdn.example/hls/channel/variant/playlist.m3u8?offset=-1800&utcstart=1785072000&end=1785075600&duration=3600"
);
}
#[test]
fn rewrite_keeps_child_archive_start_query() {
let base = "https://cdn.example/hls/channel/index.m3u8?utcstart=1785072000";
let uri = "variant/playlist.m3u8?utc=1785071000";
let out = rewrite_hls_url(base, uri);
assert_eq!(out, "https://cdn.example/hls/channel/variant/playlist.m3u8?utc=1785071000");
}
#[test]
fn rewrite_does_not_propagate_plain_start_query() {
let base = "https://cdn.example/hls/channel/index.m3u8?start=1785072000";
let uri = "variant/playlist.m3u8";
let out = rewrite_hls_url(base, uri);
assert_eq!(out, "https://cdn.example/hls/channel/variant/playlist.m3u8");
}
#[test]
fn rewrite_archive_playlist_does_not_modify_signed_media_urls() {
let base = "https://cdn.example/hls/channel/index.m3u8?utcstart=1785072000&offset=-3600";
assert_eq!(
rewrite_hls_url(base, "segment.ts?sig=abc"),
"https://cdn.example/hls/channel/segment.ts?sig=abc"
);
assert_eq!(
rewrite_hls_url(base, "key.bin?sig=def"),
"https://cdn.example/hls/channel/key.bin?sig=def"
);
assert_eq!(
rewrite_hls_url(base, "init.mp4?sig=ghi"),
"https://cdn.example/hls/channel/init.mp4?sig=ghi"
);
}
#[test]
fn rewrite_absolute_same_origin_url_remains_exact_passthrough() {
let base = "https://cdn.example/hls/channel/index.m3u8?utcstart=1785072000&offset=-3600";
let reference = "https://cdn.example/hls/channel/segment.ts?sig=a%2Fb&token=x+y";
assert!(matches!(rewrite_hls_url(base, reference), std::borrow::Cow::Borrowed(_)));
assert_eq!(rewrite_hls_url(base, reference), reference);
}
#[test]
fn rewrite_https_absolute_passthrough() {
let base = "http://example.com/hls/playlist.m3u8";
@@ -220,6 +404,13 @@ mod test {
assert_eq!(out, "file:///mnt/media/hls/seg001.ts");
}
#[test]
fn rewrite_file_child_playlist_does_not_inherit_archive_query() {
let base = "file:///mnt/media/hls/playlist.m3u8?utc=1785072000";
assert_eq!(rewrite_hls_url(base, "child.m3u8"), "file:///mnt/media/hls/child.m3u8");
}
#[test]
fn rewrite_file_absolute_passthrough() {
let base = "file:///mnt/media/hls/playlist.m3u8";
+45
View File
@@ -462,9 +462,22 @@ fn parse_extm3u_catchup_correction(attributes: &str) -> Option<Arc<str>> {
None
}
fn parse_extvlcopt_user_agent(line: &str) -> Option<&str> {
let (prefix, option) = line.trim().split_once(':')?;
if !prefix.eq_ignore_ascii_case("#EXTVLCOPT") {
return None;
}
let (name, value) = option.split_once('=')?;
if !name.trim().eq_ignore_ascii_case("http-user-agent") {
return None;
}
Some(value.trim())
}
pub async fn consume_m3u<F: FnMut(PlaylistItem)>(cfg: &Config, input: &ConfigInput, lines: DynReader, mut visit: F) {
let mut header: Option<String> = None;
let mut group: Option<Arc<str>> = None;
let mut upstream_user_agent: Option<Arc<str>> = None;
let mut default_catchup_correction: Option<Arc<str>> = None;
let input_name = &input.name;
@@ -481,6 +494,13 @@ pub async fn consume_m3u<F: FnMut(PlaylistItem)>(cfg: &Config, input: &ConfigInp
if let Some(b'#') = bytes.first().copied() {
if bytes.starts_with(b"#EXTINF") {
header = Some(line);
upstream_user_agent = None;
continue;
}
if let Some(value) = parse_extvlcopt_user_agent(&line) {
if header.is_some() {
upstream_user_agent = (!value.is_empty()).then(|| value.intern());
}
continue;
}
if let Some(rest) = line.strip_prefix("#EXTM3U") {
@@ -505,6 +525,7 @@ pub async fn consume_m3u<F: FnMut(PlaylistItem)>(cfg: &Config, input: &ConfigInp
),
};
let header = &mut item.header;
header.upstream_user_agent = upstream_user_agent.take();
header.source_ordinal = ord_counter;
ord_counter += 1;
if header.group.is_empty() {
@@ -832,4 +853,28 @@ mod test {
assert_eq!(items[0].header.group.as_ref(), "Sports");
}
#[tokio::test]
async fn consume_m3u_scopes_extvlcopt_user_agent_to_one_item() {
let content = concat!(
"#EXTM3U\n",
"#EXTINF:-1,Channel 1\n",
"#extvlcopt:HTTP-USER-AGENT= Source UA/1.0 \n",
"http://provider.example/live/user/pass/1.ts\n",
"#EXTINF:-1,Channel 2\n",
"#EXTVLCOPT:http-user-agent=\n",
"http://provider.example/live/user/pass/2.ts\n",
"#EXTVLCOPT:http-user-agent=must-not-leak\n",
"#EXTINF:-1,Channel 3\n",
"http://provider.example/live/user/pass/3.ts\n",
);
let mut items = Vec::new();
super::consume_m3u(&Config::default(), &test_input(), make_reader(content), |item| items.push(item)).await;
assert_eq!(items.len(), 3);
assert_eq!(items[0].header.upstream_user_agent.as_deref(), Some("Source UA/1.0"));
assert_eq!(items[1].header.upstream_user_agent, None);
assert_eq!(items[2].header.upstream_user_agent, None);
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ use shared::utils::Internable;
use std::collections::HashSet;
use std::sync::Arc;
use crate::utils::network::stalker::catalog::{
use crate::iptv::stalker::catalog::{
StalkerCategory, StalkerRawItem, StalkerRawItemInfo, StalkerRawSeriesDetails, StalkerRawSeriesEpisode,
StalkerRawSeriesItem, StalkerRawSeriesSeason,
};
+1 -1
View File
@@ -1,7 +1,7 @@
use crate::model::{ConfigInput, ConfigInputFlags};
use crate::model::XtreamCategory;
use crate::utils::request::DynReader;
use crate::utils::xtream::get_xtream_stream_url_base;
use crate::iptv::xtream::get_xtream_stream_url_base;
use indexmap::IndexMap;
use serde::Deserializer;
use shared::error::TuliproxError;
+76 -9
View File
@@ -6,6 +6,7 @@ use crate::{
},
sync_panel_api_exp_dates,
},
iptv::{m3u, xtream},
messaging::send_message,
media_server::{
media_server_catalog_snapshot_to_playlist, refresh_media_server_catalog_complete_before_publish,
@@ -32,7 +33,7 @@ use crate::{
PlaylistSource,
},
utils::{
debug_if_enabled, epg, log_memory_snapshot, m3u, trace_if_enabled, xtream,
debug_if_enabled, epg, log_memory_snapshot, trace_if_enabled,
StepMeasure, StepMeasureCallback,
},
};
@@ -2077,18 +2078,29 @@ mod tests {
use shared::utils::Internable;
use crate::model::Config;
fn serialize_without_trailing_input_stream_id<T: serde::Serialize>(value: &T) -> Vec<u8> {
fn serialize_without_trailing_fields<T: serde::Serialize>(value: &T, trailing_fields: &[u8]) -> Vec<u8> {
let mut encoded = rmp_serde::to_vec(value).expect("playlist item should serialize");
assert_eq!(encoded.pop(), Some(0xa0), "input_stream_id should be the trailing empty string");
for expected in trailing_fields {
assert_eq!(encoded.pop(), Some(*expected), "unexpected trailing MessagePack field");
}
let removed = trailing_fields.len();
match encoded[0] {
marker @ 0x91..=0x9f => encoded[0] = marker - 1,
marker @ 0x92..=0x9f => {
let len = usize::from(marker - 0x90);
assert!(len >= removed, "trailing field count exceeds MessagePack sequence length");
encoded[0] = 0x90 + u8::try_from(len - removed).unwrap_or_default();
}
0xdc => {
let len = u16::from_be_bytes([encoded[1], encoded[2]]);
encoded[1..3].copy_from_slice(&(len - 1).to_be_bytes());
let removed = u16::try_from(removed).unwrap_or(u16::MAX);
assert!(len >= removed, "trailing field count exceeds MessagePack sequence length");
encoded[1..3].copy_from_slice(&(len - removed).to_be_bytes());
}
0xdd => {
let len = u32::from_be_bytes([encoded[1], encoded[2], encoded[3], encoded[4]]);
encoded[1..5].copy_from_slice(&(len - 1).to_be_bytes());
let removed = u32::try_from(removed).unwrap_or(u32::MAX);
assert!(len >= removed, "trailing field count exceeds MessagePack sequence length");
encoded[1..5].copy_from_slice(&(len - removed).to_be_bytes());
}
marker => panic!("unexpected MessagePack sequence marker {marker:#x}"),
}
@@ -2231,11 +2243,12 @@ mod tests {
},
};
let header_bytes = serialize_without_trailing_input_stream_id(&source.header);
let header_bytes = serialize_without_trailing_fields(&source.header, &[0xc0, 0xa0]);
let decoded_header: PlaylistItemHeader =
rmp_serde::from_slice(&header_bytes).expect("legacy header should deserialize");
assert!(decoded_header.input_stream_id.is_empty());
assert_eq!(decoded_header.get_input_stream_id(), None);
assert_eq!(decoded_header.upstream_user_agent, None);
let mut decoded_header = decoded_header;
decoded_header.freeze_input_stream_id();
assert_eq!(decoded_header.get_input_stream_id().as_deref(), Some("origin-alpha"));
@@ -2243,19 +2256,73 @@ mod tests {
source.header.freeze_input_stream_id();
let mut m3u_item = M3uPlaylistItem::from(&source);
m3u_item.input_stream_id = "".intern();
let m3u_bytes = serialize_without_trailing_input_stream_id(&m3u_item);
let m3u_bytes = serialize_without_trailing_fields(&m3u_item, &[0xc0, 0xa0]);
let decoded_m3u: M3uPlaylistItem =
rmp_serde::from_slice(&m3u_bytes).expect("legacy M3U item should deserialize");
assert!(decoded_m3u.input_stream_id.is_empty());
assert_eq!(decoded_m3u.get_input_stream_id().as_deref(), Some("origin-alpha"));
assert_eq!(decoded_m3u.upstream_user_agent, None);
let mut xtream_item = XtreamPlaylistItem::from(&source);
xtream_item.input_stream_id = "".intern();
let xtream_bytes = serialize_without_trailing_input_stream_id(&xtream_item);
let xtream_bytes = serialize_without_trailing_fields(&xtream_item, &[0xc0, 0xa0]);
let decoded_xtream: XtreamPlaylistItem =
rmp_serde::from_slice(&xtream_bytes).expect("legacy Xtream item should deserialize");
assert!(decoded_xtream.input_stream_id.is_empty());
assert_eq!(decoded_xtream.get_input_stream_id().as_deref(), Some("80510"));
assert_eq!(decoded_xtream.upstream_user_agent, None);
}
#[test]
fn previous_messagepack_playlist_items_default_missing_upstream_user_agent() {
let source = PlaylistItem {
header: PlaylistItemHeader {
id: "80510".intern(),
input_stream_id: "origin-alpha".intern(),
..Default::default()
},
};
let header: PlaylistItemHeader = rmp_serde::from_slice(&serialize_without_trailing_fields(&source.header, &[0xc0]))
.expect("previous header should deserialize");
let m3u: M3uPlaylistItem = rmp_serde::from_slice(&serialize_without_trailing_fields(
&M3uPlaylistItem::from(&source),
&[0xc0],
))
.expect("previous M3U item should deserialize");
let xtream: XtreamPlaylistItem = rmp_serde::from_slice(&serialize_without_trailing_fields(
&XtreamPlaylistItem::from(&source),
&[0xc0],
))
.expect("previous Xtream item should deserialize");
assert_eq!(header.input_stream_id.as_ref(), "origin-alpha");
assert_eq!(m3u.input_stream_id.as_ref(), "origin-alpha");
assert_eq!(xtream.input_stream_id.as_ref(), "origin-alpha");
assert_eq!(header.upstream_user_agent, None);
assert_eq!(m3u.upstream_user_agent, None);
assert_eq!(xtream.upstream_user_agent, None);
}
#[test]
fn messagepack_playlist_items_preserve_upstream_user_agent() -> Result<(), Box<dyn std::error::Error>> {
let source = PlaylistItem {
header: PlaylistItemHeader {
upstream_user_agent: Some("Provider-UA".intern()),
..Default::default()
},
};
let header: PlaylistItemHeader = rmp_serde::from_slice(&rmp_serde::to_vec(&source.header)?)?;
let m3u: M3uPlaylistItem =
rmp_serde::from_slice(&rmp_serde::to_vec(&M3uPlaylistItem::from(&source))?)?;
let xtream: XtreamPlaylistItem =
rmp_serde::from_slice(&rmp_serde::to_vec(&XtreamPlaylistItem::from(&source))?)?;
assert_eq!(header.upstream_user_agent.as_deref(), Some("Provider-UA"));
assert_eq!(m3u.upstream_user_agent.as_deref(), Some("Provider-UA"));
assert_eq!(xtream.upstream_user_agent.as_deref(), Some("Provider-UA"));
Ok(())
}
#[test]
+3 -3
View File
@@ -6,7 +6,7 @@
//!
//! Reverse-proxy re-resolve (when a 4xx upstream error is observed) is implemented in the
//! HLS/Xtream endpoints and reaches back into the API client via the helper
//! [`crate::utils::network::stalker::client::StalkerApiClient::create_link`].
//! [`crate::iptv::stalker::client::StalkerApiClient::create_link`].
#![allow(clippy::too_many_lines, clippy::needless_pass_by_value)]
@@ -33,8 +33,8 @@ use crate::repository::stalker_generation_repository::{load_active_manifest, loa
use super::stalker_refresh::{
advance_stalker_refresh, StalkerClusterSelection, StalkerRefreshMode, StalkerRefreshOutcome,
};
use crate::utils::network::stalker::client::StalkerApiClient;
use crate::utils::network::stalker::error::StalkerError;
use crate::iptv::stalker::client::StalkerApiClient;
use crate::iptv::stalker::error::StalkerError;
/// Cluster selector used by the Stalker processor. Mirrors the Xtream cluster split
@@ -18,10 +18,10 @@ use crate::repository::stalker_repository::{
remove_stalker_file, snapshot_stalker_epg_at, snapshot_stalker_items_at,
upsert_stalker_epg_at, upsert_stalker_items_at,
};
use crate::utils::network::stalker::catalog::{StalkerCategory, StalkerRawItem};
use crate::utils::network::stalker::client::StalkerApiClient;
use crate::utils::network::stalker::error::StalkerError;
use crate::utils::network::stalker::profile::StalkerHandshake;
use crate::iptv::stalker::catalog::{StalkerCategory, StalkerRawItem};
use crate::iptv::stalker::client::StalkerApiClient;
use crate::iptv::stalker::error::StalkerError;
use crate::iptv::stalker::profile::StalkerHandshake;
use super::stalker::StalkerCluster;
const MAX_RETRIES: u8 = 3;
@@ -1,6 +1,7 @@
use crate::api::model::UpdateTask;
use crate::api::model::{ActiveProviderManager, ProviderHandle, ProviderIdType, ResolveReason, ResolveReasonSet};
use crate::library::{MetadataResolver, MetadataStorage};
use crate::iptv::xtream;
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,
@@ -21,7 +22,7 @@ use crate::repository::persists_input_series_info;
use crate::repository::{get_input_storage_path, persist_input_series_info_batch, MemoryPlaylistSource};
use crate::repository::{xtream_get_file_path, BPlusTreeQuery};
use crate::utils::ffmpeg::{is_supported_probe_url, FfmpegExecutor, ProbeFailureKind, ProbeUrlOutcome};
use crate::utils::{debug_if_enabled, xtream};
use crate::utils::debug_if_enabled;
use log::{debug, error, info, log_enabled, trace, warn, Level};
use parking_lot::Mutex;
use serde_json::Value;
@@ -1,6 +1,7 @@
use crate::api::model::{ActiveProviderManager, ProviderHandle};
use crate::api::model::{ProviderIdType, ResolveReason, ResolveReasonSet, UpdateTask};
use crate::library::{MediaMetadata, MetadataResolver, MetadataStorage};
use crate::iptv::xtream;
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,
@@ -22,7 +23,7 @@ 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::{is_supported_probe_url, FfmpegExecutor, ProbeFailureKind, ProbeUrlOutcome};
use crate::utils::{debug_if_enabled, trace_if_enabled, xtream};
use crate::utils::{debug_if_enabled, trace_if_enabled};
use log::{debug, error, info, log_enabled, trace, warn, Level};
use parking_lot::Mutex;
use serde_json::Value;