Files
tuliprox/backend/src/processing/parser/m3u.rs
T
euzuandGitHub 4d8df8f534 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.
2026-07-27 15:13:10 +02:00

881 lines
34 KiB
Rust

use crate::model::{Config, ConfigInput};
use crate::utils::request::DynReader;
use shared::model::{
CatchupAttribute, CatchupProperties, LiveStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemHeader,
PlaylistItemType, StreamProperties, XtreamCluster,
};
use shared::utils::{extract_id_from_url, extract_numeric_id_from_url, Internable};
use shared::defaults::{default_supported_video_extensions};
use std::borrow::BorrowMut;
use std::sync::Arc;
use tokio::io::AsyncBufReadExt;
use indexmap::IndexMap;
use crate::repository::CategoryKey;
// other implementations like calculating text_distance on all titles took too much time
// we keep it now as simple as possible and less memory intensive.
fn get_title_group(text: &Arc<str>) -> Arc<str> {
let mut combination = String::new();
let mut in_word = false;
for c in text.chars() {
if c.is_alphanumeric() {
if !in_word && !combination.is_empty() {
combination.push(' ');
}
in_word = true;
combination.push(c);
} else {
if in_word && combination.len() > 2 {
return combination.intern();
}
in_word = false;
}
}
if combination.len() > 2 {
return combination.intern();
}
text.clone()
}
/// Reads a quoted value into the stack and returns the start offset.
/// The value is `&stack[offset..]`. Caller must call `stack.truncate(offset)` after use.
#[inline]
fn token_value(stack: &mut String, it: &mut std::str::Chars) -> usize {
let offset = stack.len();
// `any()` intentionally consumes chars from the iterator, advancing it past the opening '"'.
// Returns true when a quote is found, so read_value can extract the content until the closing '"'.
if it.any(|ch| ch == '"') {
read_value(stack, it);
}
offset
}
/// Reads a quoted or comma-delimited value into the stack and returns the start offset.
fn read_value(stack: &mut String, it: &mut std::str::Chars) {
for c in it.skip_while(|c| c.is_whitespace()) {
if c == '"' {
break;
}
stack.push(c);
}
}
/// Reads a comma-delimited value (title) into the stack and returns the start offset.
#[inline]
fn title_value(stack: &mut String, it: &mut std::str::Chars) -> usize {
let offset = stack.len();
read_value(stack, it);
offset
}
/// Reads characters into the stack until `stop_char` is reached.
/// Returns `Some(offset)` where `&stack[offset..]` is the token, or `None` if empty.
/// Caller must call `stack.truncate(offset)` after use.
fn token_till(stack: &mut String, it: &mut std::str::Chars, stop_char: char, start_with_alpha: bool) -> Option<usize> {
let offset = stack.len();
let mut skip_non_alpha = start_with_alpha;
for ch in it.by_ref() {
if ch == stop_char {
break;
}
if stack.len() == offset && ch.is_whitespace() {
continue;
}
if skip_non_alpha {
if ch.is_alphabetic() {
skip_non_alpha = false;
} else {
continue;
}
}
stack.push(ch);
}
if stack.len() == offset {
None
} else {
Some(offset)
}
}
#[inline]
fn skip_digit(it: &mut std::str::Chars) -> Option<char> {
loop {
{
let c = it.next()?;
if !(c == '-' || c == '+' || c.is_ascii_digit()) {
return Some(c);
}
}
}
}
fn create_empty_playlistitem_header(input_name: &Arc<str>, url: String) -> PlaylistItemHeader {
PlaylistItemHeader {
url: Arc::from(url),
category_id: 0,
input_name: Arc::clone(input_name),
..Default::default()
}
}
enum M3uToken {
ProviderId,
TvgChno,
GroupTitle,
TvgId,
TvgName,
TvgLogo,
TvgLogoSmall,
ParentCode,
AudioTrack,
TimeShift,
TvgRec,
Catchup,
CatchupDays,
CatchupSource,
CatchupTime,
CatchupCorrection,
CatchupType,
CatchupExtra,
PossibleId,
Unknown,
}
#[inline]
fn eq_ascii(bytes: &[u8], expected: &[u8]) -> bool { bytes.eq_ignore_ascii_case(expected) }
#[inline]
fn classify_possible_id(bytes: &[u8]) -> M3uToken {
if eq_ascii(bytes, b"id")
|| (bytes.len() > 2 && !eq_ascii(&bytes[..3], b"tvg") && eq_ascii(&bytes[bytes.len() - 2..], b"id"))
{
M3uToken::PossibleId
} else {
M3uToken::Unknown
}
}
fn classify_token(t: &str) -> M3uToken {
let bytes = t.as_bytes();
match bytes.len() {
2 => classify_possible_id(bytes),
4 => {
if eq_ascii(bytes, b"cuid") {
M3uToken::ProviderId
} else {
M3uToken::Unknown
}
}
6 => {
if eq_ascii(bytes, b"xui-id") {
M3uToken::ProviderId
} else if eq_ascii(bytes, b"tvg-id") {
M3uToken::TvgId
} else {
classify_possible_id(bytes)
}
}
7 => {
if eq_ascii(bytes, b"tvg-rec") {
M3uToken::TvgRec
} else if eq_ascii(bytes, b"catchup") {
M3uToken::Catchup
} else {
classify_possible_id(bytes)
}
}
8 => {
if eq_ascii(bytes, b"tvg-chno") {
M3uToken::TvgChno
} else if eq_ascii(bytes, b"tvg-name") {
M3uToken::TvgName
} else if eq_ascii(bytes, b"tvg-logo") {
M3uToken::TvgLogo
} else {
classify_possible_id(bytes)
}
}
9 => {
if eq_ascii(bytes, b"timeshift") {
M3uToken::TimeShift
} else {
classify_possible_id(bytes)
}
}
10 => {
if eq_ascii(bytes, b"catchup-id") {
M3uToken::CatchupExtra
} else {
classify_possible_id(bytes)
}
}
11 => {
if eq_ascii(bytes, b"group-title") {
M3uToken::GroupTitle
} else if eq_ascii(bytes, b"parent-code") {
M3uToken::ParentCode
} else if eq_ascii(bytes, b"audio-track") {
M3uToken::AudioTrack
} else {
classify_possible_id(bytes)
}
}
12 => {
if eq_ascii(bytes, b"catchup-days") {
M3uToken::CatchupDays
} else if eq_ascii(bytes, b"catchup-type") {
M3uToken::CatchupType
} else if eq_ascii(bytes, b"catchup-time") {
M3uToken::CatchupTime
} else {
classify_possible_id(bytes)
}
}
14 => {
if eq_ascii(bytes, b"tvg-logo-small") {
M3uToken::TvgLogoSmall
} else if eq_ascii(bytes, b"catchup-source") {
M3uToken::CatchupSource
} else {
classify_possible_id(bytes)
}
}
18 => {
if eq_ascii(bytes, b"catchup-correction") {
M3uToken::CatchupCorrection
} else {
classify_possible_id(bytes)
}
}
_ => {
if bytes.len() > 8 && eq_ascii(&bytes[..8], b"catchup-") {
M3uToken::CatchupExtra
} else {
classify_possible_id(bytes)
}
}
}
}
fn ensure_live_stream_properties(header: &mut PlaylistItemHeader) -> &mut LiveStreamProperties {
if !matches!(header.additional_properties.as_ref(), Some(StreamProperties::Live(_))) {
header.additional_properties = Some(StreamProperties::Live(Box::default()));
}
let Some(StreamProperties::Live(live)) = header.additional_properties.as_mut() else {
unreachable!("additional_properties just initialized as live");
};
if live.name.is_empty() {
if !header.name.is_empty() {
live.name.clone_from(&header.name);
} else if !header.title.is_empty() {
live.name.clone_from(&header.title);
}
}
if live.epg_channel_id.is_none() {
live.epg_channel_id.clone_from(&header.epg_channel_id);
}
live
}
fn apply_catchup_properties(header: &mut PlaylistItemHeader, mut catchup: CatchupProperties, default_correction: Option<&Arc<str>>) {
let has_capability = catchup.mode.is_some()
|| catchup.days.is_some()
|| catchup.source.is_some()
|| catchup.time.is_some()
|| catchup.catchup_type.is_some()
|| !catchup.extra_attributes.is_empty();
if !has_capability {
return;
}
if catchup.correction.is_none() {
catchup.correction = default_correction.cloned();
}
let live = ensure_live_stream_properties(header);
live.tv_archive = Some(1);
live.tv_archive_duration = catchup
.days
.as_deref()
.and_then(|days| days.trim().parse::<i32>().ok())
.or(live.tv_archive_duration);
live.catchup = Some(catchup);
}
#[allow(clippy::too_many_lines)]
fn process_header_internal(
input_name: &Arc<str>,
video_suffixes: &[String],
content: &str,
url: String,
default_catchup_correction: Option<&Arc<str>>,
) -> PlaylistItemHeader {
let url_types = if video_suffixes.iter().any(|suffix| url.ends_with(suffix)) {
// TODO find Series based on group or configured names
Some((XtreamCluster::Video, PlaylistItemType::Video))
} else {
None
};
let mut plih = create_empty_playlistitem_header(input_name, url);
let mut it = content.chars();
let mut stack = String::with_capacity(64);
let is_extinf = token_till(&mut stack, &mut it, ':', false)
.is_some_and(|off| stack[off..].eq_ignore_ascii_case("#EXTINF"));
stack.clear();
if is_extinf {
let mut provider_id = None::<String>;
let mut fallback_id = None::<String>;
let mut catchup = CatchupProperties::default();
let mut c = skip_digit(&mut it);
while let Some(chr) = c {
match chr {
_ if chr.is_whitespace() => {}
',' => {
let off = title_value(&mut stack, &mut it);
plih.title = stack[off..].intern();
stack.truncate(off);
}
_ => {
let tok_start = stack.len();
stack.push(chr);
if token_till(&mut stack, &mut it, '=', true).is_some() {
let token = classify_token(&stack[tok_start..]);
let token_name = stack[tok_start..].to_owned();
stack.clear();
let val_off = token_value(&mut stack, &mut it);
match token {
M3uToken::ProviderId if stack.len() > val_off => {
provider_id = Some(stack[val_off..].to_owned());
}
M3uToken::TvgChno => plih.chno = stack[val_off..].parse::<u32>().unwrap_or(0),
M3uToken::GroupTitle => plih.group = stack[val_off..].intern(),
M3uToken::TvgId => plih.epg_channel_id = if stack.len() == val_off { None } else { Some(stack[val_off..].intern()) },
M3uToken::TvgName => plih.name = stack[val_off..].intern(),
M3uToken::TvgLogo => plih.logo = stack[val_off..].intern(),
M3uToken::TvgLogoSmall => plih.logo_small = stack[val_off..].intern(),
M3uToken::ParentCode => plih.parent_code = stack[val_off..].intern(),
M3uToken::AudioTrack => plih.audio_track = stack[val_off..].intern(),
M3uToken::TimeShift => plih.time_shift = stack[val_off..].intern(),
M3uToken::TvgRec => plih.rec = stack[val_off..].intern(),
M3uToken::Catchup if stack.len() > val_off => catchup.mode = Some(stack[val_off..].intern()),
M3uToken::CatchupDays if stack.len() > val_off => catchup.days = Some(stack[val_off..].intern()),
M3uToken::CatchupSource if stack.len() > val_off => {
catchup.source = Some(stack[val_off..].intern());
}
M3uToken::CatchupTime if stack.len() > val_off => catchup.time = Some(stack[val_off..].intern()),
M3uToken::CatchupCorrection if stack.len() > val_off => {
catchup.correction = Some(stack[val_off..].intern());
}
M3uToken::CatchupType if stack.len() > val_off => {
catchup.catchup_type = Some(stack[val_off..].intern());
}
M3uToken::CatchupExtra if stack.len() > val_off => catchup.extra_attributes.push(CatchupAttribute {
name: token_name.intern(),
value: stack[val_off..].intern(),
}),
// Unknown panel-specific ID fields (e.g. "stream-id", "channel-uid")
M3uToken::PossibleId
if fallback_id.is_none()
&& stack.len() > val_off
&& stack[val_off..].bytes().all(|b| b.is_ascii_digit()) =>
{
fallback_id = Some(stack[val_off..].to_owned());
}
_ => {}
}
stack.clear();
}
}
}
c = it.next();
}
if let Some(numeric_url_id) = extract_numeric_id_from_url(&plih.url).filter(|&id| id > 0) {
// Numeric ID extracted from URL is always the authoritative provider ID.
plih.id = numeric_url_id.to_string().intern();
} else if let Some(pid) = provider_id {
plih.id = pid.intern();
} else if let Some(fid) = fallback_id {
plih.id = fid.intern();
} else {
plih.id = extract_id_from_url(&plih.url).intern();
}
apply_catchup_properties(&mut plih, catchup, default_catchup_correction);
}
if let Some((url_cluster, url_item_type)) = url_types {
plih.xtream_cluster = url_cluster;
plih.item_type = url_item_type;
}
{
let header = plih.borrow_mut();
if header.name.is_empty() {
if !header.title.is_empty() {
header.name = header.title.clone();
} else if !header.id.is_empty() {
header.name = header.id.clone();
header.title = header.id.clone();
}
}
}
plih.freeze_input_stream_id();
plih
}
#[cfg(test)]
fn process_header(input_name: &Arc<str>, video_suffixes: &[String], content: &str, url: String) -> PlaylistItemHeader {
process_header_internal(input_name, video_suffixes, content, url, None)
}
fn parse_extm3u_catchup_correction(attributes: &str) -> Option<Arc<str>> {
let mut it = attributes.chars();
let mut stack = String::with_capacity(32);
while let Some(chr) = it.next() {
if chr.is_whitespace() {
continue;
}
let tok_start = stack.len();
stack.push(chr);
if token_till(&mut stack, &mut it, '=', true).is_none() {
stack.clear();
continue;
}
let is_catchup_correction = stack[tok_start..].eq_ignore_ascii_case("catchup-correction");
stack.clear();
let val_off = token_value(&mut stack, &mut it);
if is_catchup_correction && stack.len() > val_off {
return Some(stack[val_off..].intern());
}
stack.clear();
}
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;
let video_suffixes = match cfg.video.as_ref() {
Some(config) => {
config.extensions.iter().map(Clone::clone).collect::<Vec<String>>()
}
None => default_supported_video_extensions()
};
let mut lines = tokio::io::BufReader::new(lines).lines();
let mut ord_counter: u32 = 1;
while let Ok(Some(line)) = lines.next_line().await {
let bytes = line.as_bytes();
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") {
default_catchup_correction = parse_extm3u_catchup_correction(rest);
continue;
}
if let Some(rest) = line.strip_prefix("#EXTGRP:") {
group = Some(rest.intern());
continue;
}
continue;
}
let group_value = group.take();
if let Some(header_value) = header.take() {
let mut item = PlaylistItem {
header: process_header_internal(
input_name,
&video_suffixes,
&header_value,
line,
default_catchup_correction.as_ref(),
),
};
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() {
if let Some(group_value) = group_value {
header.group = group_value;
} else {
header.group = get_title_group(&header.title);
}
}
visit(item);
}
}
}
pub async fn parse_m3u(cfg: &Config, input: &ConfigInput, lines: DynReader) -> Vec<PlaylistGroup>
{
let mut group_map: IndexMap<CategoryKey, Vec<PlaylistItem>> = IndexMap::new();
consume_m3u(cfg, input, lines, |item| {
let key = {
let header = &item.header;
let normalized_group = shared::utils::deunicode_string(&header.group).to_lowercase().intern();
(header.xtream_cluster, normalized_group)
};
group_map.entry(key).or_default().push(item);
}).await;
let mut grp_id = 0;
group_map.into_values().filter_map(|channels| {
// create a group based on the first playlist item
let channel = channels.first();
if let Some((cluster, group_title)) = channel.map(|pli|
(pli.header.xtream_cluster, &pli.header.group)) {
grp_id += 1;
Some(PlaylistGroup { id: grp_id, xtream_cluster: cluster, title: group_title.clone(), channels })
} else {
None
}
}).collect()
}
#[cfg(test)]
mod test {
use crate::model::{Config, ConfigInput};
use crate::utils::request::DynReader;
use shared::{
model::StreamProperties,
utils::Internable,
};
use crate::processing::parser::m3u::{classify_token, process_header, M3uToken};
use tokio::io::AsyncWriteExt;
fn make_reader(content: &str) -> DynReader {
let (mut writer, reader) = tokio::io::duplex(content.len().max(4096));
let bytes = content.as_bytes().to_vec();
tokio::spawn(async move {
writer.write_all(&bytes).await.unwrap();
writer.shutdown().await.unwrap();
});
Box::pin(reader)
}
fn test_input() -> ConfigInput {
ConfigInput {
name: "input".intern(),
url: "http://provider.example".to_string(),
username: Some("user".to_string()),
password: Some("pass".to_string()),
..ConfigInput::default()
}
}
#[test]
fn test_classify_token_length_dispatch_keeps_expected_mapping() {
assert!(matches!(classify_token("xui-id"), M3uToken::ProviderId));
assert!(matches!(classify_token("CUID"), M3uToken::ProviderId));
assert!(matches!(classify_token("tvg-chno"), M3uToken::TvgChno));
assert!(matches!(classify_token("group-title"), M3uToken::GroupTitle));
assert!(matches!(classify_token("TVG-ID"), M3uToken::TvgId));
assert!(matches!(classify_token("tvg-name"), M3uToken::TvgName));
assert!(matches!(classify_token("tvg-logo"), M3uToken::TvgLogo));
assert!(matches!(classify_token("tvg-logo-small"), M3uToken::TvgLogoSmall));
assert!(matches!(classify_token("parent-code"), M3uToken::ParentCode));
assert!(matches!(classify_token("audio-track"), M3uToken::AudioTrack));
assert!(matches!(classify_token("timeshift"), M3uToken::TimeShift));
assert!(matches!(classify_token("tvg-rec"), M3uToken::TvgRec));
assert!(matches!(classify_token("catchup"), M3uToken::Catchup));
assert!(matches!(classify_token("catchup-days"), M3uToken::CatchupDays));
assert!(matches!(classify_token("catchup-source"), M3uToken::CatchupSource));
assert!(matches!(classify_token("catchup-time"), M3uToken::CatchupTime));
assert!(matches!(classify_token("catchup-correction"), M3uToken::CatchupCorrection));
assert!(matches!(classify_token("catchup-type"), M3uToken::CatchupType));
assert!(matches!(classify_token("catchup-extra"), M3uToken::CatchupExtra));
assert!(matches!(classify_token("stream-id"), M3uToken::PossibleId));
assert!(matches!(classify_token("id"), M3uToken::PossibleId));
assert!(matches!(classify_token("foo"), M3uToken::Unknown));
}
#[test]
fn test_process_header_1() {
let input = "hello".intern();
let video_suffixes = Vec::new();
let url = "http://hello.de/live/user/pass/70001.ts";
let line = r#"#EXTINF:-1 channel-id="abc-seven" tvg-id="abc-seven" tvg-logo="https://abc.nz/.images/seven.png" tvg-chno="7" group-title="Sydney" , Seven"#;
let pli = process_header(&input, &video_suffixes, line, url.to_string());
assert_eq!(pli.title, "Seven".intern());
// tvg-id is preserved as epg_channel_id, id falls back to numeric url segment
assert_eq!(pli.epg_channel_id, Some("abc-seven".intern()));
assert_eq!(pli.id, "70001".intern());
assert_eq!(pli.logo, "https://abc.nz/.images/seven.png".intern());
assert_eq!(pli.chno, 7);
assert_eq!(&*pli.group, "Sydney");
}
#[test]
fn test_process_header_2() {
let input = "hello".intern();
let video_suffixes = Vec::new();
let url = "http://hello.de/live/user/pass/70002.ts";
let line = r#"#EXTINF:-1 channel-id="abc-seven" tvg-id="abc-seven" tvg-logo="https://abc.nz/.images/seven.png" tvg-chno="7" group-title="Sydney", Seven"#;
let pli = process_header(&input, &video_suffixes, line, url.to_string());
assert_eq!(pli.title, "Seven".intern());
assert_eq!(pli.epg_channel_id, Some("abc-seven".intern()));
assert_eq!(pli.id, "70002".intern());
assert_eq!(pli.logo, "https://abc.nz/.images/seven.png".intern());
assert_eq!(pli.chno, 7);
assert_eq!(&*pli.group, "Sydney");
}
#[test]
fn test_process_header_cuid_format() {
let input = "test".intern();
let video_suffixes = Vec::new();
let url = "http://line.trx-ott.com/live/18be61b480/fc19249ec409/1905905.ts";
let line = r#"#EXTINF:0 CUID="12046" tvg-name="UK-NOWTV| SKY CRIME FHD" tvg-id="skycrime.uk" tvg-logo="https://logo.m3uassets.com/skycrime.png" group-title="🔪Murder Mystery",UK-NOWTV| SKY CRIME FHD"#;
let pli = process_header(&input, &video_suffixes, line, url.to_string());
assert_eq!(pli.name, "UK-NOWTV| SKY CRIME FHD".intern());
assert_eq!(pli.title, "UK-NOWTV| SKY CRIME FHD".intern());
assert_eq!(pli.id, "1905905".intern()); // URL id is master; CUID is only fallback
assert_eq!(pli.logo, "https://logo.m3uassets.com/skycrime.png".intern());
assert_eq!(&*pli.group, "🔪Murder Mystery");
assert_eq!(pli.epg_channel_id, Some("skycrime.uk".intern()));
}
#[test]
fn test_process_header_tvg_id_uses_url_id_fallback() {
let input = "test".intern();
let video_suffixes = Vec::new();
// Numeric last segment in URL -> extract_id_from_url returns "1905905"
let url = "http://line.trx-ott.com/live/user/pass/1905905.ts";
let line = r#"#EXTINF:-1 tvg-id="skycrime.uk" tvg-name="SKY CRIME" group-title="Crime",SKY CRIME"#;
let pli = process_header(&input, &video_suffixes, line, url.to_string());
assert_eq!(pli.id, "1905905".intern()); // url_id used as fallback
assert_eq!(pli.epg_channel_id, Some("skycrime.uk".intern())); // tvg-id preserved for EPG
}
#[test]
fn test_process_header_tvg_id_preserves_mixed_case() {
// Supersedes #688: the M3U parser no longer lowercases tvg-id. EPG matching is
// now case-insensitive, so the epg_channel_id keeps its original source case and
// the M3U/XMLTV output preserves it.
let input = "test".intern();
let video_suffixes = Vec::new();
let url = "http://line.trx-ott.com/live/user/pass/1905905.ts";
let line = r#"#EXTINF:-1 tvg-id="CNN.us" tvg-name="CNN" group-title="News",CNN"#;
let pli = process_header(&input, &video_suffixes, line, url.to_string());
assert_eq!(pli.epg_channel_id, Some("CNN.us".intern())); // original case preserved, not lowercased
}
#[test]
fn test_process_header_no_tvg_id_no_provider_id() {
let input = "test".intern();
let video_suffixes = Vec::new();
let url = "http://example.com/live/user/pass/12345.ts";
let line = r#"#EXTINF:-1 tvg-name="Test Channel" group-title="Group",Test Channel"#;
let pli = process_header(&input, &video_suffixes, line, url.to_string());
assert_eq!(pli.id, "12345".intern()); // url_id as sole fallback
assert_eq!(pli.epg_channel_id, None); // no tvg-id -> no epg_channel_id
}
#[test]
fn test_process_header_expiring_query_params_id_fallback() {
let input = "test".intern();
let video_suffixes = Vec::new();
let line = r#"#EXTINF:-1 tvg-name="Test Channel" group-title="Group",Test Channel"#;
let pli = process_header(
&input,
&video_suffixes,
line,
"http://example.com/live/user/pass/1905905.ts?expires=1712345678&token=alpha".to_string(),
);
assert_eq!(pli.id, "1905905".intern());
assert_eq!(pli.epg_channel_id, None);
let pli_variant = process_header(
&input,
&video_suffixes,
line,
"http://example.com/live/user/pass/1905905.ts?expires=1719999999&token=beta".to_string(),
);
assert_eq!(pli_variant.id, "1905905".intern());
assert_eq!(pli_variant.epg_channel_id, None);
}
#[test]
fn test_process_header_xui_id() {
let input = "hello".intern();
let video_suffixes = Vec::new();
let url = "http://hello.de/hello.ts";
let line = r#"#EXTINF:-1 tvg-id="abc-seven" xui-id="provider-123" group-title="Sydney", Seven"#;
let pli = process_header(&input, &video_suffixes, line, url.to_string());
assert_eq!(pli.title, "Seven".intern());
assert_eq!(pli.id, "provider-123".intern()); // URL has no numeric id, xui-id used as fallback
assert_eq!(pli.epg_channel_id, Some("abc-seven".intern())); // Should preserve original tvg-id
assert_eq!(&*pli.group, "Sydney");
}
#[test]
fn test_process_header_unknown_numeric_id_field() {
let input = "test".intern();
let video_suffixes = Vec::new();
let url = "http://example.com/live/user/pass/99999.ts";
// "stream-id" is an unknown field ending in "id" with a numeric value
let line = r#"#EXTINF:-1 stream-id="55555" tvg-name="Test Channel" group-title="Group",Test Channel"#;
let pli = process_header(&input, &video_suffixes, line, url.to_string());
assert_eq!(pli.id, "99999".intern()); // URL numeric id is master, stream-id is only fallback
assert_eq!(pli.epg_channel_id, None);
}
#[test]
fn test_process_header_unknown_id_non_numeric_ignored() {
let input = "test".intern();
let video_suffixes = Vec::new();
let url = "http://example.com/live/user/pass/99999.ts";
// "channel-id" has a non-numeric value, should be ignored as fallback
let line = r#"#EXTINF:-1 channel-id="abc-def" tvg-name="Test" group-title="G",Test"#;
let pli = process_header(&input, &video_suffixes, line, url.to_string());
assert_eq!(pli.id, "99999".intern()); // falls through to url_id
}
#[test]
fn test_process_header_explicit_cuid_overrides_fallback_id() {
let input = "test".intern();
let video_suffixes = Vec::new();
let url = "http://example.com/live/user/pass/99999.ts";
// Both CUID (explicit) and stream-id (fallback) present: CUID wins
let line = r#"#EXTINF:-1 stream-id="55555" CUID="77777" tvg-name="Test" group-title="G",Test"#;
let pli = process_header(&input, &video_suffixes, line, url.to_string());
assert_eq!(pli.id, "99999".intern()); // URL numeric id is master, CUID/stream-id are only fallbacks
}
#[test]
fn test_process_header_preserves_catchup_attributes() {
let input = "test".intern();
let video_suffixes = Vec::new();
let url = "http://provider.example/live/user/pass/99999.ts";
let line = r#"#EXTINF:-1 tvg-id="channel1" catchup="append" catchup-days="7" catchup-source="?offset=-${offset}&utcstart=${timestamp}" catchup-correction="-1.5" catchup-type="xc" catchup-extra="keep",Channel 1"#;
let pli = process_header(&input, &video_suffixes, line, url.to_string());
let Some(StreamProperties::Live(live_props)) = pli.additional_properties else {
panic!("expected live stream properties");
};
let catchup = live_props.catchup.expect("catchup should be parsed");
assert_eq!(catchup.mode.as_deref(), Some("append"));
assert_eq!(catchup.days.as_deref(), Some("7"));
assert_eq!(catchup.source.as_deref(), Some("?offset=-${offset}&utcstart=${timestamp}"));
assert_eq!(catchup.correction.as_deref(), Some("-1.5"));
assert_eq!(catchup.catchup_type.as_deref(), Some("xc"));
assert_eq!(catchup.extra_attributes.len(), 1);
assert_eq!(catchup.extra_attributes[0].name.as_ref(), "catchup-extra");
assert_eq!(live_props.tv_archive, Some(1));
assert_eq!(live_props.tv_archive_duration, Some(7));
}
#[test]
fn test_process_header_applies_extm3u_default_catchup_correction() {
let input = "test".intern();
let video_suffixes = Vec::new();
let line = r#"#EXTINF:-1 catchup="append" catchup-source="?offset=-${offset}",Channel 1"#;
let pli = super::process_header_internal(
&input,
&video_suffixes,
line,
"http://provider.example/live/user/pass/99999.ts".to_string(),
Some(&"-2.0".intern()),
);
let Some(StreamProperties::Live(live_props)) = pli.additional_properties else {
panic!("expected live stream properties");
};
let catchup = live_props.catchup.expect("catchup should be parsed");
assert_eq!(catchup.correction.as_deref(), Some("-2.0"));
}
#[test]
fn parse_extm3u_header_catchup_correction_from_attribute_tail() {
let correction = super::parse_extm3u_catchup_correction(r#" catchup-correction="-2.0""#);
assert_eq!(correction.as_deref(), Some("-2.0"));
}
#[tokio::test]
async fn consume_m3u_applies_extgrp_to_following_item() {
let content = concat!(
"#EXTM3U\n",
"#EXTINF:-1,Channel 1\n",
"#EXTGRP:Sports\n",
"http://provider.example/live/user/pass/1.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(), 1);
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);
}
}