mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-19 14:22:19 +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:
@@ -1,11 +1,12 @@
|
||||
use super::rewrite_hls_url;
|
||||
use crate::api::model::{
|
||||
ProxySessionId, TransientResourceId, TransientResourceKind, TransientResourceRef,
|
||||
HLS_ACCESS_LEASE_ID_PLACEHOLDER,
|
||||
use crate::{
|
||||
api::model::{
|
||||
ProxySessionId, TransientResourceId, TransientResourceKind, TransientResourceRef,
|
||||
HLS_ACCESS_LEASE_ID_PLACEHOLDER,
|
||||
},
|
||||
model::StripConfig,
|
||||
};
|
||||
use crate::model::StripConfig;
|
||||
use shared::model::HlsStripMode;
|
||||
use shared::utils::CONSTANTS;
|
||||
use shared::{model::HlsStripMode, utils::CONSTANTS};
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
use url::Url;
|
||||
|
||||
@@ -83,20 +84,14 @@ impl TransientManifestRewriter {
|
||||
}
|
||||
|
||||
if body.is_empty() {
|
||||
return TransientRewriteResult {
|
||||
body: rewritten_body,
|
||||
resources: Vec::new(),
|
||||
};
|
||||
return TransientRewriteResult { body: rewritten_body, resources: Vec::new() };
|
||||
}
|
||||
|
||||
if let Some(discontinuity_sequence) = options.handoff_discontinuity_sequence {
|
||||
rewritten_body = apply_handoff_discontinuity_boundary(&rewritten_body, discontinuity_sequence);
|
||||
}
|
||||
|
||||
TransientRewriteResult {
|
||||
body: rewritten_body,
|
||||
resources: resources.into_values().collect(),
|
||||
}
|
||||
TransientRewriteResult { body: rewritten_body, resources: resources.into_values().collect() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,12 +137,8 @@ pub fn materialize_transient_provisioning_handoff_view(
|
||||
&previous_units,
|
||||
previous_units.len().saturating_sub(selected_previous_units.len()),
|
||||
);
|
||||
let mut rewritten = String::with_capacity(
|
||||
origin_body
|
||||
.len()
|
||||
.saturating_add(previous_provisioning_body.len())
|
||||
.saturating_add(64),
|
||||
);
|
||||
let mut rewritten =
|
||||
String::with_capacity(origin_body.len().saturating_add(previous_provisioning_body.len()).saturating_add(64));
|
||||
for line in &origin_lines[..first_origin_unit.start] {
|
||||
if line.line.trim().starts_with("#EXT-X-MEDIA-SEQUENCE:") {
|
||||
if let Some(media_sequence) = media_sequence {
|
||||
@@ -213,19 +204,12 @@ pub fn apply_transient_discontinuity_sequence(body: &str, discontinuity_sequence
|
||||
}
|
||||
|
||||
pub fn transient_discontinuity_sequence(body: &str) -> Option<u64> {
|
||||
manifest_lines(body)
|
||||
.iter()
|
||||
.find_map(|line| parse_discontinuity_sequence(line.line))
|
||||
manifest_lines(body).iter().find_map(|line| parse_discontinuity_sequence(line.line))
|
||||
}
|
||||
|
||||
pub fn transient_visible_discontinuity_count(body: &str) -> u64 {
|
||||
u64::try_from(
|
||||
manifest_lines(body)
|
||||
.iter()
|
||||
.filter(|line| is_discontinuity_tag(line.line.trim()))
|
||||
.count(),
|
||||
)
|
||||
.unwrap_or(u64::MAX)
|
||||
u64::try_from(manifest_lines(body).iter().filter(|line| is_discontinuity_tag(line.line.trim())).count())
|
||||
.unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
fn rewrite_line(
|
||||
@@ -268,6 +252,21 @@ fn rewrite_line(
|
||||
);
|
||||
}
|
||||
|
||||
if trimmed.starts_with("#EXT-X-PART:")
|
||||
|| (trimmed.starts_with("#EXT-X-PRELOAD-HINT:") && trimmed.contains("TYPE=PART"))
|
||||
{
|
||||
return rewrite_uri_attribute(
|
||||
line,
|
||||
final_manifest_url,
|
||||
proxy_session_id,
|
||||
reverse_proxy_rewrite_secret,
|
||||
now_ms,
|
||||
ttl_ms,
|
||||
resources,
|
||||
TransientResourceKind::Part,
|
||||
);
|
||||
}
|
||||
|
||||
if trimmed.starts_with('#') && CONSTANTS.re_hls_uri.is_match(line) {
|
||||
return rewrite_uri_attribute(
|
||||
line,
|
||||
@@ -323,10 +322,7 @@ fn rewrite_uri_attribute(
|
||||
resources,
|
||||
kind,
|
||||
);
|
||||
CONSTANTS
|
||||
.re_hls_uri
|
||||
.replace(line, format!(r#"URI="{proxy_uri}""#))
|
||||
.to_string()
|
||||
CONSTANTS.re_hls_uri.replace(line, format!(r#"URI="{proxy_uri}""#)).to_string()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -353,10 +349,7 @@ fn rewrite_resource_uri(
|
||||
Some(extension.clone()),
|
||||
);
|
||||
let resource_id = resource.id.clone();
|
||||
resources
|
||||
.entry(resource_id.clone())
|
||||
.and_modify(|existing| *existing = resource.clone())
|
||||
.or_insert(resource);
|
||||
resources.entry(resource_id.clone()).and_modify(|existing| *existing = resource.clone()).or_insert(resource);
|
||||
(
|
||||
format!(
|
||||
"/hls/shared/live/{}/{}/r/{}.{}",
|
||||
@@ -381,12 +374,8 @@ fn extract_extension(resolved_origin_uri: &str) -> Option<String> {
|
||||
.filter(|extension| !extension.is_empty());
|
||||
}
|
||||
|
||||
let without_query = resolved_origin_uri
|
||||
.split_once('?')
|
||||
.map_or(resolved_origin_uri, |(path, _)| path);
|
||||
let without_fragment = without_query
|
||||
.split_once('#')
|
||||
.map_or(without_query, |(path, _)| path);
|
||||
let without_query = resolved_origin_uri.split_once('?').map_or(resolved_origin_uri, |(path, _)| path);
|
||||
let without_fragment = without_query.split_once('#').map_or(without_query, |(path, _)| path);
|
||||
without_fragment
|
||||
.rsplit_once('.')
|
||||
.map(|(_, extension)| extension.to_string())
|
||||
@@ -397,7 +386,7 @@ fn fallback_extension(kind: TransientResourceKind) -> &'static str {
|
||||
match kind {
|
||||
TransientResourceKind::Key => "key",
|
||||
TransientResourceKind::Map => "mp4",
|
||||
TransientResourceKind::Segment | TransientResourceKind::Other => "bin",
|
||||
TransientResourceKind::Segment | TransientResourceKind::Part | TransientResourceKind::Other => "bin",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,12 +505,7 @@ fn append_media_unit_with_duration_override(
|
||||
}
|
||||
}
|
||||
|
||||
fn append_gap_media_unit(
|
||||
output: &mut String,
|
||||
lines: &[ManifestLine<'_>],
|
||||
unit: MediaSegmentUnit,
|
||||
duration_ms: u64,
|
||||
) {
|
||||
fn append_gap_media_unit(output: &mut String, lines: &[ManifestLine<'_>], unit: MediaSegmentUnit, duration_ms: u64) {
|
||||
output.push_str("#EXT-X-GAP\n");
|
||||
let uri_index = lines[unit.start..=unit.end]
|
||||
.iter()
|
||||
@@ -551,9 +535,7 @@ fn append_manifest_block_separator(output: &mut String) {
|
||||
}
|
||||
|
||||
fn media_unit_has_discontinuity(lines: &[ManifestLine<'_>], unit: MediaSegmentUnit) -> bool {
|
||||
lines[unit.start..=unit.end]
|
||||
.iter()
|
||||
.any(|line| is_discontinuity_tag(line.line.trim()))
|
||||
lines[unit.start..=unit.end].iter().any(|line| is_discontinuity_tag(line.line.trim()))
|
||||
}
|
||||
|
||||
fn handoff_provisioning_media_sequence(
|
||||
@@ -576,12 +558,9 @@ fn format_duration_ms(duration_ms: u64) -> String { format!("{}.{:03}", duration
|
||||
|
||||
fn apply_handoff_discontinuity_boundary(body: &str, handoff_discontinuity_sequence: u64) -> String {
|
||||
let lines = manifest_lines(body);
|
||||
let origin_discontinuity_sequence = lines
|
||||
.iter()
|
||||
.find_map(|line| parse_discontinuity_sequence(line.line))
|
||||
.unwrap_or(0);
|
||||
let effective_discontinuity_sequence =
|
||||
origin_discontinuity_sequence.saturating_add(handoff_discontinuity_sequence);
|
||||
let origin_discontinuity_sequence =
|
||||
lines.iter().find_map(|line| parse_discontinuity_sequence(line.line)).unwrap_or(0);
|
||||
let effective_discontinuity_sequence = origin_discontinuity_sequence.saturating_add(handoff_discontinuity_sequence);
|
||||
let existing_sequence_index = lines.iter().position(|line| is_discontinuity_sequence_tag(line.line.trim()));
|
||||
let insert_sequence_index = existing_sequence_index.unwrap_or_else(|| discontinuity_sequence_insert_index(&lines));
|
||||
let first_segment = first_media_segment_boundary(&lines);
|
||||
@@ -656,9 +635,7 @@ fn parse_discontinuity_sequence(line: &str) -> Option<u64> {
|
||||
line.trim().strip_prefix("#EXT-X-DISCONTINUITY-SEQUENCE:")?.trim().parse().ok()
|
||||
}
|
||||
|
||||
fn is_discontinuity_sequence_tag(line: &str) -> bool {
|
||||
line.starts_with("#EXT-X-DISCONTINUITY-SEQUENCE:")
|
||||
}
|
||||
fn is_discontinuity_sequence_tag(line: &str) -> bool { line.starts_with("#EXT-X-DISCONTINUITY-SEQUENCE:") }
|
||||
|
||||
fn is_discontinuity_tag(line: &str) -> bool { line == "#EXT-X-DISCONTINUITY" }
|
||||
|
||||
@@ -670,9 +647,7 @@ fn duration_ms_from_extinf(value: &str) -> Option<u64> {
|
||||
u64::try_from(Duration::from_secs_f64(seconds).as_millis()).ok()
|
||||
}
|
||||
|
||||
fn is_segment_unit_tag(line: &str) -> bool {
|
||||
is_media_segment_unit_tag(line)
|
||||
}
|
||||
fn is_segment_unit_tag(line: &str) -> bool { is_media_segment_unit_tag(line) }
|
||||
|
||||
fn is_media_segment_unit_tag(line: &str) -> bool {
|
||||
line.starts_with("#EXTINF:")
|
||||
@@ -694,14 +669,23 @@ mod tests {
|
||||
apply_transient_discontinuity_sequence, materialize_transient_provisioning_handoff_view,
|
||||
transient_discontinuity_sequence, TransientManifestRewriter, TransientRewriteOptions,
|
||||
};
|
||||
use crate::api::model::{build_transient_resource_id, ProxySessionId, TransientResourceKind};
|
||||
use crate::model::StripConfig;
|
||||
use shared::model::HlsStripMode;
|
||||
use crate::{
|
||||
api::model::{build_transient_resource_id, ProxySessionId, TransientResourceKind},
|
||||
model::StripConfig,
|
||||
};
|
||||
use shared::model::HlsStripMode;
|
||||
|
||||
const BASE_URL: &str = "http://origin.example.com/live/final/index.m3u8";
|
||||
|
||||
fn rewrite(body: &str) -> crate::processing::parser::hls::transient_manifest::TransientRewriteResult {
|
||||
TransientManifestRewriter::rewrite(body, BASE_URL, &ProxySessionId("proxy-id".to_string()), b"secret", 100, 1_000)
|
||||
TransientManifestRewriter::rewrite(
|
||||
body,
|
||||
BASE_URL,
|
||||
&ProxySessionId("proxy-id".to_string()),
|
||||
b"secret",
|
||||
100,
|
||||
1_000,
|
||||
)
|
||||
}
|
||||
|
||||
fn rewrite_with_handoff(body: &str) -> crate::processing::parser::hls::transient_manifest::TransientRewriteResult {
|
||||
@@ -712,9 +696,7 @@ use shared::model::HlsStripMode;
|
||||
b"secret",
|
||||
100,
|
||||
1_000,
|
||||
TransientRewriteOptions {
|
||||
handoff_discontinuity_sequence: Some(0),
|
||||
},
|
||||
TransientRewriteOptions { handoff_discontinuity_sequence: Some(0) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -722,7 +704,9 @@ use shared::model::HlsStripMode;
|
||||
fn ext_x_key_uri_is_rewritten_without_changing_other_attributes() {
|
||||
let result = rewrite("#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI=\"keys/key.bin\",IV=0x1\n#EXTINF:4.0,\nseg.ts\n");
|
||||
|
||||
assert!(result.body.contains("#EXT-X-KEY:METHOD=AES-128,URI=\"/hls/shared/live/proxy-id/__hls_access_lease_id__/r/"));
|
||||
assert!(result
|
||||
.body
|
||||
.contains("#EXT-X-KEY:METHOD=AES-128,URI=\"/hls/shared/live/proxy-id/__hls_access_lease_id__/r/"));
|
||||
assert!(result.body.contains(".bin\",IV=0x1"));
|
||||
assert!(!result.body.contains("keys/key.bin"));
|
||||
}
|
||||
@@ -767,12 +751,16 @@ use shared::model::HlsStripMode;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_uri_attributes_are_rewritten_to_transient_resources() {
|
||||
fn low_latency_part_uri_is_rewritten_with_part_diagnostics_kind() {
|
||||
let result = rewrite("#EXTM3U\n#EXT-X-PART:DURATION=1.0,URI=\"parts/part001.m4s\"\n");
|
||||
|
||||
assert!(result.body.contains("#EXT-X-PART:DURATION=1.0,URI=\"/hls/shared/live/proxy-id/__hls_access_lease_id__/r/"));
|
||||
assert!(result
|
||||
.body
|
||||
.contains("#EXT-X-PART:DURATION=1.0,URI=\"/hls/shared/live/proxy-id/__hls_access_lease_id__/r/"));
|
||||
assert!(result.body.contains(".m4s\""));
|
||||
assert!(!result.body.contains("parts/part001.m4s"));
|
||||
assert_eq!(result.resources.len(), 1);
|
||||
assert_eq!(result.resources[0].kind, TransientResourceKind::Part);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -888,7 +876,9 @@ use shared::model::HlsStripMode;
|
||||
assert!(body.contains("#EXT-X-DISCONTINUITY-SEQUENCE:0\n\n#EXTINF:2.000,"));
|
||||
assert_eq!(body.matches("#EXTINF:").count(), 6);
|
||||
assert!(body.contains("#EXTINF:2.000,\n/hls/shared/live/proxy-id/__hls_access_lease_id__/000002.ts?pseq=2"));
|
||||
assert!(body.contains("\n#EXT-X-GAP\n#EXTINF:2.000,\n/hls/shared/live/proxy-id/__hls_access_lease_id__/000003.ts?pseq=3"));
|
||||
assert!(body.contains(
|
||||
"\n#EXT-X-GAP\n#EXTINF:2.000,\n/hls/shared/live/proxy-id/__hls_access_lease_id__/000003.ts?pseq=3"
|
||||
));
|
||||
assert!(body.contains("\n#EXT-X-DISCONTINUITY\n#EXTINF:1.92,"));
|
||||
let gap = body.find("\n#EXT-X-GAP\n").expect("gap tag");
|
||||
let discontinuity = body.find("\n#EXT-X-DISCONTINUITY\n#EXTINF:1.92,").expect("handoff discontinuity");
|
||||
@@ -907,5 +897,4 @@ use shared::model::HlsStripMode;
|
||||
}
|
||||
|
||||
fn strip_segments(value: u64) -> StripConfig { StripConfig { mode: HlsStripMode::Segments, value } }
|
||||
|
||||
}
|
||||
|
||||
@@ -428,6 +428,7 @@ fn process_header_internal(
|
||||
}
|
||||
}
|
||||
|
||||
plih.freeze_input_stream_id();
|
||||
plih
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ pub fn parse_xtream_series_info(parent_uuid: &UUIDType, series_info: &SeriesStre
|
||||
}
|
||||
|
||||
|
||||
PlaylistItem {
|
||||
let mut item = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: generate_provider_playlist_uuid(&input.name, &episode_id, PlaylistItemType::Series),
|
||||
id: episode_id.into(),
|
||||
@@ -124,8 +124,10 @@ pub fn parse_xtream_series_info(parent_uuid: &UUIDType, series_info: &SeriesStre
|
||||
// Keep episode ordering tied to its parent SeriesInfo to avoid cross-series ordinal overlap.
|
||||
source_ordinal: parent_source_ordinal,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
item.header.freeze_input_stream_id();
|
||||
item
|
||||
}).collect();
|
||||
return if result.is_empty() { None } else { Some(result) };
|
||||
}
|
||||
@@ -199,7 +201,7 @@ pub async fn parse_xtream(input: &ConfigInput,
|
||||
let category_name = &group.category_name;
|
||||
let stream_url = create_xtream_url(xtream_cluster, url, username, password, &stream, live_stream_use_prefix, live_stream_without_extension);
|
||||
let item_type = PlaylistItemType::from(xtream_cluster);
|
||||
let item = PlaylistItem {
|
||||
let mut item = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
id: stream.get_stream_id().intern(),
|
||||
uuid: generate_provider_playlist_uuid(&input_name, &stream.get_stream_id().to_string(), item_type),
|
||||
@@ -217,6 +219,7 @@ pub async fn parse_xtream(input: &ConfigInput,
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
item.header.freeze_input_stream_id();
|
||||
group.add(item);
|
||||
}
|
||||
|
||||
@@ -371,7 +374,7 @@ where
|
||||
let stream_url = create_xtream_url(cluster, url, username, password, &stream, live_stream_use_prefix, live_stream_without_extension);
|
||||
|
||||
let item_type = PlaylistItemType::from(cluster);
|
||||
let item = PlaylistItem {
|
||||
let mut item = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
id: stream.get_stream_id().intern(),
|
||||
uuid: generate_provider_playlist_uuid(input_name, &stream.get_stream_id().to_string(), item_type),
|
||||
@@ -390,6 +393,7 @@ where
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
item.header.freeze_input_stream_id();
|
||||
|
||||
// if let Some(StreamProperties::Series(props)) = item.header.additional_properties.as_mut() {
|
||||
// // We need to set category_id for Series properties just like parse_xtream might expect or use?
|
||||
|
||||
@@ -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