feat: hls content encoding support and shared session between different targets (#789)

* feat: key shared hls sessions by origin id
* feat: support hls content encoding
* fix: coderabbitai findings
* Fix: Custom headers are lost on provider://
In the manual redirect path, the abstract provider:// URL is compared against the resolved HTTP URL.
This causes the request to be marked as "scrubbed" before the first real redirect even occurs, stripping out headers like X-API-Key.
This commit is contained in:
knylbyte
2026-07-15 10:40:30 +02:00
committed by GitHub
parent 8f65e98241
commit c70fbc671e
64 changed files with 10183 additions and 2729 deletions
+188 -2
View File
@@ -1044,6 +1044,13 @@ fn execute_pipe<'a>(
};
let mut new_fpl = FetchedPlaylist { input: fpl.input, source, epg: fpl.epg.clone() };
// In-memory items are frozen here at the target-processing boundary. Read-only disk sources
// capture the same identity when their persisted M3U/Xtream items are converted to PlaylistItem.
if new_fpl.is_memory() {
for item in new_fpl.items_mut() {
item.header.freeze_input_stream_id();
}
}
if target.options.as_ref().is_some_and(|opt| opt.remove_duplicates) {
new_fpl.deduplicate(duplicates);
}
@@ -1664,15 +1671,194 @@ pub async fn exec_processing(
#[cfg(test)]
mod tests {
use super::*;
use shared::foundation::{get_filter, ValueProvider};
use shared::model::{ClusterFlags, PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster};
use shared::foundation::{get_filter, MapperScript, ValueProvider};
use shared::model::{
ClusterFlags, ConfigRenameDto, ConfigTargetDto, M3uPlaylistItem, PlaylistEntry, PlaylistItem,
PlaylistItemHeader, PlaylistItemType, XtreamCluster, XtreamPlaylistItem,
};
use shared::utils::Internable;
fn serialize_without_trailing_input_stream_id<T: serde::Serialize>(value: &T) -> 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");
match encoded[0] {
marker @ 0x91..=0x9f => encoded[0] = marker - 1,
0xdc => {
let len = u16::from_be_bytes([encoded[1], encoded[2]]);
encoded[1..3].copy_from_slice(&(len - 1).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());
}
marker => panic!("unexpected MessagePack sequence marker {marker:#x}"),
}
encoded
}
fn item_with_props(props: StreamProperties) -> PlaylistItem {
let header = shared::model::PlaylistItemHeader { additional_properties: Some(props), ..Default::default() };
PlaylistItem { header }
}
#[test]
fn rename_preserves_input_stream_id_captured_at_target_boundary() {
let mut item = PlaylistItem {
header: PlaylistItemHeader {
id: "origin-alpha".intern(),
url: "http://provider.example/channel.m3u8".intern(),
..Default::default()
},
};
item.header.freeze_input_stream_id();
let rename = ConfigRename::from(&ConfigRenameDto {
field: ItemField::Url,
pattern: "provider".to_string(),
new_name: "target".to_string(),
t_pattern: None,
});
exec_rename(&mut item, Some(&vec![rename]));
assert_eq!(item.header.url.as_ref(), "http://target.example/channel.m3u8");
assert_eq!(item.header.input_stream_id.as_ref(), "origin-alpha");
}
#[test]
fn mapper_changes_id_without_changing_frozen_input_stream_id() {
let mut item = PlaylistItem {
header: PlaylistItemHeader {
id: "origin-alpha".intern(),
name: "Channel".intern(),
..Default::default()
},
};
item.header.freeze_input_stream_id();
let mapping = Mapping {
mapper: Some(vec![crate::model::Mapper {
filter: r#"name ~ ".*""#.to_string(),
script: r#"@id = "target-id""#.to_string(),
t_filter: Some(get_filter(r#"name ~ ".*""#, None).expect("filter should parse")),
t_script: Some(MapperScript::parse(r#"@id = "target-id""#, None).expect("mapper should parse")),
}]),
..Default::default()
};
let (mapped, _, matched) = map_channel(item, &mapping);
assert!(matched);
assert_eq!(mapped.header.id.as_ref(), "target-id");
assert_eq!(mapped.header.input_stream_id.as_ref(), "origin-alpha");
}
#[test]
fn mapper_cannot_resurrect_missing_legacy_input_stream_id_from_target_id() {
let mut source = PlaylistItem {
header: PlaylistItemHeader {
id: "80510".intern(),
url: "http://provider.example/live/user/pass/80510.ts".intern(),
input_name: "input".intern(),
item_type: PlaylistItemType::Live,
xtream_cluster: XtreamCluster::Live,
..Default::default()
},
};
source.header.freeze_input_stream_id();
let mut legacy_xtream = XtreamPlaylistItem::from(&source);
legacy_xtream.provider_id = 0;
legacy_xtream.input_stream_id = "".intern();
legacy_xtream.url = "http://provider.example/live/channel.m3u8".intern();
let mut legacy_item = PlaylistItem::from(&legacy_xtream);
legacy_item.header.freeze_input_stream_id();
let mapping = Mapping {
mapper: Some(vec![crate::model::Mapper {
filter: r#"name ~ ".*""#.to_string(),
script: r#"@id = "target-id""#.to_string(),
t_filter: Some(get_filter(r#"name ~ ".*""#, None).expect("filter should parse")),
t_script: Some(MapperScript::parse(r#"@id = "target-id""#, None).expect("mapper should parse")),
}]),
..Default::default()
};
let (mapped, _, matched) = map_channel(legacy_item, &mapping);
let materialized_m3u = M3uPlaylistItem::from(&mapped);
let materialized_xtream = XtreamPlaylistItem::from(&mapped);
assert!(matched);
assert_eq!(mapped.header.id.as_ref(), "target-id");
assert_eq!(mapped.get_input_stream_id(), None);
assert!(materialized_m3u.provider_id.is_empty());
assert_eq!(materialized_m3u.get_input_stream_id(), None);
assert_eq!(materialized_xtream.provider_id, 0);
assert_eq!(materialized_xtream.get_input_stream_id(), None);
}
#[test]
fn execute_pipe_freezes_input_stream_id_without_rename_or_mapper() {
let input = ConfigInput::default();
let item = PlaylistItem {
header: PlaylistItemHeader { id: "origin-alpha".intern(), ..Default::default() },
};
let source = MemoryPlaylistSource::new(vec![PlaylistGroup {
id: 1,
title: "Group".intern(),
channels: vec![item],
xtream_cluster: XtreamCluster::Live,
}])
.into_source();
let mut fetched = FetchedPlaylist { input: &input, source, epg: None };
let mut duplicates = HashSet::new();
let target = ConfigTarget::from(&ConfigTargetDto::default());
let mut processed = execute_pipe(&target, &vec![], &mut fetched, &mut duplicates, false)
.expect("target processing should succeed");
let mut groups = processed.source.take_groups();
assert_eq!(groups[0].channels[0].header.input_stream_id.as_ref(), "origin-alpha");
assert!(groups[0].channels[0].header.set_field("id", "late-target-id"));
assert_eq!(groups[0].channels[0].header.input_stream_id.as_ref(), "origin-alpha");
}
#[test]
fn legacy_messagepack_playlist_items_default_missing_input_stream_id() {
let mut source = PlaylistItem {
header: PlaylistItemHeader {
id: "origin-alpha".intern(),
url: "http://provider.example/live/user/pass/80510.ts".intern(),
input_name: "input".intern(),
item_type: PlaylistItemType::Live,
xtream_cluster: XtreamCluster::Live,
..Default::default()
},
};
let header_bytes = serialize_without_trailing_input_stream_id(&source.header);
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);
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"));
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 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"));
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 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"));
}
#[test]
fn has_probe_details_requires_video_and_audio_for_video() {
let video = shared::model::VideoStreamProperties {