mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-23 00:03:33 +02:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user