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
@@ -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 } }
}
+1
View File
@@ -428,6 +428,7 @@ fn process_header_internal(
}
}
plih.freeze_input_stream_id();
plih
}
+9 -5
View File
@@ -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?
+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 {