mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-25 08:12:19 +01: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
-1
@@ -73,7 +73,7 @@ dashmap.workspace = true
|
||||
hyper = "1.10.1"
|
||||
hyper-util = "0.1.20"
|
||||
socket2 = { version = "0.6.4", features = ["all"] }
|
||||
async-compression = { version = "0.4.42", features = ["tokio", "gzip", "zlib"] }
|
||||
async-compression = { version = "0.4.42", features = ["tokio", "brotli", "deflate", "gzip", "zlib", "zstd"] }
|
||||
chrono-tz = "0.10.4"
|
||||
zeroize.workspace = true
|
||||
uuid = { version = "1.23.3", features = ["v4"] }
|
||||
|
||||
+592
-511
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ use crate::{
|
||||
api_utils::{create_api_proxy_user, mark_response_as_uncompressed},
|
||||
endpoints::hls_api::{
|
||||
build_virtual_hls_entry_path, hls_panel_provisioning_poll_manifest_response,
|
||||
resolve_hls_virtual_input_for_target,
|
||||
resolve_hls_virtual_source_for_target,
|
||||
},
|
||||
model::{
|
||||
create_custom_video_stream_response, hls_custom_video_manifest_response_with_virtual_id,
|
||||
@@ -410,19 +410,20 @@ async fn cvs_provisioning_manifest_api(
|
||||
Err(response) => return *response,
|
||||
};
|
||||
|
||||
let Some(input) = resolve_hls_virtual_input_for_target(&app_state, &target, query.id).await else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
let source = match resolve_hls_virtual_source_for_target(&app_state, &target, query.id).await {
|
||||
Ok(source) => source,
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
||||
let original_hls_entry_path = build_virtual_hls_entry_path(&target, &input, &user, query.id);
|
||||
let original_hls_entry_path = build_virtual_hls_entry_path(&target, &source.input, &user, query.id);
|
||||
let server_path = app_state.app_config.get_user_server_info(&user).and_then(|server| server.path);
|
||||
hls_panel_provisioning_poll_manifest_response(
|
||||
&app_state,
|
||||
&fingerprint,
|
||||
&user,
|
||||
&target,
|
||||
&input,
|
||||
query.id,
|
||||
&source.input,
|
||||
&source.stream_identity,
|
||||
&original_hls_entry_path,
|
||||
server_path.as_deref(),
|
||||
)
|
||||
|
||||
+1075
-537
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ use crate::{
|
||||
endpoints::{
|
||||
hls_api::{
|
||||
build_virtual_hls_entry_path, handle_hls_stream_request, hls_admission_failure_manifest_response,
|
||||
hls_custom_video_manifest_response, m3u_archive_epg_reference_ts,
|
||||
hls_custom_video_manifest_response, m3u_archive_epg_reference_ts, HlsEntryStreamIdentity,
|
||||
},
|
||||
xtream_api::{ApiStreamContext, ApiStreamRequest},
|
||||
},
|
||||
@@ -305,6 +305,8 @@ pub(in crate::api) async fn m3u_api_stream_loaded(
|
||||
input: &input,
|
||||
user: &user,
|
||||
session_reservation_ttl_secs: get_session_reservation_ttl_secs(app_state, pli.item_type),
|
||||
content_representation:
|
||||
crate::api::model::ProviderContentRepresentationMode::for_playback_extension(extension),
|
||||
},
|
||||
None,
|
||||
)
|
||||
@@ -396,6 +398,13 @@ pub(in crate::api) async fn m3u_api_stream_loaded(
|
||||
let archive_reference = m3u_archive_epg_reference_ts(&pli.url);
|
||||
// Reverse proxy mode — only route genuine HLS into the HLS handler, not DASH
|
||||
if is_session_request && extension == shared::defaults::HLS_EXT {
|
||||
let Some(stream_identity) = HlsEntryStreamIdentity::from_playlist_item(&pli) else {
|
||||
error!(
|
||||
"HLS input stream identity missing for virtual_id={}; refresh target playlist",
|
||||
pli.virtual_id
|
||||
);
|
||||
return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||
};
|
||||
let original_hls_entry_path = build_virtual_hls_entry_path(&target, &input, &user, pli.virtual_id);
|
||||
return handle_hls_stream_request(
|
||||
fingerprint,
|
||||
@@ -405,7 +414,7 @@ pub(in crate::api) async fn m3u_api_stream_loaded(
|
||||
user_session.as_ref(),
|
||||
&pli.url,
|
||||
archive_reference,
|
||||
pli.virtual_id,
|
||||
stream_identity,
|
||||
&input,
|
||||
req_headers,
|
||||
connection_permission,
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::{
|
||||
endpoints::{
|
||||
hls_api::{
|
||||
build_virtual_hls_entry_path, handle_hls_stream_request, hls_admission_failure_manifest_response,
|
||||
hls_custom_video_manifest_response,
|
||||
hls_custom_video_manifest_response, HlsEntryStreamIdentity,
|
||||
},
|
||||
xmltv_api::{get_empty_epg_response, get_epg_path_for_target_by_type, serve_short_epg},
|
||||
},
|
||||
@@ -483,6 +483,8 @@ async fn xtream_player_api_stream(
|
||||
input: &input,
|
||||
user: &user,
|
||||
session_reservation_ttl_secs: get_session_reservation_ttl_secs(app_state, item_type),
|
||||
content_representation:
|
||||
crate::api::model::ProviderContentRepresentationMode::for_playback_extension(playback_ext),
|
||||
},
|
||||
None,
|
||||
)
|
||||
@@ -576,6 +578,13 @@ async fn xtream_player_api_stream(
|
||||
let is_session_request = is_session_based_playback(item_type, Some(playback_ext));
|
||||
// Reverse proxy mode — only route genuine HLS into the HLS handler, not DASH
|
||||
if is_session_request && playback_ext == shared::defaults::HLS_EXT {
|
||||
let Some(stream_identity) = HlsEntryStreamIdentity::from_playlist_item(&pli) else {
|
||||
error!(
|
||||
"HLS input stream identity missing for virtual_id={}; refresh target playlist",
|
||||
pli.virtual_id
|
||||
);
|
||||
return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||
};
|
||||
let original_hls_entry_path = build_virtual_hls_entry_path(&target, &input, &user, pli.virtual_id);
|
||||
return handle_hls_stream_request(
|
||||
fingerprint,
|
||||
@@ -585,7 +594,7 @@ async fn xtream_player_api_stream(
|
||||
user_session.as_ref(),
|
||||
&stream_url,
|
||||
None,
|
||||
pli.virtual_id,
|
||||
stream_identity,
|
||||
&input,
|
||||
req_headers,
|
||||
connection_permission,
|
||||
@@ -747,6 +756,10 @@ pub(in crate::api) async fn xtream_player_api_stream_with_token(
|
||||
|
||||
// Reverse proxy mode — only route genuine HLS into the HLS handler, not DASH
|
||||
if is_session_request && playback_ext == Some(shared::defaults::HLS_EXT) {
|
||||
let Some(stream_identity) = HlsEntryStreamIdentity::from_playlist_item(&pli) else {
|
||||
error!("HLS input stream identity missing for virtual_id={virtual_id}; refresh target playlist");
|
||||
return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||
};
|
||||
let original_hls_entry_path = build_virtual_hls_entry_path(&target, &input, &user, virtual_id);
|
||||
return handle_hls_stream_request(
|
||||
fingerprint,
|
||||
@@ -756,7 +769,7 @@ pub(in crate::api) async fn xtream_player_api_stream_with_token(
|
||||
None,
|
||||
&pli.url,
|
||||
None,
|
||||
virtual_id,
|
||||
stream_identity,
|
||||
&input,
|
||||
req_headers,
|
||||
UserConnectionPermission::Allowed,
|
||||
@@ -1780,6 +1793,7 @@ mod tests {
|
||||
input_name: "local".intern(),
|
||||
channel_no: 1,
|
||||
source_ordinal: 0,
|
||||
input_stream_id: "".intern(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1930,6 +1944,7 @@ mod tests {
|
||||
input_name: "strong".intern(),
|
||||
channel_no: 0,
|
||||
source_ordinal: 0,
|
||||
input_stream_id: "813563".intern(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@ use std::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Arc, RwLock as StdRwLock,
|
||||
},
|
||||
time::{Duration, SystemTime},
|
||||
time::SystemTime,
|
||||
};
|
||||
use tokio::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt, SeekFrom},
|
||||
sync::{Mutex, RwLock},
|
||||
time::timeout,
|
||||
time::{timeout_at, Instant},
|
||||
};
|
||||
|
||||
pub const DEFAULT_HLS_CACHE_PATH: &str = "/tmp/tuliprox/cache/hls";
|
||||
@@ -173,6 +173,31 @@ pub struct StagedCacheObject {
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// Typed source used to distinguish a decoded-object size violation from a filesystem failure.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("hls cache object exceeds configured size limit {limit}")]
|
||||
pub(crate) struct HlsCacheObjectLimitError {
|
||||
limit: u64,
|
||||
}
|
||||
|
||||
impl HlsCacheObjectLimitError {
|
||||
pub(crate) fn limit(&self) -> u64 { self.limit }
|
||||
}
|
||||
|
||||
pub(crate) fn hls_cache_object_limit_from_io(error: &io::Error) -> Option<&HlsCacheObjectLimitError> {
|
||||
let mut source: &(dyn std::error::Error + 'static) = error.get_ref()?;
|
||||
loop {
|
||||
if let Some(limit_error) = source.downcast_ref::<HlsCacheObjectLimitError>() {
|
||||
return Some(limit_error);
|
||||
}
|
||||
source = source.source()?;
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_object_limit_error(limit: u64) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, HlsCacheObjectLimitError { limit })
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum CacheInvalidationOutcome {
|
||||
Invalidated,
|
||||
@@ -258,11 +283,11 @@ impl HlsSegmentCache {
|
||||
self.write_temp_and_commit_inner(key, &mut reader, None).await
|
||||
}
|
||||
|
||||
pub async fn write_temp_and_commit_with_timeout<K, R>(
|
||||
pub async fn write_temp_and_commit_with_deadline<K, R>(
|
||||
&self,
|
||||
key: &K,
|
||||
mut reader: R,
|
||||
deadline: Duration,
|
||||
deadline: Instant,
|
||||
) -> io::Result<CachedSegmentMetadata>
|
||||
where
|
||||
K: HlsCacheObjectKey,
|
||||
@@ -271,11 +296,11 @@ impl HlsSegmentCache {
|
||||
self.write_temp_and_commit_inner(key, &mut reader, Some(deadline)).await
|
||||
}
|
||||
|
||||
pub async fn stage_temp_with_timeout<K, R>(
|
||||
pub async fn stage_temp_with_deadline<K, R>(
|
||||
&self,
|
||||
key: &K,
|
||||
mut reader: R,
|
||||
deadline: Duration,
|
||||
deadline: Instant,
|
||||
) -> io::Result<StagedCacheObject>
|
||||
where
|
||||
K: HlsCacheObjectKey,
|
||||
@@ -301,8 +326,9 @@ impl HlsSegmentCache {
|
||||
K: HlsCacheObjectKey,
|
||||
{
|
||||
self.ensure_cache_root_marker().await?;
|
||||
if staged.size > self.max_object_bytes.load(Ordering::Acquire) {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "hls cache object exceeds configured size limit"));
|
||||
let max_object_bytes = self.max_object_bytes.load(Ordering::Acquire);
|
||||
if staged.size > max_object_bytes {
|
||||
return Err(cache_object_limit_error(max_object_bytes));
|
||||
}
|
||||
let final_path = self.path_for_key(key);
|
||||
let Some(parent) = final_path.parent() else {
|
||||
@@ -314,12 +340,8 @@ impl HlsSegmentCache {
|
||||
let mut capacity = self.capacity.lock().await;
|
||||
if !capacity.initialized || capacity.cache_path != cache_path {
|
||||
let (total_bytes, session_bytes) = scan_committed_cache_usage(&cache_path).await?;
|
||||
*capacity = CacheCapacityState {
|
||||
cache_path: cache_path.clone(),
|
||||
initialized: true,
|
||||
total_bytes,
|
||||
session_bytes,
|
||||
};
|
||||
*capacity =
|
||||
CacheCapacityState { cache_path: cache_path.clone(), initialized: true, total_bytes, session_bytes };
|
||||
}
|
||||
let old_size = match fs::metadata(&final_path).await {
|
||||
Ok(metadata) => metadata.len(),
|
||||
@@ -360,7 +382,7 @@ impl HlsSegmentCache {
|
||||
&self,
|
||||
key: &K,
|
||||
reader: &mut R,
|
||||
deadline: Option<Duration>,
|
||||
deadline: Option<Instant>,
|
||||
) -> io::Result<CachedSegmentMetadata>
|
||||
where
|
||||
K: HlsCacheObjectKey,
|
||||
@@ -374,12 +396,15 @@ impl HlsSegmentCache {
|
||||
&self,
|
||||
key: &K,
|
||||
reader: &mut R,
|
||||
deadline: Option<Duration>,
|
||||
deadline: Option<Instant>,
|
||||
) -> io::Result<StagedCacheObject>
|
||||
where
|
||||
K: HlsCacheObjectKey,
|
||||
R: AsyncRead + Unpin,
|
||||
{
|
||||
if deadline.is_some_and(|deadline| deadline <= Instant::now()) {
|
||||
return Err(io::Error::new(io::ErrorKind::TimedOut, "hls cache object write timed out"));
|
||||
}
|
||||
self.ensure_cache_root_marker().await?;
|
||||
let final_path = self.path_for_key(key);
|
||||
let Some(parent) = final_path.parent() else {
|
||||
@@ -388,19 +413,25 @@ impl HlsSegmentCache {
|
||||
fs::create_dir_all(parent).await?;
|
||||
|
||||
let (temp_path, mut temp_file) = self.create_temp_file(key).await?;
|
||||
if deadline.is_some_and(|deadline| deadline <= Instant::now()) {
|
||||
drop(temp_file);
|
||||
let _ = fs::remove_file(&temp_path).await;
|
||||
self.unregister_temp_file(&temp_path).await;
|
||||
return Err(io::Error::new(io::ErrorKind::TimedOut, "hls cache object write timed out"));
|
||||
}
|
||||
let copy = async {
|
||||
let max_object_bytes = self.max_object_bytes.load(Ordering::Acquire);
|
||||
let mut limited = reader.take(max_object_bytes.saturating_add(1));
|
||||
let size = tokio::io::copy(&mut limited, &mut temp_file).await?;
|
||||
if size > max_object_bytes {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "hls cache object exceeds configured size limit"));
|
||||
return Err(cache_object_limit_error(max_object_bytes));
|
||||
}
|
||||
temp_file.flush().await?;
|
||||
drop(temp_file);
|
||||
Ok::<u64, io::Error>(size)
|
||||
};
|
||||
let copy_result = if let Some(deadline) = deadline {
|
||||
if let Ok(result) = timeout(deadline, copy).await {
|
||||
if let Ok(result) = timeout_at(deadline, copy).await {
|
||||
result
|
||||
} else {
|
||||
let _ = fs::remove_file(&temp_path).await;
|
||||
@@ -436,11 +467,7 @@ impl HlsSegmentCache {
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
if result.is_ok()
|
||||
&& size > 0
|
||||
&& capacity.initialized
|
||||
&& capacity.cache_path == self.cache_path_snapshot()
|
||||
{
|
||||
if result.is_ok() && size > 0 && capacity.initialized && capacity.cache_path == self.cache_path_snapshot() {
|
||||
capacity.total_bytes = capacity.total_bytes.saturating_sub(size);
|
||||
let session_component = key.session_path_component();
|
||||
if let Some(session_bytes) = capacity.session_bytes.get_mut(&session_component) {
|
||||
@@ -610,13 +637,7 @@ impl HlsSegmentCache {
|
||||
|
||||
async fn ensure_cache_root_marker(&self) -> io::Result<()> {
|
||||
let cache_path = self.cache_path_snapshot();
|
||||
if self
|
||||
.marker_path
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.as_ref()
|
||||
== Some(&cache_path)
|
||||
{
|
||||
if self.marker_path.read().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() == Some(&cache_path) {
|
||||
return Ok(());
|
||||
}
|
||||
ensure_not_root_like_cache_path(&cache_path)?;
|
||||
@@ -744,9 +765,17 @@ impl Default for HlsSegmentCache {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CacheInvalidationOutcome, HlsSegmentCache, MapCacheKey, SegmentCacheKey, TransientObjectCacheKey};
|
||||
use crate::api::model::{build_transient_resource_id, ProxySessionId};
|
||||
use std::{collections::HashSet, io, sync::Arc, time::{Duration, SystemTime}};
|
||||
use super::{
|
||||
hls_cache_object_limit_from_io, CacheInvalidationOutcome, HlsSegmentCache, MapCacheKey, SegmentCacheKey,
|
||||
TransientObjectCacheKey,
|
||||
};
|
||||
use crate::api::model::{build_transient_resource_id, HlsOriginResourceFetchError, ProxySessionId};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
io,
|
||||
sync::Arc,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
fn cache_key() -> SegmentCacheKey { SegmentCacheKey::new(ProxySessionId("proxy_session".to_string()), 123, "ts") }
|
||||
@@ -817,19 +846,35 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_temp_and_commit_with_timeout_cleans_active_temp_file() {
|
||||
async fn write_temp_and_commit_with_deadline_cleans_active_temp_file() {
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir");
|
||||
let cache = HlsSegmentCache::with_cache_path(temp_dir.path());
|
||||
let key = cache_key();
|
||||
let (_writer, reader) = tokio::io::duplex(64);
|
||||
|
||||
let result = cache.write_temp_and_commit_with_timeout(&key, reader, Duration::from_millis(1)).await;
|
||||
let result = cache
|
||||
.write_temp_and_commit_with_deadline(&key, reader, tokio::time::Instant::now() + Duration::from_millis(1))
|
||||
.await;
|
||||
|
||||
assert_eq!(result.expect_err("commit should time out").kind(), io::ErrorKind::TimedOut);
|
||||
assert!(!cache.has_active_temp_files().await);
|
||||
assert_eq!(cache.metadata(&key).await.expect("metadata should read"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exhausted_write_deadline_rejects_even_an_immediately_available_body() {
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir");
|
||||
let cache = HlsSegmentCache::with_cache_path(temp_dir.path());
|
||||
let key = cache_key();
|
||||
|
||||
let result = cache.write_temp_and_commit_with_deadline(&key, &b"ready"[..], tokio::time::Instant::now()).await;
|
||||
|
||||
assert_eq!(result.expect_err("expired deadline must time out").kind(), io::ErrorKind::TimedOut);
|
||||
assert!(!cache.has_active_temp_files().await);
|
||||
assert_eq!(cache.metadata(&key).await.expect("metadata should read"), None);
|
||||
assert_eq!(std::fs::read_dir(temp_dir.path()).expect("cache root reads").count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_object_write_rejects_bytes_above_the_configured_limit() {
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir");
|
||||
@@ -839,8 +884,15 @@ mod tests {
|
||||
|
||||
let result = cache.write_bytes_and_commit(&key, b"four").await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let error = result.expect_err("decoded object above the configured limit must fail");
|
||||
let limit_error = hls_cache_object_limit_from_io(&error).expect("typed object-limit source");
|
||||
assert_eq!(limit_error.limit(), 3);
|
||||
assert!(matches!(
|
||||
HlsOriginResourceFetchError::cache_body(&error),
|
||||
HlsOriginResourceFetchError::CacheObjectLimit { limit: 3 }
|
||||
));
|
||||
assert!(cache.metadata(&key).await.expect("metadata").is_none());
|
||||
assert!(!cache.has_active_temp_files().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -854,10 +906,7 @@ mod tests {
|
||||
tokio::fs::create_dir_all(temp_dir.path().join("orphan")).await.expect("orphan dir");
|
||||
let cutoff = SystemTime::now();
|
||||
|
||||
let removed = cache
|
||||
.delete_orphan_session_dirs(&HashSet::from([active]), cutoff)
|
||||
.await
|
||||
.expect("cleanup");
|
||||
let removed = cache.delete_orphan_session_dirs(&HashSet::from([active]), cutoff).await.expect("cleanup");
|
||||
|
||||
assert_eq!(removed, 1);
|
||||
assert!(temp_dir.path().join("active").exists());
|
||||
@@ -880,10 +929,7 @@ mod tests {
|
||||
filetime::set_file_mtime(temp_dir.path().join(fresh.0.clone()), filetime::FileTime::from_system_time(future))
|
||||
.expect("set fresh mtime");
|
||||
|
||||
let removed = cache
|
||||
.delete_orphan_session_dirs(&HashSet::new(), cutoff)
|
||||
.await
|
||||
.expect("cleanup");
|
||||
let removed = cache.delete_orphan_session_dirs(&HashSet::new(), cutoff).await.expect("cleanup");
|
||||
|
||||
assert_eq!(removed, 1, "stale orphan dir should be removed");
|
||||
assert!(!temp_dir.path().join(stale.0).exists());
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use super::{
|
||||
renderer_candidate_window_proxy_seqs, safe_proxy_session_id, CacheInvalidationOutcome, HlsCacheMetrics,
|
||||
HlsSegmentCache, HlsSession, HlsExpiredSessionReason, HlsSessionHandle, HlsSessionStore, MapCacheKey,
|
||||
MapCacheStatus, ProxyMapId,
|
||||
ProxySessionId, SegmentCacheKey, SegmentCacheStatus, TransientObjectCacheKey,
|
||||
HlsExpiredSessionReason, HlsSegmentCache, HlsSession, HlsSessionHandle, HlsSessionStore, MapCacheKey,
|
||||
MapCacheStatus, ProxyMapId, ProxySessionId, SegmentCacheKey, SegmentCacheStatus, TransientObjectCacheKey,
|
||||
};
|
||||
use crate::{api::model::AppState, model::HlsCacheConfig};
|
||||
use arc_swap::ArcSwap;
|
||||
@@ -203,10 +202,8 @@ impl HlsGarbageCollector {
|
||||
for session in &sessions {
|
||||
active_session_ids.insert(session.read().await.proxy_session_id.clone());
|
||||
}
|
||||
report.orphan_session_dirs_deleted = self
|
||||
.cache
|
||||
.delete_orphan_session_dirs(&active_session_ids, gc_start)
|
||||
.await?;
|
||||
report.orphan_session_dirs_deleted =
|
||||
self.cache.delete_orphan_session_dirs(&active_session_ids, gc_start).await?;
|
||||
let mut pending_deletions = Vec::new();
|
||||
for session in &sessions {
|
||||
let mut session = session.write().await;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use crate::api::model::proxy::header_policy::{HeaderProtocol, HopByHopHeader};
|
||||
use crate::model::ReverseProxyDisabledHeaderConfig;
|
||||
use crate::{
|
||||
api::model::proxy::header_policy::{HeaderProtocol, HopByHopHeader},
|
||||
model::ReverseProxyDisabledHeaderConfig,
|
||||
utils::content_coding::force_accept_encoding_identity,
|
||||
};
|
||||
use axum::http::{header, HeaderMap, HeaderName, HeaderValue};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -25,10 +28,8 @@ pub fn scrub_hls_origin_headers(headers: &mut HeaderMap, disabled_headers: Optio
|
||||
.flat_map(|value| value.split(','))
|
||||
.filter_map(|name| HeaderName::from_bytes(name.trim().as_bytes()).ok())
|
||||
.collect::<Vec<_>>();
|
||||
names.extend(headers
|
||||
.keys()
|
||||
.filter(|name| should_remove_hls_origin_header(name.as_str(), disabled_headers))
|
||||
.cloned()
|
||||
names.extend(
|
||||
headers.keys().filter(|name| should_remove_hls_origin_header(name.as_str(), disabled_headers)).cloned(),
|
||||
);
|
||||
for name in names {
|
||||
headers.remove(name);
|
||||
@@ -55,9 +56,8 @@ pub fn extract_hls_provider_session_header_map(headers: &HeaderMap) -> HeaderMap
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut session_headers = HeaderMap::new();
|
||||
if let Some(cookie_header) = (!cookies.is_empty())
|
||||
.then(|| cookies.join("; "))
|
||||
.and_then(|value| HeaderValue::from_str(&value).ok())
|
||||
if let Some(cookie_header) =
|
||||
(!cookies.is_empty()).then(|| cookies.join("; ")).and_then(|value| HeaderValue::from_str(&value).ok())
|
||||
{
|
||||
session_headers.insert(header::COOKIE, cookie_header);
|
||||
}
|
||||
@@ -91,7 +91,7 @@ pub fn hls_origin_headers_with_provider_session(
|
||||
|
||||
pub fn force_identity_without_range(headers: &mut HeaderMap) {
|
||||
headers.remove(header::RANGE);
|
||||
headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("identity"));
|
||||
force_accept_encoding_identity(headers);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -5,13 +5,23 @@ const PROXY_SESSION_ID_LEN: usize = 22;
|
||||
const PROXY_SESSION_ID_KEY_CONTEXT: &str = "tuliprox:hls-cache:proxy-session-id-key:v1";
|
||||
|
||||
/// Stable Tuliprox content identity for a live HLS source.
|
||||
///
|
||||
/// `stream_ref` is the immutable input-stream ID captured before target mapping.
|
||||
/// Together with `input_id`, it identifies the origin content across targets;
|
||||
/// target IDs and virtual IDs must never be used as `stream_ref`.
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
|
||||
pub struct HlsSessionKey {
|
||||
/// Internal ID of the configured Tuliprox input.
|
||||
pub input_id: u16,
|
||||
/// Exact, non-empty origin/provider stream ID represented as a string.
|
||||
pub stream_ref: String,
|
||||
}
|
||||
|
||||
impl HlsSessionKey {
|
||||
/// Creates a key from the configured input ID and its immutable input-stream ID.
|
||||
///
|
||||
/// Callers must pass the origin/provider ID captured before target mapping, not
|
||||
/// a target-specific or virtual ID.
|
||||
pub fn new(input_id: u16, stream_ref: impl Into<String>) -> Self {
|
||||
Self { input_id, stream_ref: stream_ref.into() }
|
||||
}
|
||||
@@ -56,6 +66,14 @@ mod tests {
|
||||
assert_ne!(first, different_stream);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_hls_session_key_preserves_alphanumeric_input_stream_id() {
|
||||
let key = HlsSessionKey::new(7, "m3u-channel_A42");
|
||||
|
||||
assert_eq!(key.stream_ref, "m3u-channel_A42");
|
||||
assert_eq!(key.stable_value(), "input:7|hls|m3u-channel_A42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_hls_session_key_does_not_contain_origin_or_provider_url_parts() {
|
||||
let key = HlsSessionKey::new(7, "80510");
|
||||
|
||||
@@ -98,11 +98,8 @@ impl HlsAccessLeasePendingDeadline {
|
||||
}
|
||||
|
||||
const fn tightened_with(self, candidate: Self) -> Self {
|
||||
let deadline_ms = if self.deadline_ms() <= candidate.deadline_ms() {
|
||||
self.deadline_ms()
|
||||
} else {
|
||||
candidate.deadline_ms()
|
||||
};
|
||||
let deadline_ms =
|
||||
if self.deadline_ms() <= candidate.deadline_ms() { self.deadline_ms() } else { candidate.deadline_ms() };
|
||||
match (self, candidate) {
|
||||
(Self::FollowUp { .. }, _) | (_, Self::FollowUp { .. }) => Self::FollowUp { deadline_ms },
|
||||
(Self::Bootstrap { .. }, Self::Bootstrap { .. }) => Self::Bootstrap { deadline_ms },
|
||||
@@ -302,10 +299,7 @@ impl HlsAccessLeaseStore {
|
||||
.filter(|lease| lease.proxy_session_id == *proxy_session_id)
|
||||
.map(|lease| lease.lease_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
lease_ids
|
||||
.into_iter()
|
||||
.filter_map(|lease_id| self.by_lease_id.remove(&lease_id))
|
||||
.collect()
|
||||
lease_ids.into_iter().filter_map(|lease_id| self.by_lease_id.remove(&lease_id)).collect()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) -> usize {
|
||||
@@ -325,7 +319,12 @@ impl HlsAccessLeaseStore {
|
||||
.map(|lease| lease.username.clone())
|
||||
}
|
||||
|
||||
pub fn response_snapshot(&mut self, lease_id: &HlsAccessLeaseId, path_proxy_session_id: &ProxySessionId, now_ms: u64) -> Option<HlsAccessLease> {
|
||||
pub fn response_snapshot(
|
||||
&mut self,
|
||||
lease_id: &HlsAccessLeaseId,
|
||||
path_proxy_session_id: &ProxySessionId,
|
||||
now_ms: u64,
|
||||
) -> Option<HlsAccessLease> {
|
||||
let lease = self.by_lease_id.get_mut(lease_id)?;
|
||||
if &lease.proxy_session_id != path_proxy_session_id {
|
||||
return None;
|
||||
@@ -347,10 +346,8 @@ impl HlsAccessLeaseStore {
|
||||
}
|
||||
lease.refresh_validity(now_ms);
|
||||
if lease_state_allows_use(lease.state) {
|
||||
lease.response_flag = Some(HlsAccessLeaseResponseFlag::ChannelUnavailable {
|
||||
reason,
|
||||
set_at_ms: now_ms,
|
||||
});
|
||||
lease.response_flag =
|
||||
Some(HlsAccessLeaseResponseFlag::ChannelUnavailable { reason, set_at_ms: now_ms });
|
||||
marked += 1;
|
||||
}
|
||||
}
|
||||
@@ -819,9 +816,7 @@ mod tests {
|
||||
let marked = store.mark_channel_unavailable_for_session(
|
||||
&proxy_session_id,
|
||||
17_000,
|
||||
HlsAccessLeaseChannelUnavailableReason::SegmentPermanentFailure {
|
||||
status: Some(StatusCode::NOT_FOUND),
|
||||
},
|
||||
HlsAccessLeaseChannelUnavailableReason::SegmentPermanentFailure { status: Some(StatusCode::NOT_FOUND) },
|
||||
);
|
||||
|
||||
assert_eq!(marked, 1);
|
||||
@@ -865,11 +860,7 @@ mod tests {
|
||||
set_at_ms: 2_000
|
||||
})
|
||||
));
|
||||
assert!(store
|
||||
.response_snapshot(&other_lease_id, &proxy_session_id, 2_000)
|
||||
.unwrap()
|
||||
.response_flag
|
||||
.is_none());
|
||||
assert!(store.response_snapshot(&other_lease_id, &proxy_session_id, 2_000).unwrap().response_flag.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1147,9 +1138,7 @@ mod tests {
|
||||
store.prepare_access_lease(lease(activated_id.clone(), &proxy_session_id.0, 1_000));
|
||||
store.prepare_access_lease(lease(denied_id.clone(), &proxy_session_id.0, 1_000));
|
||||
store.prepare_access_lease(lease(expired_id.clone(), &proxy_session_id.0, 1_000));
|
||||
assert!(store
|
||||
.activate_access_lease(&idle_id, &proxy_session_id, 2_000, timing(1_000, 15_000))
|
||||
.is_activated());
|
||||
assert!(store.activate_access_lease(&idle_id, &proxy_session_id, 2_000, timing(1_000, 15_000)).is_activated());
|
||||
assert!(store
|
||||
.activate_access_lease(&activated_id, &proxy_session_id, 2_000, timing(5_000, 15_000))
|
||||
.is_activated());
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
use super::{
|
||||
build_rewrite_secret_fingerprint, safe_proxy_session_id, safe_session_key, GarbageCollectionPolicy,
|
||||
HlsAccessLease, HlsAccessLeaseActivation, HlsAccessLeaseChannelUnavailableReason, HlsAccessLeaseId,
|
||||
HlsAccessLeaseLifecycleSnapshot, HlsAccessLeasePendingDeadline, HlsAccessLeaseSessionSnapshot,
|
||||
HlsAccessLeaseState, HlsAccessLeaseStore, HlsAccessLeaseTiming, HlsAccessLeaseTouch, HlsCacheMetrics,
|
||||
HlsExpiredSessionMarker, HlsExpiredSessionReason, HlsGarbageCollector, HlsLifecycleEvent, HlsLifecycleEventKey,
|
||||
HlsLifecycleManager, HlsMapWorkerPool,
|
||||
HlsOriginSource, HlsQosRegistry, HlsSegmentCache, HlsSegmentRepairManager, HlsSegmentWorkerPool, HlsSessionHandle,
|
||||
HlsSessionKey, HlsSessionStore, HlsSessionStoreOutcome, ProxySessionId, SegmentFetchPolicy,
|
||||
build_rewrite_secret_fingerprint, safe_proxy_session_id, safe_session_key, GarbageCollectionPolicy, HlsAccessLease,
|
||||
HlsAccessLeaseActivation, HlsAccessLeaseChannelUnavailableReason, HlsAccessLeaseId,
|
||||
HlsAccessLeaseLifecycleSnapshot, HlsAccessLeasePendingDeadline, HlsAccessLeaseSessionSnapshot, HlsAccessLeaseState,
|
||||
HlsAccessLeaseStore, HlsAccessLeaseTiming, HlsAccessLeaseTouch, HlsCacheMetrics, HlsExpiredSessionMarker,
|
||||
HlsExpiredSessionReason, HlsGarbageCollector, HlsLifecycleEvent, HlsLifecycleEventKey, HlsLifecycleManager,
|
||||
HlsMapWorkerPool, HlsOriginSource, HlsQosRegistry, HlsSegmentCache, HlsSegmentRepairManager, HlsSegmentWorkerPool,
|
||||
HlsSessionHandle, HlsSessionKey, HlsSessionStore, HlsSessionStoreOutcome, ProxySessionId, SegmentFetchPolicy,
|
||||
TransientResourceStore,
|
||||
};
|
||||
use crate::{
|
||||
api::model::{ActiveProviderManager, ActiveUserManager, AppState},
|
||||
model::{AppConfig, HlsCacheConfig, StripConfig},
|
||||
model::{AppConfig, HlsCacheConfig, HlsManifestRecoveryBurstConfig, StripConfig},
|
||||
};
|
||||
use crate::model::HlsManifestRecoveryBurstConfig;
|
||||
use arc_swap::ArcSwap;
|
||||
use log::{debug, error, info};
|
||||
use shared::utils::sanitize_sensitive_info;
|
||||
@@ -82,10 +80,7 @@ fn hls_pending_manifest_follow_up_window_ms(target_duration: Option<u32>) -> u64
|
||||
target_duration_secs.saturating_mul(2_000).max(10_000)
|
||||
}
|
||||
|
||||
fn hls_pending_manifest_follow_up_deadline(
|
||||
now_ms: u64,
|
||||
target_duration: Option<u32>,
|
||||
) -> HlsAccessLeasePendingDeadline {
|
||||
fn hls_pending_manifest_follow_up_deadline(now_ms: u64, target_duration: Option<u32>) -> HlsAccessLeasePendingDeadline {
|
||||
HlsAccessLeasePendingDeadline::FollowUp {
|
||||
deadline_ms: now_ms.saturating_add(hls_pending_manifest_follow_up_window_ms(target_duration)),
|
||||
}
|
||||
@@ -219,11 +214,7 @@ impl HlsProxyManager {
|
||||
Self::with_hls_cache_config_and_secret_enabled(config, rewrite_secret, true)
|
||||
}
|
||||
|
||||
fn with_hls_cache_config_and_secret_enabled(
|
||||
config: &HlsCacheConfig,
|
||||
rewrite_secret: &[u8],
|
||||
enabled: bool,
|
||||
) -> Self {
|
||||
fn with_hls_cache_config_and_secret_enabled(config: &HlsCacheConfig, rewrite_secret: &[u8], enabled: bool) -> Self {
|
||||
let segment_fetch_policy = SegmentFetchPolicy::from_config(config);
|
||||
let global_fetch_semaphore = Arc::new(Semaphore::new(segment_fetch_policy.max_global_segment_fetches));
|
||||
let sessions = Arc::new(HlsSessionStore::new());
|
||||
@@ -320,10 +311,8 @@ impl HlsProxyManager {
|
||||
account_name: &Arc<str>,
|
||||
now_ms: u64,
|
||||
) -> bool {
|
||||
let key = HlsAccountOverlapCooldownKey {
|
||||
input_name: Arc::clone(input_name),
|
||||
account_name: Arc::clone(account_name),
|
||||
};
|
||||
let key =
|
||||
HlsAccountOverlapCooldownKey { input_name: Arc::clone(input_name), account_name: Arc::clone(account_name) };
|
||||
let mut cooldowns = self.account_overlap_cooldowns.write().await;
|
||||
let Some(cooldown) = cooldowns.get(&key).copied() else {
|
||||
return false;
|
||||
@@ -382,10 +371,7 @@ impl HlsProxyManager {
|
||||
return;
|
||||
}
|
||||
let key = HlsAccountOverlapCooldownKey { input_name, account_name };
|
||||
self.account_overlap_cooldowns
|
||||
.write()
|
||||
.await
|
||||
.insert(key.clone(), HlsAccountOverlapCooldown { until_ms });
|
||||
self.account_overlap_cooldowns.write().await.insert(key.clone(), HlsAccountOverlapCooldown { until_ms });
|
||||
debug!(
|
||||
"HLS account overlap cooldown set for input {} account {} until {} ms after {}",
|
||||
sanitize_sensitive_info(key.input_name.as_ref()),
|
||||
@@ -402,11 +388,8 @@ impl HlsProxyManager {
|
||||
.reverse_proxy
|
||||
.as_ref()
|
||||
.map_or(app_config.encrypt_secret, |reverse_proxy| reverse_proxy.rewrite_secret);
|
||||
let hls_config = config
|
||||
.reverse_proxy
|
||||
.as_ref()
|
||||
.and_then(|reverse_proxy| reverse_proxy.hls_cache.as_ref())
|
||||
.cloned();
|
||||
let hls_config =
|
||||
config.reverse_proxy.as_ref().and_then(|reverse_proxy| reverse_proxy.hls_cache.as_ref()).cloned();
|
||||
let enabled = hls_config.is_some();
|
||||
let hls_config =
|
||||
hls_config.unwrap_or_else(|| HlsCacheConfig::from(&shared::model::HlsCacheConfigDto::default()));
|
||||
@@ -483,10 +466,7 @@ impl HlsProxyManager {
|
||||
now_ms: u64,
|
||||
reason: HlsAccessLeaseChannelUnavailableReason,
|
||||
) -> usize {
|
||||
self.access_leases
|
||||
.write()
|
||||
.await
|
||||
.mark_channel_unavailable_for_session(proxy_session_id, now_ms, reason)
|
||||
self.access_leases.write().await.mark_channel_unavailable_for_session(proxy_session_id, now_ms, reason)
|
||||
}
|
||||
|
||||
pub async fn mark_access_lease_channel_unavailable(
|
||||
@@ -560,11 +540,12 @@ impl HlsProxyManager {
|
||||
target_duration: Option<u32>,
|
||||
) -> bool {
|
||||
let deadline = hls_pending_manifest_follow_up_deadline(now_ms, target_duration);
|
||||
let lease = self
|
||||
.access_leases
|
||||
.write()
|
||||
.await
|
||||
.mark_pending_manifest_follow_up_for_lease(lease_id, proxy_session_id, now_ms, deadline);
|
||||
let lease = self.access_leases.write().await.mark_pending_manifest_follow_up_for_lease(
|
||||
lease_id,
|
||||
proxy_session_id,
|
||||
now_ms,
|
||||
deadline,
|
||||
);
|
||||
if let Some(lease) = lease {
|
||||
self.schedule_access_lease_validity(&lease).await;
|
||||
debug!(
|
||||
@@ -585,11 +566,11 @@ impl HlsProxyManager {
|
||||
target_duration: Option<u32>,
|
||||
) -> usize {
|
||||
let deadline = hls_pending_manifest_follow_up_deadline(now_ms, target_duration);
|
||||
let leases = self
|
||||
.access_leases
|
||||
.write()
|
||||
.await
|
||||
.mark_pending_manifest_follow_up_for_session(proxy_session_id, now_ms, deadline);
|
||||
let leases = self.access_leases.write().await.mark_pending_manifest_follow_up_for_session(
|
||||
proxy_session_id,
|
||||
now_ms,
|
||||
deadline,
|
||||
);
|
||||
for lease in &leases {
|
||||
self.schedule_access_lease_validity(lease).await;
|
||||
}
|
||||
@@ -690,11 +671,7 @@ impl HlsProxyManager {
|
||||
now_ms: u64,
|
||||
) -> Option<HlsExpiredSessionMarker> {
|
||||
self.sessions
|
||||
.expired_session_marker(
|
||||
proxy_session_id,
|
||||
now_ms,
|
||||
self.session_idle_timeout_ms().saturating_mul(2).max(1),
|
||||
)
|
||||
.expired_session_marker(proxy_session_id, now_ms, self.session_idle_timeout_ms().saturating_mul(2).max(1))
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -789,9 +766,7 @@ impl HlsProxyManager {
|
||||
}
|
||||
if snapshot.state != HlsAccessLeaseState::Expired && snapshot.state != HlsAccessLeaseState::Denied {
|
||||
let due_at_ms = if snapshot.state == HlsAccessLeaseState::Pending {
|
||||
snapshot
|
||||
.pending_deadline
|
||||
.map_or(snapshot.valid_until_ms, HlsAccessLeasePendingDeadline::deadline_ms)
|
||||
snapshot.pending_deadline.map_or(snapshot.valid_until_ms, HlsAccessLeasePendingDeadline::deadline_ms)
|
||||
} else {
|
||||
snapshot.valid_until_ms
|
||||
};
|
||||
@@ -1129,7 +1104,10 @@ mod tests {
|
||||
utils::FileLockManager,
|
||||
};
|
||||
use arc_swap::{ArcSwap, ArcSwapOption};
|
||||
use shared::model::{ConfigPaths, HlsCacheConfigDto, HlsManifestRecoveryBurstLevel, HlsStripConfigDto, HlsStripMode, ReverseProxyConfigDto};
|
||||
use shared::model::{
|
||||
ConfigPaths, HlsCacheConfigDto, HlsManifestRecoveryBurstLevel, HlsStripConfigDto, HlsStripMode,
|
||||
ReverseProxyConfigDto,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn empty_paths() -> ConfigPaths {
|
||||
@@ -1207,10 +1185,7 @@ mod tests {
|
||||
assert_eq!(manager.cache_duration_seconds(), 99);
|
||||
assert_eq!(manager.transient_resource_ttl_ms(), 99_000);
|
||||
assert_eq!(manager.session_idle_timeout_ms(), 55_000);
|
||||
assert_eq!(
|
||||
manager.manifest_recovery_burst().level,
|
||||
HlsManifestRecoveryBurstLevel::Balanced
|
||||
);
|
||||
assert_eq!(manager.manifest_recovery_burst().level, HlsManifestRecoveryBurstLevel::Balanced);
|
||||
assert_eq!(manager.strip().mode, HlsStripMode::Seconds);
|
||||
assert_eq!(manager.strip().value, 7);
|
||||
assert_eq!(session.read().await.segment_prefetch_queue.max_prefetch_depth(), 4);
|
||||
@@ -1222,10 +1197,7 @@ mod tests {
|
||||
|
||||
assert!(!manager.is_enabled());
|
||||
assert_eq!(
|
||||
manager
|
||||
.run_garbage_collection_once(1_000)
|
||||
.await
|
||||
.expect("disabled gc should no-op"),
|
||||
manager.run_garbage_collection_once(1_000).await.expect("disabled gc should no-op"),
|
||||
super::super::GarbageCollectionReport::default()
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
HlsAccountBindingProtection, HlsFreshManifestRequiredReason, HlsManifestCommitRequirement,
|
||||
HlsOriginAccountBinding, HlsSession, HlsSessionHandle, HlsSessionMode, HlsSessionStoreOutcome,
|
||||
HlsAccountBindingProtection, HlsFreshManifestRequiredReason, HlsManifestCommitRequirement, HlsOriginAccountBinding,
|
||||
HlsSession, HlsSessionHandle, HlsSessionMode, HlsSessionStoreOutcome,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -78,7 +78,7 @@ pub fn hls_committed_manifest_body_for_request(
|
||||
candidate.rendered_at_ms >= started_at_ms,
|
||||
now_ms,
|
||||
) && manifest_rendered_after_required_boundary(Some(candidate.rendered_at_ms), options))
|
||||
.then_some(candidate.body)
|
||||
.then_some(candidate.body)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -109,9 +109,7 @@ pub async fn hls_manifest_commit_requirement(
|
||||
};
|
||||
}
|
||||
if matches!(session_outcome, HlsSessionStoreOutcome::Created) {
|
||||
return HlsManifestCommitRequirement::FreshCommitRequired {
|
||||
reason: HlsFreshManifestRequiredReason::ColdStart,
|
||||
};
|
||||
return HlsManifestCommitRequirement::FreshCommitRequired { reason: HlsFreshManifestRequiredReason::ColdStart };
|
||||
}
|
||||
|
||||
let session = session.read().await;
|
||||
@@ -181,10 +179,12 @@ fn can_serve_committed_manifest(
|
||||
matches!(policy, HlsCachedManifestPolicy::AllowInitialNoMediaYet)
|
||||
&& committed_manifest_valid_at(candidate, now_ms)
|
||||
}
|
||||
HlsAccountBindingProtection::Expired => matches!(policy, HlsCachedManifestPolicy::AllowInitialNoMediaYet)
|
||||
&& (refreshed_after_wait_started
|
||||
|| (session.origin_account_binding.as_ref().is_some_and(HlsOriginAccountBinding::is_active)
|
||||
&& committed_manifest_valid_at(candidate, now_ms))),
|
||||
HlsAccountBindingProtection::Expired => {
|
||||
matches!(policy, HlsCachedManifestPolicy::AllowInitialNoMediaYet)
|
||||
&& (refreshed_after_wait_started
|
||||
|| (session.origin_account_binding.as_ref().is_some_and(HlsOriginAccountBinding::is_active)
|
||||
&& committed_manifest_valid_at(candidate, now_ms)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::{
|
||||
extract_hls_provider_session_header_map, safe_origin_log_value, safe_session_key, HlsAccountBindingProtection,
|
||||
HlsBoundAccountAcquireErrorKind, HlsSessionHandle, HlsSessionMode, TimelineMapError,
|
||||
extract_hls_provider_session_header_map, log_hls_origin_content_coding, safe_origin_log_value, safe_session_key,
|
||||
HlsAccountBindingProtection, HlsBoundAccountAcquireErrorKind, HlsOriginContentCodingObjectKind,
|
||||
HlsOriginContentCodingSource, HlsSessionHandle, HlsSessionMode, TimelineMapError,
|
||||
};
|
||||
use crate::{
|
||||
model::{
|
||||
@@ -11,20 +12,26 @@ use crate::{
|
||||
parse_manifest_timing, parse_origin_manifest_timeline, parse_origin_media_manifest, OriginManifestParseOutcome,
|
||||
ParsedOriginManifestTimeline,
|
||||
},
|
||||
utils::request::{
|
||||
send_input_with_retry_and_provider_policy_with_manual_redirects_and_options_result,
|
||||
send_input_with_retry_and_provider_policy_with_options_result, RequestFetchOptions,
|
||||
utils::{
|
||||
content_coding::{
|
||||
apply_outbound_content_coding_policy, content_decoding_error_from_io, decode_response_to_identity,
|
||||
is_http_body_transport_error, read_utf8_limited, ContentBodyReadError, ContentCoding,
|
||||
ContentCodingDetection, ContentCodingError, OutboundContentCodingPolicy,
|
||||
},
|
||||
request::{
|
||||
send_input_with_retry_and_provider_policy_with_manual_redirects_and_options_result,
|
||||
send_input_with_retry_and_provider_policy_with_options_result, RequestFetchOptions,
|
||||
},
|
||||
},
|
||||
};
|
||||
use axum::http::{header, HeaderMap, StatusCode};
|
||||
use futures::StreamExt;
|
||||
use log::{debug, warn};
|
||||
use reqwest::Client;
|
||||
use shared::{
|
||||
model::{HlsManifestRecoveryBurstLevel, HlsManifestRecoveryBurstPlan, InputFetchMethod, HlsStripMode},
|
||||
model::{HlsManifestRecoveryBurstLevel, HlsManifestRecoveryBurstPlan, HlsStripMode, InputFetchMethod},
|
||||
utils::sanitize_sensitive_info,
|
||||
};
|
||||
use std::{collections::HashMap, fmt, future::Future, sync::Arc, time::Duration};
|
||||
use std::{collections::HashMap, fmt, future::Future, io, sync::Arc, time::Duration};
|
||||
use tokio::{task::JoinSet, time::timeout};
|
||||
use url::Url;
|
||||
|
||||
@@ -33,7 +40,7 @@ const DEFAULT_HLS_TARGET_DURATION_SECS: u32 = 15;
|
||||
const HLS_MANIFEST_HOST_SWITCH_BASE_WINDOW_SEGMENTS: u32 = 3;
|
||||
const HLS_MANIFEST_HOST_SWITCH_MAX_FAILURE_THRESHOLD: u32 = 5;
|
||||
const DEFAULT_HLS_SESSION_IDLE_TIMEOUT_SECS: u64 = 300;
|
||||
const MAX_HLS_MANIFEST_BYTES: usize = 2 * 1024 * 1024;
|
||||
pub(crate) const MAX_HLS_MANIFEST_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
/// Origin manifest entrypoint snapshot for live HLS refreshes.
|
||||
#[derive(Clone)]
|
||||
@@ -137,16 +144,64 @@ pub fn classify_origin_manifest_status(status: StatusCode) -> OriginManifestStat
|
||||
OriginManifestStatusClass::NonRetryableFailure
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum OriginManifestFetchError {
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum OriginManifestFetchError {
|
||||
#[error("origin manifest returned permanent status {0}")]
|
||||
PermanentStatus(StatusCode),
|
||||
#[error("origin manifest returned retryable status {0}, retry_after_ms={1:?}")]
|
||||
RetryableStatus(StatusCode, Option<u64>),
|
||||
#[error("origin manifest retry attempts exhausted")]
|
||||
RetryExhausted,
|
||||
#[error("origin manifest returned non-retryable status {0}")]
|
||||
NonRetryableStatus(StatusCode),
|
||||
#[error("origin manifest request failed: {0}")]
|
||||
Request(String),
|
||||
#[error("origin manifest redirect failed: {0}")]
|
||||
Redirect(String),
|
||||
#[error("origin manifest request timed out")]
|
||||
Timeout,
|
||||
#[error("origin provider unavailable: {0:?}")]
|
||||
ProviderUnavailable(HlsBoundAccountAcquireErrorKind),
|
||||
#[error("origin manifest content coding failed: {0}")]
|
||||
ContentCoding(ContentCodingError),
|
||||
#[error("origin manifest decoding failed: coding={coding:?}")]
|
||||
ContentDecoding { coding: ContentCoding },
|
||||
#[error("decoded origin manifest exceeds configured limit {limit}")]
|
||||
DecodedBodyLimitExceeded { limit: usize },
|
||||
#[error("decoded origin manifest is not valid UTF-8: valid_up_to={valid_up_to} error_len={error_len:?}")]
|
||||
InvalidUtf8 { valid_up_to: usize, error_len: Option<usize> },
|
||||
}
|
||||
|
||||
impl OriginManifestFetchError {
|
||||
/// Returns a fixed/numeric diagnostic label without origin-controlled strings.
|
||||
pub(crate) fn log_label(&self) -> String {
|
||||
match self {
|
||||
Self::PermanentStatus(status) => format!("permanent_status status={}", status.as_u16()),
|
||||
Self::RetryableStatus(status, _) => format!("retryable_status status={}", status.as_u16()),
|
||||
Self::RetryExhausted => "retry_exhausted".to_string(),
|
||||
Self::NonRetryableStatus(status) => format!("non_retryable_status status={}", status.as_u16()),
|
||||
Self::Request(_) => "request".to_string(),
|
||||
Self::Redirect(_) => "redirect".to_string(),
|
||||
Self::Timeout => "timeout".to_string(),
|
||||
Self::ProviderUnavailable(kind) => format!("provider_unavailable kind={}", kind.as_log_label()),
|
||||
Self::ContentCoding(error) => match error {
|
||||
ContentCodingError::InvalidHeader => "content_coding class=invalid_header".to_string(),
|
||||
ContentCodingError::Unsupported(_) => "content_coding class=unsupported".to_string(),
|
||||
ContentCodingError::EncodedPartialContent => "content_coding class=encoded_partial_content".to_string(),
|
||||
ContentCodingError::PrefixRead(error) => content_decoding_error_from_io(error).map_or_else(
|
||||
|| "content_coding class=prefix_read".to_string(),
|
||||
|error| format!("content_decoding coding={}", error.coding.as_http_token()),
|
||||
),
|
||||
},
|
||||
Self::ContentDecoding { coding, .. } => {
|
||||
format!("content_decoding coding={}", coding.as_http_token())
|
||||
}
|
||||
Self::DecodedBodyLimitExceeded { limit } => format!("decoded_body_limit limit={limit}"),
|
||||
Self::InvalidUtf8 { valid_up_to, error_len } => {
|
||||
format!("invalid_utf8 valid_up_to={valid_up_to} error_len={error_len:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -482,40 +537,85 @@ async fn fetch_hls_origin_manifest_initial_global_policy(
|
||||
);
|
||||
let fetch_options = RequestFetchOptions::with_attempt_idle_timeout(Duration::from_millis(
|
||||
context.origin_manifest_timeout_ms.max(1),
|
||||
));
|
||||
let response_result = if context.use_manual_redirects {
|
||||
send_input_with_retry_and_provider_policy_with_manual_redirects_and_options_result(
|
||||
&context.app_config,
|
||||
&context.no_redirect_client,
|
||||
&input_source,
|
||||
Some(&context.headers),
|
||||
context.origin_entry.url(),
|
||||
MAX_MANUAL_REDIRECTS,
|
||||
fetch_options,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
send_input_with_retry_and_provider_policy_with_options_result(
|
||||
&context.app_config,
|
||||
&context.client,
|
||||
&input_source,
|
||||
Some(&context.headers),
|
||||
context.origin_entry.url(),
|
||||
fetch_options,
|
||||
)
|
||||
.await
|
||||
};
|
||||
let response_result = response_result.map_err(|err| origin_manifest_fetch_error_from_request_error(&err))?;
|
||||
let provider_url_index = response_result.provider_url_index;
|
||||
let resolved_request_url =
|
||||
resolved_hls_manifest_request_url_from_input(&input_source, provider_url_index, context.origin_entry.url());
|
||||
response_to_fetched_manifest(
|
||||
response_result.response,
|
||||
provider_url_index,
|
||||
resolved_request_url,
|
||||
context.origin_manifest_timeout_ms,
|
||||
)
|
||||
.await
|
||||
))
|
||||
.with_content_coding(OutboundContentCodingPolicy::Identity)
|
||||
.without_resource_retries();
|
||||
let attempts = context.retry_policy.attempt_count();
|
||||
|
||||
// This HLS loop owns the logical manifest-attempt budget. Each iteration may traverse one bounded provider URL
|
||||
// failover cycle and redirect chain, but the generic resource-retry counter is not nested beneath it.
|
||||
for attempt_index in 0..attempts {
|
||||
let response_result = if context.use_manual_redirects {
|
||||
send_input_with_retry_and_provider_policy_with_manual_redirects_and_options_result(
|
||||
&context.app_config,
|
||||
&context.no_redirect_client,
|
||||
&input_source,
|
||||
Some(&context.headers),
|
||||
context.origin_entry.url(),
|
||||
MAX_MANUAL_REDIRECTS,
|
||||
fetch_options,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
send_input_with_retry_and_provider_policy_with_options_result(
|
||||
&context.app_config,
|
||||
&context.client,
|
||||
&input_source,
|
||||
Some(&context.headers),
|
||||
context.origin_entry.url(),
|
||||
fetch_options,
|
||||
)
|
||||
.await
|
||||
};
|
||||
let fetch_result = match response_result {
|
||||
Ok(response_result) => {
|
||||
let provider_url_index = response_result.provider_url_index;
|
||||
let resolved_request_url = resolved_hls_manifest_request_url_from_input(
|
||||
&input_source,
|
||||
provider_url_index,
|
||||
context.origin_entry.url(),
|
||||
);
|
||||
response_to_fetched_manifest(
|
||||
response_result.response,
|
||||
provider_url_index,
|
||||
resolved_request_url,
|
||||
context.origin_manifest_timeout_ms,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(err) => Err(origin_manifest_fetch_error_from_io_error(&err)),
|
||||
};
|
||||
match fetch_result {
|
||||
Ok(fetched) => return Ok(fetched.with_attempts(attempt_index + 1)),
|
||||
Err(err) if is_hls_retryable_initial_manifest_fetch_error(&err) && attempt_index + 1 < attempts => {
|
||||
let retry_after_ms = match &err {
|
||||
OriginManifestFetchError::RetryableStatus(_, retry_after_ms) => *retry_after_ms,
|
||||
_ => None,
|
||||
};
|
||||
let jitter_ms = if retry_after_ms.is_some() || context.retry_policy.jitter_max_ms == 0 {
|
||||
0
|
||||
} else {
|
||||
fastrand::u64(0..=context.retry_policy.jitter_max_ms)
|
||||
};
|
||||
let delay_ms = next_retry_delay_ms(&context.retry_policy, attempt_index, retry_after_ms, jitter_ms);
|
||||
log_manifest_retry_scheduled(
|
||||
context,
|
||||
ManifestRetryLogKind::InitialFetch,
|
||||
attempt_index,
|
||||
attempts,
|
||||
delay_ms,
|
||||
None,
|
||||
Some(&err),
|
||||
)
|
||||
.await;
|
||||
if delay_ms > 0 {
|
||||
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||
}
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(OriginManifestFetchError::RetryExhausted)
|
||||
}
|
||||
|
||||
pub(crate) async fn score_hls_manifest_candidate_for_selection_log(
|
||||
@@ -578,9 +678,10 @@ where
|
||||
HlsManifestRecoveryAttemptError::Rejected(reason) if attempt_index + 1 < attempts => {
|
||||
log_manifest_retry_scheduled(
|
||||
context,
|
||||
ManifestRetryLogKind::PinnedHostRecovery,
|
||||
attempt_index,
|
||||
attempts,
|
||||
next_retry_delay_ms(&context.retry_policy, attempt_index, None),
|
||||
next_retry_delay_ms(&context.retry_policy, attempt_index, None, 0),
|
||||
Some(&reason),
|
||||
None,
|
||||
)
|
||||
@@ -596,9 +697,10 @@ where
|
||||
{
|
||||
log_manifest_retry_scheduled(
|
||||
context,
|
||||
ManifestRetryLogKind::PinnedHostRecovery,
|
||||
attempt_index,
|
||||
attempts,
|
||||
next_retry_delay_ms(&context.retry_policy, attempt_index, None),
|
||||
next_retry_delay_ms(&context.retry_policy, attempt_index, None, 0),
|
||||
None,
|
||||
Some(&err),
|
||||
)
|
||||
@@ -1301,17 +1403,36 @@ pub(crate) fn fetched_effective_manifest_host(fetched: &FetchedOriginManifest) -
|
||||
Url::parse(&fetched.resolved_request_url).ok().and_then(|url| url.host_str().map(str::to_string))
|
||||
}
|
||||
|
||||
fn is_hls_retryable_manifest_reject_fetch_error(err: &OriginManifestFetchError) -> bool {
|
||||
fn is_hls_retryable_initial_manifest_fetch_error(err: &OriginManifestFetchError) -> bool {
|
||||
matches!(
|
||||
err,
|
||||
OriginManifestFetchError::RetryableStatus(_, _)
|
||||
| OriginManifestFetchError::Request(_)
|
||||
| OriginManifestFetchError::Redirect(_)
|
||||
| OriginManifestFetchError::Timeout
|
||||
| OriginManifestFetchError::RetryExhausted
|
||||
| OriginManifestFetchError::ContentDecoding { .. }
|
||||
| OriginManifestFetchError::ContentCoding(ContentCodingError::PrefixRead(_))
|
||||
)
|
||||
}
|
||||
|
||||
fn is_hls_retryable_manifest_reject_fetch_error(err: &OriginManifestFetchError) -> bool {
|
||||
match err {
|
||||
OriginManifestFetchError::RetryableStatus(_, _)
|
||||
| OriginManifestFetchError::Request(_)
|
||||
| OriginManifestFetchError::Redirect(_)
|
||||
| OriginManifestFetchError::Timeout
|
||||
| OriginManifestFetchError::RetryExhausted
|
||||
| OriginManifestFetchError::ContentDecoding { .. }
|
||||
| OriginManifestFetchError::ContentCoding(ContentCodingError::PrefixRead(_)) => true,
|
||||
OriginManifestFetchError::PermanentStatus(_)
|
||||
| OriginManifestFetchError::NonRetryableStatus(_)
|
||||
| OriginManifestFetchError::ProviderUnavailable(_)
|
||||
| OriginManifestFetchError::ContentCoding(_)
|
||||
| OriginManifestFetchError::DecodedBodyLimitExceeded { .. }
|
||||
| OriginManifestFetchError::InvalidUtf8 { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn commit_error_to_fetch_error(err: &HlsManifestCommitError) -> OriginManifestFetchError {
|
||||
match err {
|
||||
HlsManifestCommitError::TimelineRejected { .. } | HlsManifestCommitError::RetryCurrentTarget => {
|
||||
@@ -1347,12 +1468,25 @@ pub(crate) fn next_committed_origin_highwater(
|
||||
}
|
||||
}
|
||||
|
||||
fn next_retry_delay_ms(retry_policy: &RetryPolicy, attempt_index: usize, retry_after_ms: Option<u64>) -> u64 {
|
||||
retry_after_ms.unwrap_or_else(|| retry_policy.delays_ms.get(attempt_index + 1).copied().unwrap_or_default())
|
||||
fn next_retry_delay_ms(
|
||||
retry_policy: &RetryPolicy,
|
||||
attempt_index: usize,
|
||||
retry_after_ms: Option<u64>,
|
||||
jitter_ms: u64,
|
||||
) -> u64 {
|
||||
retry_after_ms
|
||||
.unwrap_or_else(|| retry_policy.delay_for_attempt_ms(attempt_index + 1, jitter_ms).unwrap_or_default())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ManifestRetryLogKind {
|
||||
InitialFetch,
|
||||
PinnedHostRecovery,
|
||||
}
|
||||
|
||||
async fn log_manifest_retry_scheduled(
|
||||
context: &HlsOriginManifestFetchContext,
|
||||
retry_kind: ManifestRetryLogKind,
|
||||
attempt_index: usize,
|
||||
attempts: usize,
|
||||
delay_ms: u64,
|
||||
@@ -1363,7 +1497,7 @@ async fn log_manifest_retry_scheduled(
|
||||
let session = context.session.read().await;
|
||||
safe_session_key(&session.key)
|
||||
};
|
||||
let status = manifest_retry_status_label(reject_reason, fetch_error);
|
||||
let status = manifest_retry_status_label(retry_kind, reject_reason, fetch_error);
|
||||
warn!(
|
||||
"Manifest '{}' retry scheduled: status {} attempt {} of {} next_delay_ms={delay_ms}",
|
||||
session_label,
|
||||
@@ -1374,16 +1508,21 @@ async fn log_manifest_retry_scheduled(
|
||||
}
|
||||
|
||||
fn manifest_retry_status_label(
|
||||
retry_kind: ManifestRetryLogKind,
|
||||
reject_reason: Option<&HlsManifestRejectLogReason>,
|
||||
fetch_error: Option<&OriginManifestFetchError>,
|
||||
) -> String {
|
||||
match (reject_reason, fetch_error) {
|
||||
(Some(reason), Some(err)) => {
|
||||
format!("{} error={}", reason.status_label(), safe_origin_log_value(format!("{err:?}")))
|
||||
match (retry_kind, reject_reason, fetch_error) {
|
||||
(_, Some(reason), Some(err)) => format!("{} error={}", reason.status_label(), err.log_label()),
|
||||
(_, Some(reason), None) => reason.status_label(),
|
||||
(ManifestRetryLogKind::InitialFetch, None, Some(err)) => {
|
||||
format!("initial-fetch error={}", err.log_label())
|
||||
}
|
||||
(Some(reason), None) => reason.status_label(),
|
||||
(None, Some(err)) => format!("pinned-host-recovery error={}", safe_origin_log_value(format!("{err:?}"))),
|
||||
(None, None) => "pinned-host-recovery".to_string(),
|
||||
(ManifestRetryLogKind::InitialFetch, None, None) => "initial-fetch".to_string(),
|
||||
(ManifestRetryLogKind::PinnedHostRecovery, None, Some(err)) => {
|
||||
format!("pinned-host-recovery error={}", err.log_label())
|
||||
}
|
||||
(ManifestRetryLogKind::PinnedHostRecovery, None, None) => "pinned-host-recovery".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1406,12 +1545,14 @@ async fn fetch_origin_manifest_once(
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
let mut request_headers = headers.clone();
|
||||
apply_outbound_content_coding_policy(&mut request_headers, OutboundContentCodingPolicy::Identity);
|
||||
let response = client
|
||||
.get(entry_url.clone())
|
||||
.headers(headers.clone())
|
||||
.headers(request_headers)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| origin_manifest_fetch_error_from_request_error(&err))?;
|
||||
.map_err(|err| origin_manifest_fetch_error_from_reqwest_error(&err))?;
|
||||
response_to_fetched_manifest(response, provider_url_index, entry_url.clone(), origin_manifest_timeout_ms).await
|
||||
}
|
||||
}
|
||||
@@ -1428,12 +1569,14 @@ async fn fetch_origin_manifest_with_manual_redirects(
|
||||
let mut remaining_redirects = MAX_MANUAL_REDIRECTS;
|
||||
|
||||
loop {
|
||||
let mut request_headers = current_headers.clone();
|
||||
apply_outbound_content_coding_policy(&mut request_headers, OutboundContentCodingPolicy::Identity);
|
||||
let response = client
|
||||
.get(current_url.clone())
|
||||
.headers(current_headers.clone())
|
||||
.headers(request_headers)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| origin_manifest_fetch_error_from_request_error(&err))?;
|
||||
.map_err(|err| origin_manifest_fetch_error_from_reqwest_error(&err))?;
|
||||
if !response.status().is_redirection() {
|
||||
return response_to_fetched_manifest(
|
||||
response,
|
||||
@@ -1544,18 +1687,17 @@ async fn response_to_fetched_manifest(
|
||||
);
|
||||
match classify_origin_manifest_status(status) {
|
||||
OriginManifestStatusClass::Success => {
|
||||
let final_url = response.url().clone();
|
||||
let redirect_host = hls_manifest_redirect_host(&resolved_request_url, &final_url);
|
||||
let provider_session_headers = extract_hls_provider_session_header_map(response.headers());
|
||||
let body = read_origin_manifest_body(response, origin_manifest_timeout_ms).await?;
|
||||
let (decoded, body) = read_origin_manifest_body(response, origin_manifest_timeout_ms).await?;
|
||||
let redirect_host = hls_manifest_redirect_host(&resolved_request_url, &decoded.final_url);
|
||||
let provider_session_headers = extract_hls_provider_session_header_map(&decoded.headers);
|
||||
Ok(FetchedOriginManifest {
|
||||
body,
|
||||
final_manifest_url: final_url.to_string(),
|
||||
final_manifest_url: decoded.final_url.to_string(),
|
||||
resolved_request_url: resolved_request_url.to_string(),
|
||||
redirect_host,
|
||||
provider_url_index,
|
||||
provider_session_headers,
|
||||
status,
|
||||
status: decoded.status,
|
||||
attempts: 1,
|
||||
})
|
||||
}
|
||||
@@ -1567,37 +1709,66 @@ async fn response_to_fetched_manifest(
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_body_size_after_chunk(current: usize, chunk: usize) -> Option<usize> {
|
||||
current.checked_add(chunk).filter(|size| *size <= MAX_HLS_MANIFEST_BYTES)
|
||||
}
|
||||
|
||||
async fn read_origin_manifest_body(
|
||||
response: reqwest::Response,
|
||||
origin_manifest_timeout_ms: u64,
|
||||
) -> Result<String, OriginManifestFetchError> {
|
||||
if response.content_length().is_some_and(|size| size > MAX_HLS_MANIFEST_BYTES as u64) {
|
||||
return Err(OriginManifestFetchError::NonRetryableStatus(StatusCode::PAYLOAD_TOO_LARGE));
|
||||
}
|
||||
) -> Result<(crate::utils::content_coding::DecodedHttpResponse, String), OriginManifestFetchError> {
|
||||
let read = async move {
|
||||
let capacity = response.content_length().map_or(0, |size| {
|
||||
usize::try_from(size.min(MAX_HLS_MANIFEST_BYTES as u64)).unwrap_or(MAX_HLS_MANIFEST_BYTES)
|
||||
});
|
||||
let mut body = Vec::with_capacity(capacity);
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|err| origin_manifest_fetch_error_from_request_error(&err))?;
|
||||
if manifest_body_size_after_chunk(body.len(), chunk.len()).is_none() {
|
||||
return Err(OriginManifestFetchError::NonRetryableStatus(StatusCode::PAYLOAD_TOO_LARGE));
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
let mut decoded =
|
||||
decode_response_to_identity(response, ContentCodingDetection::DeclaredOrKnownHlsManifestMagic)
|
||||
.await
|
||||
.map_err(origin_manifest_content_coding_error)?;
|
||||
if let Some(observation) = decoded.content_coding_observation() {
|
||||
log_hls_origin_content_coding(
|
||||
observation,
|
||||
HlsOriginContentCodingObjectKind::Manifest,
|
||||
false,
|
||||
HlsOriginContentCodingSource::Shared,
|
||||
);
|
||||
}
|
||||
String::from_utf8(body).map_err(|_| OriginManifestFetchError::Request("origin manifest is not UTF-8".to_string()))
|
||||
let body = read_utf8_limited(&mut decoded.body, MAX_HLS_MANIFEST_BYTES)
|
||||
.await
|
||||
.map_err(origin_manifest_body_read_error)?;
|
||||
Ok((decoded, body))
|
||||
};
|
||||
timeout(Duration::from_millis(origin_manifest_timeout_ms.max(1)), read)
|
||||
.await
|
||||
.map_err(|_| OriginManifestFetchError::Timeout)?
|
||||
}
|
||||
|
||||
fn origin_manifest_content_coding_error(error: ContentCodingError) -> OriginManifestFetchError {
|
||||
match error {
|
||||
ContentCodingError::PrefixRead(io_error) => {
|
||||
if let Some(decoding_error) = content_decoding_error_from_io(&io_error) {
|
||||
OriginManifestFetchError::ContentDecoding { coding: decoding_error.coding }
|
||||
} else if io_error.kind() == io::ErrorKind::TimedOut {
|
||||
OriginManifestFetchError::Timeout
|
||||
} else if is_http_body_transport_error(&io_error) {
|
||||
origin_manifest_fetch_error_from_io_error(&io_error)
|
||||
} else {
|
||||
OriginManifestFetchError::ContentCoding(ContentCodingError::PrefixRead(io_error))
|
||||
}
|
||||
}
|
||||
error => OriginManifestFetchError::ContentCoding(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn origin_manifest_body_read_error(error: ContentBodyReadError) -> OriginManifestFetchError {
|
||||
match error {
|
||||
ContentBodyReadError::LimitExceeded { limit } => OriginManifestFetchError::DecodedBodyLimitExceeded { limit },
|
||||
ContentBodyReadError::InvalidUtf8 { valid_up_to, error_len } => {
|
||||
OriginManifestFetchError::InvalidUtf8 { valid_up_to, error_len }
|
||||
}
|
||||
ContentBodyReadError::Io(io_error) => {
|
||||
if let Some(decoding_error) = content_decoding_error_from_io(&io_error) {
|
||||
OriginManifestFetchError::ContentDecoding { coding: decoding_error.coding }
|
||||
} else {
|
||||
origin_manifest_fetch_error_from_io_error(&io_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn hls_manifest_redirect_host(resolved_request_url: &Url, final_url: &Url) -> Option<String> {
|
||||
let final_host = final_url.host_str()?;
|
||||
(resolved_request_url.host_str() != Some(final_host)).then(|| final_host.to_string())
|
||||
@@ -1649,6 +1820,22 @@ fn origin_manifest_fetch_error_from_request_error(err: &impl ToString) -> Origin
|
||||
}
|
||||
}
|
||||
|
||||
fn origin_manifest_fetch_error_from_reqwest_error(err: &reqwest::Error) -> OriginManifestFetchError {
|
||||
if err.is_timeout() {
|
||||
OriginManifestFetchError::Timeout
|
||||
} else {
|
||||
origin_manifest_fetch_error_from_request_error(err)
|
||||
}
|
||||
}
|
||||
|
||||
fn origin_manifest_fetch_error_from_io_error(err: &io::Error) -> OriginManifestFetchError {
|
||||
if err.kind() == io::ErrorKind::TimedOut {
|
||||
OriginManifestFetchError::Timeout
|
||||
} else {
|
||||
origin_manifest_fetch_error_from_request_error(err)
|
||||
}
|
||||
}
|
||||
|
||||
fn request_failed_status_from_message(message: &str) -> Option<StatusCode> {
|
||||
let marker = "Request failed (";
|
||||
let status_start = message.find(marker)?.checked_add(marker.len())?;
|
||||
@@ -1730,7 +1917,7 @@ pub(crate) async fn refresh_from_live_hls_entrypoint_with_retries(
|
||||
log_origin_refresh_retry_scheduled(
|
||||
origin_entry,
|
||||
attempt_index,
|
||||
next_retry_delay_ms(retry_policy, attempt_index, retry_after_ms),
|
||||
next_retry_delay_ms(retry_policy, attempt_index, retry_after_ms, 0),
|
||||
format!("status={}", status.as_u16()),
|
||||
);
|
||||
retry_after_delay_ms = retry_after_ms;
|
||||
@@ -1742,8 +1929,22 @@ pub(crate) async fn refresh_from_live_hls_entrypoint_with_retries(
|
||||
log_origin_refresh_retry_scheduled(
|
||||
origin_entry,
|
||||
attempt_index,
|
||||
next_retry_delay_ms(retry_policy, attempt_index, None),
|
||||
format!("error={}", safe_origin_log_value(&err)),
|
||||
next_retry_delay_ms(retry_policy, attempt_index, None, 0),
|
||||
"error=request".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(Err(
|
||||
err @ (OriginManifestFetchError::ContentDecoding { .. }
|
||||
| OriginManifestFetchError::ContentCoding(ContentCodingError::PrefixRead(_))),
|
||||
)) => {
|
||||
if attempt_index + 1 == attempts {
|
||||
return Err(err);
|
||||
}
|
||||
log_origin_refresh_retry_scheduled(
|
||||
origin_entry,
|
||||
attempt_index,
|
||||
next_retry_delay_ms(retry_policy, attempt_index, None, 0),
|
||||
format!("error={}", err.log_label()),
|
||||
);
|
||||
}
|
||||
Ok(Err(err @ (OriginManifestFetchError::Redirect(_) | OriginManifestFetchError::Timeout))) => {
|
||||
@@ -1753,14 +1954,19 @@ pub(crate) async fn refresh_from_live_hls_entrypoint_with_retries(
|
||||
log_origin_refresh_retry_scheduled(
|
||||
origin_entry,
|
||||
attempt_index,
|
||||
next_retry_delay_ms(retry_policy, attempt_index, None),
|
||||
format!("error={}", safe_origin_log_value(format!("{err:?}"))),
|
||||
next_retry_delay_ms(retry_policy, attempt_index, None, 0),
|
||||
format!("error={}", err.log_label()),
|
||||
);
|
||||
}
|
||||
Ok(Err(OriginManifestFetchError::RetryExhausted)) => return Err(OriginManifestFetchError::RetryExhausted),
|
||||
Ok(Err(OriginManifestFetchError::ProviderUnavailable(kind))) => {
|
||||
return Err(OriginManifestFetchError::ProviderUnavailable(kind));
|
||||
}
|
||||
Ok(Err(
|
||||
err @ (OriginManifestFetchError::ContentCoding(_)
|
||||
| OriginManifestFetchError::DecodedBodyLimitExceeded { .. }
|
||||
| OriginManifestFetchError::InvalidUtf8 { .. }),
|
||||
)) => return Err(err),
|
||||
Err(OriginManifestFetchError::Timeout) => {
|
||||
if attempt_index + 1 == attempts {
|
||||
return Err(OriginManifestFetchError::Timeout);
|
||||
@@ -1768,7 +1974,7 @@ pub(crate) async fn refresh_from_live_hls_entrypoint_with_retries(
|
||||
log_origin_refresh_retry_scheduled(
|
||||
origin_entry,
|
||||
attempt_index,
|
||||
next_retry_delay_ms(retry_policy, attempt_index, None),
|
||||
next_retry_delay_ms(retry_policy, attempt_index, None, 0),
|
||||
"error=timeout",
|
||||
);
|
||||
}
|
||||
@@ -1797,10 +2003,130 @@ fn log_origin_refresh_retry_scheduled(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
manifest_body_size_after_chunk, origin_manifest_fetch_error_from_request_error, request_failed_status_from_message,
|
||||
OriginManifestFetchError, MAX_HLS_MANIFEST_BYTES,
|
||||
fetch_origin_manifest_once, origin_manifest_content_coding_error, origin_manifest_fetch_error_from_io_error,
|
||||
origin_manifest_fetch_error_from_request_error, request_failed_status_from_message, ManifestRetryLogKind,
|
||||
OriginManifestFetchError, RetryPolicy, MAX_HLS_MANIFEST_BYTES,
|
||||
};
|
||||
use axum::http::StatusCode;
|
||||
use crate::utils::content_coding::{ContentCoding, ContentCodingError};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use flate2::{
|
||||
write::{DeflateEncoder, GzEncoder},
|
||||
Compression,
|
||||
};
|
||||
use reqwest::{redirect::Policy, Client};
|
||||
use std::{io, io::Write, time::Duration};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpListener,
|
||||
sync::oneshot,
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
const TEST_MANIFEST: &[u8] = b"#EXTM3U\n#EXT-X-TARGETDURATION:2\n#EXTINF:2,\nsegment.ts\n";
|
||||
|
||||
struct TestOriginResponse {
|
||||
status: &'static str,
|
||||
headers: Vec<(&'static str, String)>,
|
||||
body: Vec<u8>,
|
||||
body_delay: Duration,
|
||||
}
|
||||
|
||||
impl TestOriginResponse {
|
||||
fn ok(body: Vec<u8>) -> Self {
|
||||
Self { status: "200 OK", headers: Vec::new(), body, body_delay: Duration::ZERO }
|
||||
}
|
||||
|
||||
fn with_header(mut self, name: &'static str, value: impl Into<String>) -> Self {
|
||||
self.headers.push((name, value.into()));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_test_origin(response: TestOriginResponse) -> (Url, oneshot::Receiver<String>) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await.expect("bind test origin");
|
||||
let address = listener.local_addr().expect("test origin address");
|
||||
let (request_sender, request_receiver) = oneshot::channel();
|
||||
tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.expect("accept test request");
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 1024];
|
||||
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
let count = socket.read(&mut buffer).await.expect("read test request");
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&buffer[..count]);
|
||||
}
|
||||
let _ = request_sender.send(String::from_utf8_lossy(&request).into_owned());
|
||||
|
||||
let mut response_head = format!(
|
||||
"HTTP/1.1 {}\r\nConnection: close\r\nContent-Length: {}\r\n",
|
||||
response.status,
|
||||
response.body.len()
|
||||
);
|
||||
for (name, value) in response.headers {
|
||||
response_head.push_str(name);
|
||||
response_head.push_str(": ");
|
||||
response_head.push_str(&value);
|
||||
response_head.push_str("\r\n");
|
||||
}
|
||||
response_head.push_str("\r\n");
|
||||
socket.write_all(response_head.as_bytes()).await.expect("write test response headers");
|
||||
if !response.body_delay.is_zero() {
|
||||
tokio::time::sleep(response.body_delay).await;
|
||||
}
|
||||
let _ = socket.write_all(&response.body).await;
|
||||
});
|
||||
(Url::parse(&format!("http://{address}/manifest.m3u8")).expect("test origin URL"), request_receiver)
|
||||
}
|
||||
|
||||
fn gzip(input: &[u8]) -> Vec<u8> {
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
encoder.write_all(input).expect("gzip input");
|
||||
encoder.finish().expect("finish gzip")
|
||||
}
|
||||
|
||||
fn raw_deflate(input: &[u8]) -> Vec<u8> {
|
||||
let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
|
||||
encoder.write_all(input).expect("raw-deflate input");
|
||||
encoder.finish().expect("finish raw-deflate")
|
||||
}
|
||||
|
||||
fn brotli(input: &[u8]) -> Vec<u8> {
|
||||
let mut output = Vec::new();
|
||||
{
|
||||
let mut encoder = brotli::CompressorWriter::new(&mut output, 4096, 5, 22);
|
||||
encoder.write_all(input).expect("brotli input");
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
async fn zstd(input: &[u8]) -> Vec<u8> {
|
||||
let (writer, mut reader) = tokio::io::duplex(64 * 1024);
|
||||
let input = input.to_vec();
|
||||
let encoder_task = tokio::spawn(async move {
|
||||
let mut encoder = async_compression::tokio::write::ZstdEncoder::new(writer);
|
||||
encoder.write_all(&input).await.expect("zstd input");
|
||||
encoder.shutdown().await.expect("finish zstd");
|
||||
});
|
||||
let mut output = Vec::new();
|
||||
reader.read_to_end(&mut output).await.expect("read zstd output");
|
||||
encoder_task.await.expect("join zstd encoder");
|
||||
output
|
||||
}
|
||||
|
||||
fn captured_header<'a>(request: &'a str, name: &str) -> Option<&'a str> {
|
||||
request.lines().skip(1).find_map(|line| {
|
||||
let (header_name, value) = line.split_once(':')?;
|
||||
header_name.eq_ignore_ascii_case(name).then(|| value.trim())
|
||||
})
|
||||
}
|
||||
|
||||
fn test_clients() -> (Client, Client) {
|
||||
let client = Client::builder().build().expect("test client");
|
||||
let no_redirect_client = Client::builder().redirect(Policy::none()).build().expect("no-redirect client");
|
||||
(client, no_redirect_client)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_failed_status_is_extracted_from_global_provider_policy_error() {
|
||||
@@ -1812,10 +2138,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_manifest_retry_delay_uses_next_slot_bounded_jitter_and_retry_after_override() {
|
||||
let retry_policy = RetryPolicy { delays_ms: [0, 100, 250, 500, 750], jitter_max_ms: 25 };
|
||||
|
||||
assert_eq!(super::next_retry_delay_ms(&retry_policy, 0, None, 17), 117);
|
||||
assert_eq!(super::next_retry_delay_ms(&retry_policy, 0, None, 100), 125);
|
||||
assert_eq!(super::next_retry_delay_ms(&retry_policy, 1, None, 10), 260);
|
||||
assert_eq!(super::next_retry_delay_ms(&retry_policy, 0, Some(3_000), 25), 3_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_manifest_retry_status_is_not_labeled_as_recovery() {
|
||||
let error = OriginManifestFetchError::Timeout;
|
||||
|
||||
let initial = super::manifest_retry_status_label(ManifestRetryLogKind::InitialFetch, None, Some(&error));
|
||||
let recovery = super::manifest_retry_status_label(ManifestRetryLogKind::PinnedHostRecovery, None, Some(&error));
|
||||
|
||||
assert!(initial.starts_with("initial-fetch error="));
|
||||
assert!(!initial.contains("pinned-host-recovery"));
|
||||
assert!(recovery.starts_with("pinned-host-recovery error="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_content_coding_log_labels_never_include_origin_controlled_details() {
|
||||
let unsupported =
|
||||
OriginManifestFetchError::ContentCoding(ContentCodingError::Unsupported("signed-token-secret".to_string()));
|
||||
assert_eq!(unsupported.log_label(), "content_coding class=unsupported");
|
||||
assert!(!unsupported.log_label().contains("signed-token-secret"));
|
||||
|
||||
let decoding = OriginManifestFetchError::ContentDecoding { coding: ContentCoding::Brotli };
|
||||
assert_eq!(decoding.log_label(), "content_decoding coding=br");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_failed_407_maps_to_retryable_manifest_status() {
|
||||
let err = origin_manifest_fetch_error_from_request_error(&
|
||||
"Request failed (407 Proxy Authentication Required): provider://demo/live/u/p/1.m3u8",
|
||||
let err = origin_manifest_fetch_error_from_request_error(
|
||||
&"Request failed (407 Proxy Authentication Required): provider://demo/live/u/p/1.m3u8",
|
||||
);
|
||||
assert!(matches!(
|
||||
err,
|
||||
@@ -1839,20 +2198,211 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn request_failed_404_maps_to_permanent_manifest_status() {
|
||||
let err =
|
||||
origin_manifest_fetch_error_from_request_error(&"Request failed (404 Not Found): http://example.test/live.m3u8");
|
||||
let err = origin_manifest_fetch_error_from_request_error(
|
||||
&"Request failed (404 Not Found): http://example.test/live.m3u8",
|
||||
);
|
||||
assert!(matches!(err, OriginManifestFetchError::PermanentStatus(StatusCode::NOT_FOUND)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_error_without_http_status_stays_request_error() {
|
||||
let err = origin_manifest_fetch_error_from_request_error(&"error sending request for url");
|
||||
assert!(matches!(err, OriginManifestFetchError::Request(message) if message == "error sending request for url"));
|
||||
assert!(
|
||||
matches!(err, OriginManifestFetchError::Request(message) if message == "error sending request for url")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_body_limit_rejects_the_first_byte_above_the_limit() {
|
||||
assert_eq!(manifest_body_size_after_chunk(MAX_HLS_MANIFEST_BYTES - 1, 1), Some(MAX_HLS_MANIFEST_BYTES));
|
||||
assert_eq!(manifest_body_size_after_chunk(MAX_HLS_MANIFEST_BYTES, 1), None);
|
||||
fn io_timeout_maps_to_structured_manifest_timeout() {
|
||||
let error = io::Error::new(io::ErrorKind::TimedOut, "origin body timed out");
|
||||
assert!(matches!(origin_manifest_fetch_error_from_io_error(&error), OriginManifestFetchError::Timeout));
|
||||
let prefix_timeout =
|
||||
ContentCodingError::PrefixRead(io::Error::new(io::ErrorKind::TimedOut, "origin prefix timed out"));
|
||||
assert!(matches!(origin_manifest_content_coding_error(prefix_timeout), OriginManifestFetchError::Timeout));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_manifest_decodes_supported_origin_content_codings_and_keeps_provider_cookie() {
|
||||
let encoded_bodies = [
|
||||
("gzip", gzip(TEST_MANIFEST)),
|
||||
("deflate", raw_deflate(TEST_MANIFEST)),
|
||||
("br", brotli(TEST_MANIFEST)),
|
||||
("zstd", zstd(TEST_MANIFEST).await),
|
||||
];
|
||||
|
||||
for (coding, encoded_body) in encoded_bodies {
|
||||
let response = TestOriginResponse::ok(encoded_body)
|
||||
.with_header("Content-Encoding", coding)
|
||||
.with_header("Set-Cookie", format!("sid={coding}; Path=/"));
|
||||
let (entry_url, request_receiver) = spawn_test_origin(response).await;
|
||||
let (client, no_redirect_client) = test_clients();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br"));
|
||||
|
||||
let fetched =
|
||||
fetch_origin_manifest_once(&entry_url, &headers, &client, &no_redirect_client, false, None, 1_000)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("decode {coding} manifest: {error:?}"));
|
||||
|
||||
assert_eq!(fetched.body.as_bytes(), TEST_MANIFEST, "coding={coding}");
|
||||
assert_eq!(
|
||||
fetched
|
||||
.provider_session_headers
|
||||
.get(header::COOKIE)
|
||||
.expect("provider cookie")
|
||||
.to_str()
|
||||
.expect("provider cookie text"),
|
||||
format!("sid={coding}"),
|
||||
"coding={coding}"
|
||||
);
|
||||
let request = request_receiver.await.expect("captured request");
|
||||
assert_eq!(captured_header(&request, "accept-encoding"), Some("identity"), "coding={coding}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_manifest_magic_sniffs_gzip_only_in_manifest_mode() {
|
||||
let (entry_url, _) = spawn_test_origin(TestOriginResponse::ok(gzip(TEST_MANIFEST))).await;
|
||||
let (client, no_redirect_client) = test_clients();
|
||||
|
||||
let fetched =
|
||||
fetch_origin_manifest_once(&entry_url, &HeaderMap::new(), &client, &no_redirect_client, false, None, 1_000)
|
||||
.await
|
||||
.expect("decode magic-sniffed gzip manifest");
|
||||
|
||||
assert_eq!(fetched.body.as_bytes(), TEST_MANIFEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_manual_redirect_reapplies_identity_after_cross_origin_scrubbing() {
|
||||
let (target_url, target_request_receiver) =
|
||||
spawn_test_origin(TestOriginResponse::ok(TEST_MANIFEST.to_vec())).await;
|
||||
let redirect_response = TestOriginResponse {
|
||||
status: "302 Found",
|
||||
headers: vec![("Location", target_url.to_string())],
|
||||
body: Vec::new(),
|
||||
body_delay: Duration::ZERO,
|
||||
};
|
||||
let (entry_url, redirect_request_receiver) = spawn_test_origin(redirect_response).await;
|
||||
let (client, no_redirect_client) = test_clients();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br"));
|
||||
headers.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer secret"));
|
||||
headers.insert(header::COOKIE, HeaderValue::from_static("sid=secret"));
|
||||
|
||||
let fetched = fetch_origin_manifest_once(&entry_url, &headers, &client, &no_redirect_client, true, None, 1_000)
|
||||
.await
|
||||
.expect("follow manual manifest redirect");
|
||||
assert_eq!(fetched.body.as_bytes(), TEST_MANIFEST);
|
||||
|
||||
let redirect_request = redirect_request_receiver.await.expect("captured redirect request");
|
||||
let target_request = target_request_receiver.await.expect("captured target request");
|
||||
assert_eq!(captured_header(&redirect_request, "accept-encoding"), Some("identity"));
|
||||
assert_eq!(captured_header(&target_request, "accept-encoding"), Some("identity"));
|
||||
assert!(captured_header(&target_request, "authorization").is_none());
|
||||
assert!(captured_header(&target_request, "cookie").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_manifest_limit_applies_after_decompression() {
|
||||
let decoded_body = vec![b'x'; MAX_HLS_MANIFEST_BYTES + 1];
|
||||
let response = TestOriginResponse::ok(gzip(&decoded_body)).with_header("Content-Encoding", "gzip");
|
||||
let (entry_url, _) = spawn_test_origin(response).await;
|
||||
let (client, no_redirect_client) = test_clients();
|
||||
|
||||
let error =
|
||||
fetch_origin_manifest_once(&entry_url, &HeaderMap::new(), &client, &no_redirect_client, false, None, 2_000)
|
||||
.await
|
||||
.expect_err("decoded manifest above limit must fail");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
OriginManifestFetchError::DecodedBodyLimitExceeded { limit } if limit == MAX_HLS_MANIFEST_BYTES
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_manifest_deadline_includes_magic_prefix_read() {
|
||||
let response = TestOriginResponse {
|
||||
body_delay: Duration::from_millis(100),
|
||||
..TestOriginResponse::ok(TEST_MANIFEST.to_vec())
|
||||
};
|
||||
let (entry_url, _) = spawn_test_origin(response).await;
|
||||
let (client, no_redirect_client) = test_clients();
|
||||
|
||||
let error =
|
||||
fetch_origin_manifest_once(&entry_url, &HeaderMap::new(), &client, &no_redirect_client, false, None, 10)
|
||||
.await
|
||||
.expect_err("prefix read must honor manifest deadline");
|
||||
|
||||
assert!(matches!(error, OriginManifestFetchError::Timeout));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_manifest_encoded_body_timeout_stays_structured_timeout() {
|
||||
let response = TestOriginResponse {
|
||||
body_delay: Duration::from_millis(100),
|
||||
..TestOriginResponse::ok(gzip(TEST_MANIFEST)).with_header("Content-Encoding", "gzip")
|
||||
};
|
||||
let (entry_url, _) = spawn_test_origin(response).await;
|
||||
let (client, no_redirect_client) = test_clients();
|
||||
|
||||
let error =
|
||||
fetch_origin_manifest_once(&entry_url, &HeaderMap::new(), &client, &no_redirect_client, false, None, 10)
|
||||
.await
|
||||
.expect_err("encoded body read must honor manifest deadline");
|
||||
|
||||
assert!(matches!(error, OriginManifestFetchError::Timeout));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_manifest_distinguishes_invalid_utf8_from_decoder_failure() {
|
||||
let (invalid_utf8_url, _) = spawn_test_origin(TestOriginResponse::ok(vec![0xff])).await;
|
||||
let corrupt_gzip = TestOriginResponse::ok(vec![0x1f, 0x8b, 0x08, 0x00]).with_header("Content-Encoding", "gzip");
|
||||
let (corrupt_gzip_url, _) = spawn_test_origin(corrupt_gzip).await;
|
||||
let (client, no_redirect_client) = test_clients();
|
||||
|
||||
let invalid_utf8 = fetch_origin_manifest_once(
|
||||
&invalid_utf8_url,
|
||||
&HeaderMap::new(),
|
||||
&client,
|
||||
&no_redirect_client,
|
||||
false,
|
||||
None,
|
||||
1_000,
|
||||
)
|
||||
.await
|
||||
.expect_err("invalid UTF-8 must fail");
|
||||
let decoder_failure = fetch_origin_manifest_once(
|
||||
&corrupt_gzip_url,
|
||||
&HeaderMap::new(),
|
||||
&client,
|
||||
&no_redirect_client,
|
||||
false,
|
||||
None,
|
||||
1_000,
|
||||
)
|
||||
.await
|
||||
.expect_err("corrupt gzip must fail");
|
||||
|
||||
assert!(matches!(invalid_utf8, OriginManifestFetchError::InvalidUtf8 { .. }));
|
||||
assert!(matches!(decoder_failure, OriginManifestFetchError::ContentDecoding { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_manifest_rejects_encoded_partial_content() {
|
||||
let response = TestOriginResponse {
|
||||
status: "206 Partial Content",
|
||||
..TestOriginResponse::ok(gzip(TEST_MANIFEST)).with_header("Content-Encoding", "gzip")
|
||||
};
|
||||
let (entry_url, _) = spawn_test_origin(response).await;
|
||||
let (client, no_redirect_client) = test_clients();
|
||||
|
||||
let error =
|
||||
fetch_origin_manifest_once(&entry_url, &HeaderMap::new(), &client, &no_redirect_client, false, None, 1_000)
|
||||
.await
|
||||
.expect_err("encoded partial manifest must fail");
|
||||
|
||||
assert!(matches!(error, OriginManifestFetchError::ContentCoding(ContentCodingError::EncodedPartialContent)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{cache::MapCacheKey, ids::ProxySessionId, session::HlsSession, timeline::CacheAccessState};
|
||||
use axum::http::StatusCode;
|
||||
use crate::processing::parser::hls::origin_manifest::ParsedByteRange;
|
||||
use axum::http::StatusCode;
|
||||
use std::{
|
||||
fmt,
|
||||
hash::{Hash, Hasher},
|
||||
|
||||
@@ -2,20 +2,19 @@ use super::{
|
||||
begin_hls_origin_account_io_bounded, build_hls_origin_resource_headers, finish_hls_origin_account_io,
|
||||
hls_object_body_deadline, run_hls_origin_resource_retry_loop_with_attempt_prepare, CachedSegmentMetadata,
|
||||
HlsAccessLeaseChannelUnavailableReason, HlsAccessLeaseStore, HlsBoundAccountAcquireErrorKind,
|
||||
HlsOriginAccountIoLeaseGuard, HlsOriginByteRangeExpectation, HlsOriginIoContext, HlsOriginResourceClients,
|
||||
HlsOriginResourceFetchError, HlsOriginResourceFetchTarget, HlsResourceFetchKind, HlsResourceFetchSource,
|
||||
HlsSegmentCache,
|
||||
HlsSessionHandle, MapCacheKey, MapCacheStatus, OriginMapFetchRef, ProxyMapId, SegmentFetchPolicy,
|
||||
HlsOriginAccountIoLeaseGuard, HlsOriginByteRangeExpectation, HlsOriginIoContext, HlsOriginResourceBodyDeadline,
|
||||
HlsOriginResourceClients, HlsOriginResourceFetchError, HlsOriginResourceFetchTarget, HlsResourceFetchKind,
|
||||
HlsResourceFetchSource, HlsSegmentCache, HlsSessionHandle, MapCacheKey, MapCacheStatus, OriginMapFetchRef,
|
||||
ProxyMapId, SegmentFetchPolicy,
|
||||
};
|
||||
use crate::processing::parser::hls::origin_manifest::ParsedByteRange;
|
||||
use crate::{processing::parser::hls::origin_manifest::ParsedByteRange, utils::content_coding::DecodedHttpResponse};
|
||||
use arc_swap::ArcSwap;
|
||||
use axum::http::HeaderMap;
|
||||
use futures::{FutureExt, TryStreamExt};
|
||||
use futures::FutureExt;
|
||||
use log::debug;
|
||||
use reqwest::Client;
|
||||
use std::{fmt, io, sync::Arc};
|
||||
use std::{fmt, sync::Arc};
|
||||
use tokio::sync::{OwnedSemaphorePermit, RwLock, Semaphore};
|
||||
use tokio_util::io::StreamReader;
|
||||
|
||||
/// Shared context required to schedule EXT-X-MAP origin fetches without holding session locks.
|
||||
#[derive(Clone)]
|
||||
@@ -174,12 +173,7 @@ impl HlsMapWorkerPool {
|
||||
proxy_map_id_log
|
||||
);
|
||||
|
||||
Some(MapFetchSnapshot {
|
||||
proxy_map_id,
|
||||
proxy_map_id_log,
|
||||
cache_key,
|
||||
fetch_ref,
|
||||
})
|
||||
Some(MapFetchSnapshot { proxy_map_id, proxy_map_id_log, cache_key, fetch_ref })
|
||||
}
|
||||
|
||||
async fn fetch_one_map(
|
||||
@@ -216,8 +210,10 @@ impl HlsMapWorkerPool {
|
||||
response_flag_reason = Some(HlsAccessLeaseChannelUnavailableReason::MapPermanentFailure {
|
||||
status: err.permanent_status(),
|
||||
});
|
||||
entry.status =
|
||||
MapCacheStatus::FailedPermanent { failed_at_ms: finished_at_ms, status: err.permanent_status() };
|
||||
entry.status = MapCacheStatus::FailedPermanent {
|
||||
failed_at_ms: finished_at_ms,
|
||||
status: err.permanent_status(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,10 +244,7 @@ impl HlsMapWorkerPool {
|
||||
reason: HlsAccessLeaseChannelUnavailableReason,
|
||||
) -> usize {
|
||||
let proxy_session_id = session.read().await.proxy_session_id.clone();
|
||||
self.access_leases
|
||||
.write()
|
||||
.await
|
||||
.mark_channel_unavailable_for_session(&proxy_session_id, now_ms, reason)
|
||||
self.access_leases.write().await.mark_channel_unavailable_for_session(&proxy_session_id, now_ms, reason)
|
||||
}
|
||||
|
||||
fn schedule_wake(self: &Arc<Self>, context: MapFetchContext, now_ms: u64) {
|
||||
@@ -396,7 +389,6 @@ async fn fetch_map_with_retries_into_cache(
|
||||
let session_log_id = context.session.read().await.proxy_session_id.0.clone();
|
||||
let context = context.clone();
|
||||
let snapshot = snapshot.clone();
|
||||
let commit_policy = policy.clone();
|
||||
let policy_for_prepare = policy.clone();
|
||||
let prepare_context = context.clone();
|
||||
let cleanup_context = context.clone();
|
||||
@@ -417,12 +409,11 @@ async fn fetch_map_with_retries_into_cache(
|
||||
}
|
||||
.boxed()
|
||||
},
|
||||
move |response, _attempt, guard| {
|
||||
move |response, _attempt, body_deadline, guard| {
|
||||
let context = context.clone();
|
||||
let snapshot = snapshot.clone();
|
||||
let policy = commit_policy.clone();
|
||||
async move {
|
||||
let commit_result = commit_map_response_into_cache(&context, &snapshot, &policy, response).await;
|
||||
let commit_result = commit_map_response_into_cache(&context, &snapshot, response, body_deadline).await;
|
||||
let origin_work = finish_map_origin_attempt(context, guard).await;
|
||||
commit_result.map(|metadata| MapFetchCommit {
|
||||
content_length: metadata.size,
|
||||
@@ -438,24 +429,14 @@ async fn fetch_map_with_retries_into_cache(
|
||||
async fn commit_map_response_into_cache(
|
||||
context: &MapFetchContext,
|
||||
snapshot: &MapFetchSnapshot,
|
||||
policy: &SegmentFetchPolicy,
|
||||
response: reqwest::Response,
|
||||
response: DecodedHttpResponse,
|
||||
body_deadline: HlsOriginResourceBodyDeadline,
|
||||
) -> Result<CachedSegmentMetadata, MapFetchError> {
|
||||
let deadline = hls_object_body_deadline(policy.origin_segment_timeout_ms);
|
||||
let stream_reader = StreamReader::new(response.bytes_stream().map_err(io::Error::other));
|
||||
context
|
||||
.segment_cache
|
||||
.write_temp_and_commit_with_timeout(&snapshot.cache_key, stream_reader, deadline)
|
||||
.write_temp_and_commit_with_deadline(&snapshot.cache_key, response.body, body_deadline.deadline())
|
||||
.await
|
||||
.map_err(
|
||||
|err| {
|
||||
if err.kind() == io::ErrorKind::TimedOut {
|
||||
MapFetchError::Timeout
|
||||
} else {
|
||||
MapFetchError::cache_commit(&err)
|
||||
}
|
||||
},
|
||||
)
|
||||
.map_err(|err| MapFetchError::cache_body(&err))
|
||||
}
|
||||
|
||||
fn build_map_origin_headers(
|
||||
@@ -481,7 +462,8 @@ mod tests {
|
||||
},
|
||||
};
|
||||
use axum::http::{header, HeaderMap};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use flate2::{write::GzEncoder, Compression};
|
||||
use std::{io::Write, sync::Arc, time::Duration};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpListener,
|
||||
@@ -526,7 +508,7 @@ mod tests {
|
||||
&HeaderMap::new(),
|
||||
Some(ParsedByteRange { offset: 10, length: 5 }),
|
||||
)
|
||||
.expect("headers should build");
|
||||
.expect("headers should build");
|
||||
|
||||
assert_eq!(headers.get(header::RANGE).expect("range"), "bytes=10-14");
|
||||
assert_eq!(headers.get(header::ACCEPT_ENCODING).expect("encoding"), "identity");
|
||||
@@ -609,6 +591,97 @@ mod tests {
|
||||
server.await.expect("server joins");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gzip_fmp4_map_fetch_commits_identity_representation() {
|
||||
const FMP4_MAP: &[u8] = b"\0\0\0\x18ftypisom\0\0\x02\0isomiso2";
|
||||
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
encoder.write_all(FMP4_MAP).expect("gzip fixture should encode");
|
||||
let encoded_map = encoder.finish().expect("gzip fixture should finish");
|
||||
let encoded_len = encoded_map.len();
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds");
|
||||
let addr = listener.local_addr().expect("local addr");
|
||||
let server = tokio::spawn(async move {
|
||||
let Ok((mut socket, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let mut buf = vec![0_u8; 2048];
|
||||
let read = socket.read(&mut buf).await.expect("request reads");
|
||||
let request = String::from_utf8_lossy(&buf[..read]);
|
||||
assert!(
|
||||
request.to_ascii_lowercase().contains("accept-encoding: identity"),
|
||||
"map origin request must enforce identity"
|
||||
);
|
||||
let headers = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: video/mp4\r\nContent-Encoding: gzip\r\nContent-Length: {encoded_len}\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
socket.write_all(headers.as_bytes()).await.expect("response headers write");
|
||||
socket.write_all(&encoded_map).await.expect("response body writes");
|
||||
});
|
||||
let base_url = format!("http://{addr}/live/index.m3u8");
|
||||
let manifest = normal_manifest("#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXTINF:4.0,\n1.m4s\n", &base_url);
|
||||
let store = HlsSessionStore::new();
|
||||
let session = store.get_or_create_session(HlsSessionKey::new(1, "1"), b"secret", 0).await;
|
||||
{
|
||||
let mut session = session.write().await;
|
||||
session.apply_origin_manifest(&manifest).expect("manifest maps");
|
||||
session.queue_map_fetch_candidates(1);
|
||||
}
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir");
|
||||
let cache = Arc::new(HlsSegmentCache::with_cache_path(temp_dir.path().to_path_buf()));
|
||||
let worker = Arc::new(HlsMapWorkerPool::new(SegmentFetchPolicy {
|
||||
retry_jitter_max_ms: 0,
|
||||
origin_segment_timeout_ms: 1_000,
|
||||
..SegmentFetchPolicy::default()
|
||||
}));
|
||||
let proxy_session_id = session.read().await.proxy_session_id.clone();
|
||||
grant_usable_map_access_lease(&worker, &proxy_session_id).await;
|
||||
worker
|
||||
.wake_scheduler(
|
||||
MapFetchContext {
|
||||
session: Arc::clone(&session),
|
||||
segment_cache: Arc::clone(&cache),
|
||||
headers: HeaderMap::new(),
|
||||
origin_provider_session_headers: HeaderMap::new(),
|
||||
client: reqwest::Client::new(),
|
||||
no_redirect_client: reqwest::Client::new(),
|
||||
use_manual_redirects: false,
|
||||
origin_io: None,
|
||||
},
|
||||
2,
|
||||
)
|
||||
.await;
|
||||
|
||||
for _ in 0..50 {
|
||||
if matches!(
|
||||
session.read().await.maps.values().next().map(|map| &map.status),
|
||||
Some(MapCacheStatus::Ready { .. })
|
||||
) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
let cache_key = {
|
||||
let session = session.read().await;
|
||||
let map = session.maps.values().next().expect("map");
|
||||
assert!(matches!(
|
||||
&map.status,
|
||||
MapCacheStatus::Ready { content_length, .. }
|
||||
if *content_length == u64::try_from(FMP4_MAP.len()).expect("fixture length fits u64")
|
||||
));
|
||||
map.cache_key.clone()
|
||||
};
|
||||
let metadata = cache.metadata(&cache_key).await.expect("metadata reads").expect("map is committed");
|
||||
let cached_map = tokio::fs::read(metadata.path).await.expect("cached map reads");
|
||||
assert_eq!(cached_map, FMP4_MAP);
|
||||
assert!(!cached_map.starts_with(&[0x1f, 0x8b]));
|
||||
assert!(!cache.has_active_temp_files().await);
|
||||
server.await.expect("server joins");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn map_fetch_snapshot_uses_concrete_final_map_fetch_url() {
|
||||
let manifest = normal_manifest(
|
||||
@@ -638,10 +711,8 @@ mod tests {
|
||||
origin_io: None,
|
||||
};
|
||||
|
||||
let snapshot = worker
|
||||
.next_fetch_snapshot(&context, 2, &SegmentFetchPolicy::default())
|
||||
.await
|
||||
.expect("map snapshot");
|
||||
let snapshot =
|
||||
worker.next_fetch_snapshot(&context, 2, &SegmentFetchPolicy::default()).await.expect("map snapshot");
|
||||
|
||||
assert_eq!(snapshot.fetch_ref.resolved_origin_url, "https://cdn.example.net/live/redirected/init.mp4");
|
||||
assert_eq!(snapshot.fetch_ref.byte_range, Some(ParsedByteRange { length: 5, offset: 10 }));
|
||||
|
||||
@@ -43,9 +43,9 @@ mod headers;
|
||||
mod ids;
|
||||
mod lease;
|
||||
mod lifecycle;
|
||||
mod manager;
|
||||
mod manifest_commit;
|
||||
mod manifest_fetch;
|
||||
mod manager;
|
||||
mod map;
|
||||
mod map_fetcher;
|
||||
mod observability;
|
||||
@@ -55,8 +55,8 @@ mod playback;
|
||||
mod prefetch;
|
||||
mod qos;
|
||||
mod refresh;
|
||||
mod resource_fetch;
|
||||
mod renderer;
|
||||
mod resource_fetch;
|
||||
mod response;
|
||||
mod segment_fetcher;
|
||||
mod segment_repair;
|
||||
@@ -87,22 +87,18 @@ pub use self::{
|
||||
ids::{build_proxy_session_id, HlsSessionKey, ProxySessionId},
|
||||
lease::{
|
||||
new_hls_access_lease_id, HlsAccessLease, HlsAccessLeaseActivation, HlsAccessLeaseChannelUnavailableReason,
|
||||
HlsAccessLeaseId, HlsAccessLeaseIdleRelease, HlsAccessLeaseLifecycleSnapshot,
|
||||
HlsAccessLeasePendingDeadline, HlsAccessLeaseResponseFlag, HlsAccessLeaseSessionSnapshot,
|
||||
HlsAccessLeaseState, HlsAccessLeaseStore, HlsAccessLeaseTiming, HlsAccessLeaseTouch,
|
||||
HlsFreshManifestRequiredReason, HlsPlaybackFamilyKey,
|
||||
HlsAccessLeaseId, HlsAccessLeaseIdleRelease, HlsAccessLeaseLifecycleSnapshot, HlsAccessLeasePendingDeadline,
|
||||
HlsAccessLeaseResponseFlag, HlsAccessLeaseSessionSnapshot, HlsAccessLeaseState, HlsAccessLeaseStore,
|
||||
HlsAccessLeaseTiming, HlsAccessLeaseTouch, HlsFreshManifestRequiredReason, HlsPlaybackFamilyKey,
|
||||
},
|
||||
lifecycle::{HlsLifecycleEvent, HlsLifecycleEventKey, HlsLifecycleManager},
|
||||
manager::{exec_hls_lifecycle, HlsProxyManager},
|
||||
manifest_commit::{
|
||||
hls_cached_manifest_options_for_requirement, hls_committed_manifest_body_for_request,
|
||||
hls_manifest_commit_requirement, hls_should_wait_for_initial_manifest_commit, HlsCachedManifestOptions,
|
||||
HlsCommittedManifestBody,
|
||||
},
|
||||
manifest_fetch::{
|
||||
classify_origin_manifest_status, LiveHlsOriginEntry, OriginManifestFetchError, OriginManifestStatusClass,
|
||||
RetryPolicy,
|
||||
},
|
||||
manager::{exec_hls_lifecycle, HlsProxyManager},
|
||||
manifest_fetch::{classify_origin_manifest_status, LiveHlsOriginEntry, OriginManifestStatusClass, RetryPolicy},
|
||||
map::{MapCacheStatus, MapEntry, OriginMapFetchRef, OriginMapKey, ProxyMapId},
|
||||
map_fetcher::{HlsMapWorkerPool, MapFetchContext},
|
||||
observability::{
|
||||
@@ -130,21 +126,17 @@ pub use self::{
|
||||
cold_start_retry_after_seconds, maybe_trigger_origin_refresh, trigger_origin_refresh_sync,
|
||||
HlsManifestCommitRequirement, OriginRefreshRequest, OriginRefreshState,
|
||||
},
|
||||
resource_fetch::{
|
||||
build_hls_origin_resource_headers, build_hls_origin_resource_headers_with_client_range,
|
||||
classify_hls_resource_status, fetch_hls_origin_resource_response, log_hls_resource_attempt_started,
|
||||
log_hls_resource_attempt_succeeded, log_hls_resource_fetch_failed, log_hls_resource_retry_scheduled,
|
||||
log_hls_resource_timeout, retry_after_secs_from_ms, run_hls_origin_resource_retry_loop,
|
||||
run_hls_origin_resource_retry_loop_with_attempt_prepare, HlsOriginByteRangeExpectation,
|
||||
HlsOriginResourceAttemptCleanupFuture, HlsOriginResourceAttemptPrepareFuture, HlsOriginResourceClients,
|
||||
HlsOriginResourceCommitFuture, HlsOriginResourceFetchError, HlsOriginResourceFetchTarget,
|
||||
HlsResourceFetchAttempt, HlsResourceFetchKind, HlsResourceFetchLogContext, HlsResourceFetchLogStatus,
|
||||
HlsResourceFetchSource, HlsResourceStatusClass,
|
||||
},
|
||||
renderer::{
|
||||
renderer_candidate_window_proxy_seqs, HlsManifestRenderer, RenderError, RenderPolicy, RenderedManifest,
|
||||
RenderedManifestStoreOutcome, RenderedManifestStoreRejectReason,
|
||||
},
|
||||
resource_fetch::{
|
||||
build_hls_origin_resource_headers, build_hls_origin_resource_headers_with_client_range,
|
||||
retry_after_secs_from_ms, run_hls_origin_resource_retry_loop_with_attempt_prepare,
|
||||
HlsOriginByteRangeExpectation, HlsOriginResourceBodyDeadline, HlsOriginResourceClients,
|
||||
HlsOriginResourceFetchError, HlsOriginResourceFetchTarget, HlsResourceFetchAttempt, HlsResourceFetchKind,
|
||||
HlsResourceFetchSource,
|
||||
},
|
||||
response::{
|
||||
serve_hls_map_cache_outcome, serve_hls_map_cache_response, serve_hls_segment_cache_outcome,
|
||||
serve_hls_segment_cache_response, serve_hls_transient_object_cache_outcome,
|
||||
@@ -163,14 +155,12 @@ pub use self::{
|
||||
TransientPassthroughReason,
|
||||
},
|
||||
session_store::{
|
||||
HlsExpiredSessionMarker, HlsExpiredSessionReason, HlsSessionHandle, HlsSessionStore,
|
||||
HlsSessionStoreOutcome,
|
||||
HlsExpiredSessionMarker, HlsExpiredSessionReason, HlsSessionHandle, HlsSessionStore, HlsSessionStoreOutcome,
|
||||
},
|
||||
timeline::{
|
||||
default_content_type_for_segment_ext, CacheAccessState, OriginSegmentFetchRef, OriginSegmentKey,
|
||||
is_hls_provisioning_gap_segment, is_hls_provisioning_segment, SegmentCacheStatus, SegmentEntry,
|
||||
TimelineMapError, HLS_PROVISIONING_GAP_ORIGIN_EPOCH, HLS_PROVISIONING_ORIGIN_EPOCH,
|
||||
HLS_PROVISIONING_SEGMENT_DURATION_MS,
|
||||
default_content_type_for_segment_ext, is_hls_provisioning_gap_segment, is_hls_provisioning_segment,
|
||||
CacheAccessState, OriginSegmentFetchRef, OriginSegmentKey, SegmentCacheStatus, SegmentEntry, TimelineMapError,
|
||||
HLS_PROVISIONING_GAP_ORIGIN_EPOCH, HLS_PROVISIONING_ORIGIN_EPOCH, HLS_PROVISIONING_SEGMENT_DURATION_MS,
|
||||
HLS_PROVISIONING_TARGET_DURATION_SECS,
|
||||
},
|
||||
transient::{
|
||||
@@ -183,9 +173,15 @@ pub use self::{
|
||||
fetch_and_commit_hls_transient_origin_response_with_attempt_prepare,
|
||||
fetch_hls_transient_origin_response_with_attempt_prepare, hls_transient_object_fetch_failure,
|
||||
hls_transient_origin_response, hls_transient_resource_fetch_kind,
|
||||
is_hls_transient_full_object_cacheable_request, resolve_hls_transient_object_cache_action,
|
||||
HlsTransientCacheCommitContext, HlsTransientObjectCacheAction, HlsTransientObjectCacheResolution,
|
||||
HlsTransientObjectFetchFailure, HlsTransientObjectFetchFinalizer, HlsTransientOriginCacheFetchRequest,
|
||||
HlsTransientOriginFetchRequest, HlsTransientOriginIoGuard,
|
||||
is_hls_transient_full_object_cacheable_request, record_successful_transient_segment_fetch,
|
||||
record_temporary_transient_segment_fetch_failure, resolve_hls_transient_object_cache_action,
|
||||
HlsTransientCacheCommitContext, HlsTransientDecodedOriginResponse, HlsTransientDirectResponseContext,
|
||||
HlsTransientObjectCacheAction, HlsTransientObjectCacheResolution, HlsTransientObjectFetchFailure,
|
||||
HlsTransientObjectFetchFinalizer, HlsTransientOriginCacheFetchRequest, HlsTransientOriginFetchRequest,
|
||||
HlsTransientOriginIoGuard,
|
||||
},
|
||||
};
|
||||
pub(crate) use self::{
|
||||
manifest_fetch::MAX_HLS_MANIFEST_BYTES,
|
||||
observability::{log_hls_origin_content_coding, HlsOriginContentCodingObjectKind, HlsOriginContentCodingSource},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,80 @@
|
||||
use super::{HlsAccessLeaseId, HlsSessionKey, ProxySessionId};
|
||||
use crate::utils::content_coding::ContentCodingObservation;
|
||||
use log::debug;
|
||||
use sha2::{Digest, Sha256};
|
||||
use shared::utils::sanitize_sensitive_info;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Fixed HLS object classes allowed in content-coding diagnostics.
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub(crate) enum HlsOriginContentCodingObjectKind {
|
||||
Manifest,
|
||||
Segment,
|
||||
Map,
|
||||
Key,
|
||||
Part,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl HlsOriginContentCodingObjectKind {
|
||||
const fn as_log_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Manifest => "manifest",
|
||||
Self::Segment => "segment",
|
||||
Self::Map => "map",
|
||||
Self::Key => "key",
|
||||
Self::Part => "part",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed Tuliprox HLS stacks allowed in content-coding diagnostics.
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub(crate) enum HlsOriginContentCodingSource {
|
||||
Legacy,
|
||||
Shared,
|
||||
}
|
||||
|
||||
impl HlsOriginContentCodingSource {
|
||||
const fn as_log_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Legacy => "legacy",
|
||||
Self::Shared => "shared",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Logs a prepared origin-coding normalization using fixed or numeric fields only.
|
||||
pub(crate) fn log_hls_origin_content_coding(
|
||||
observation: ContentCodingObservation,
|
||||
object_kind: HlsOriginContentCodingObjectKind,
|
||||
range_requested: bool,
|
||||
source: HlsOriginContentCodingSource,
|
||||
) {
|
||||
debug!(
|
||||
"HLS origin content coding normalization prepared: {}",
|
||||
hls_origin_content_coding_log_fields(observation, object_kind, range_requested, source)
|
||||
);
|
||||
}
|
||||
|
||||
fn hls_origin_content_coding_log_fields(
|
||||
observation: ContentCodingObservation,
|
||||
object_kind: HlsOriginContentCodingObjectKind,
|
||||
range_requested: bool,
|
||||
source: HlsOriginContentCodingSource,
|
||||
) -> String {
|
||||
let content_length = observation.content_length.map_or_else(|| "unknown".to_string(), |value| value.to_string());
|
||||
format!(
|
||||
"object_kind={} content_encoding={} status={} requested_accept_encoding=identity decoded_to_identity=true content_length={} range_requested={} source={}",
|
||||
object_kind.as_log_value(),
|
||||
observation.content_encoding,
|
||||
observation.status.as_u16(),
|
||||
content_length,
|
||||
range_requested,
|
||||
source.as_log_value()
|
||||
)
|
||||
}
|
||||
|
||||
/// In-memory counters for the live HLS cache path.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct HlsCacheMetrics {
|
||||
@@ -120,7 +192,7 @@ pub fn safe_user_session_token(session_token: &str) -> String { short_hash(sessi
|
||||
|
||||
pub fn safe_proxy_session_id(proxy_session_id: &ProxySessionId) -> String { short_hash(&proxy_session_id.0) }
|
||||
|
||||
pub fn safe_origin_log_value(value: impl AsRef<str>) -> String { sanitize_sensitive_info(value.as_ref()).into_owned() }
|
||||
pub fn safe_origin_log_value(value: impl AsRef<str>) -> String { format!("origin#{}", short_hash(value.as_ref())) }
|
||||
|
||||
fn increment(counter: &AtomicU64, count: u64) { counter.fetch_add(count, Ordering::Relaxed); }
|
||||
|
||||
@@ -134,12 +206,20 @@ fn short_hash(value: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{safe_origin_log_value, safe_proxy_session_id, safe_session_key, HlsCacheMetrics};
|
||||
use crate::api::model::{HlsSessionKey, ProxySessionId};
|
||||
use super::{
|
||||
hls_origin_content_coding_log_fields, safe_origin_log_value, safe_proxy_session_id, safe_session_key,
|
||||
HlsCacheMetrics, HlsOriginContentCodingObjectKind, HlsOriginContentCodingSource,
|
||||
};
|
||||
use crate::{
|
||||
api::model::{HlsSessionKey, ProxySessionId},
|
||||
utils::content_coding::ContentCodingObservation,
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
#[test]
|
||||
fn safe_log_helpers_do_not_emit_credentials_or_full_proxy_session_id() {
|
||||
let sanitized = safe_origin_log_value("http://user:password@example.com/live/user/password/123.ts");
|
||||
assert!(sanitized.starts_with("origin#"));
|
||||
assert!(!sanitized.contains("user:password"));
|
||||
assert!(!sanitized.contains("/user/password/"));
|
||||
|
||||
@@ -171,4 +251,47 @@ mod tests {
|
||||
assert_eq!(snapshot.prefetch_queued, 2);
|
||||
assert_eq!(snapshot.prefetch_skipped, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_coding_log_fields_are_fixed_redacted_and_complete() {
|
||||
let fields = hls_origin_content_coding_log_fields(
|
||||
ContentCodingObservation {
|
||||
content_encoding: "multiple",
|
||||
status: StatusCode::OK,
|
||||
content_length: Some(321),
|
||||
},
|
||||
HlsOriginContentCodingObjectKind::Manifest,
|
||||
false,
|
||||
HlsOriginContentCodingSource::Legacy,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
fields,
|
||||
"object_kind=manifest content_encoding=multiple status=200 requested_accept_encoding=identity decoded_to_identity=true content_length=321 range_requested=false source=legacy"
|
||||
);
|
||||
for sensitive_marker in ["http", "?", "cookie", "manifest-body", "signed-token"] {
|
||||
assert!(!fields.contains(sensitive_marker));
|
||||
}
|
||||
|
||||
let unknown_length = hls_origin_content_coding_log_fields(
|
||||
ContentCodingObservation {
|
||||
content_encoding: "gzip",
|
||||
status: StatusCode::PARTIAL_CONTENT,
|
||||
content_length: None,
|
||||
},
|
||||
HlsOriginContentCodingObjectKind::Segment,
|
||||
true,
|
||||
HlsOriginContentCodingSource::Shared,
|
||||
);
|
||||
assert!(unknown_length.contains("content_length=unknown"));
|
||||
assert!(unknown_length.contains("range_requested=true source=shared"));
|
||||
|
||||
let part = hls_origin_content_coding_log_fields(
|
||||
ContentCodingObservation { content_encoding: "br", status: StatusCode::OK, content_length: Some(17) },
|
||||
HlsOriginContentCodingObjectKind::Part,
|
||||
false,
|
||||
HlsOriginContentCodingSource::Shared,
|
||||
);
|
||||
assert!(part.starts_with("object_kind=part content_encoding=br status=200 "));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@ use crate::{
|
||||
};
|
||||
use log::debug;
|
||||
use shared::utils::sanitize_sensitive_info;
|
||||
use std::{fmt, net::SocketAddr, sync::Arc};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::{
|
||||
fmt,
|
||||
net::SocketAddr,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::{
|
||||
sync::{Mutex, Notify},
|
||||
time::timeout,
|
||||
@@ -16,15 +20,26 @@ const HLS_ACCOUNT_OVERLAP_FALLBACK_TARGET_DURATION_MS: u64 = 15_000;
|
||||
const HLS_ORIGIN_ACCOUNT_IO_WAIT_RECHECK: Duration = Duration::from_millis(25);
|
||||
|
||||
/// Stable source metadata for one shared live-HLS session.
|
||||
///
|
||||
/// Session identity is derived only from `input_id` and the immutable input
|
||||
/// origin ID in `stream_ref`; target IDs, virtual IDs, and resolved origin URLs
|
||||
/// are deliberately excluded.
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct HlsOriginSource {
|
||||
/// Internal ID of the configured Tuliprox input.
|
||||
pub input_id: u16,
|
||||
/// Input name used to resolve the current origin account or alias.
|
||||
pub input_name: Arc<str>,
|
||||
/// Exact, non-empty origin/provider stream ID captured before target mapping.
|
||||
pub stream_ref: String,
|
||||
pub source_kind: HlsOriginSourceKind,
|
||||
}
|
||||
|
||||
impl HlsOriginSource {
|
||||
/// Creates source metadata from an immutable input-stream identity.
|
||||
///
|
||||
/// `stream_ref` must be the origin/provider ID, never a target-specific or
|
||||
/// virtual ID.
|
||||
pub fn new(
|
||||
input_id: u16,
|
||||
input_name: Arc<str>,
|
||||
@@ -129,11 +144,7 @@ impl HlsAccountOverlapTiming {
|
||||
}
|
||||
|
||||
pub fn reservation_ttl_secs(self) -> u64 {
|
||||
self.hard_active_window_ms
|
||||
.saturating_add(self.soft_active_window_ms)
|
||||
.saturating_add(999)
|
||||
/ 1_000
|
||||
+ 1
|
||||
self.hard_active_window_ms.saturating_add(self.soft_active_window_ms).saturating_add(999) / 1_000 + 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,9 +627,7 @@ async fn reserve_hls_origin_account_io_slot(
|
||||
if now >= deadline {
|
||||
return Err(HlsBoundAccountAcquireErrorKind::WaitTimedOut);
|
||||
}
|
||||
let wait_for = deadline
|
||||
.saturating_duration_since(now)
|
||||
.min(HLS_ORIGIN_ACCOUNT_IO_WAIT_RECHECK);
|
||||
let wait_for = deadline.saturating_duration_since(now).min(HLS_ORIGIN_ACCOUNT_IO_WAIT_RECHECK);
|
||||
tokio::select! {
|
||||
() = notify.notified() => {}
|
||||
() = tokio::time::sleep(wait_for) => {}
|
||||
@@ -846,6 +855,15 @@ mod tests {
|
||||
assert_eq!(direct.session_key().stable_value(), "input:7|hls|80510");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_source_preserves_alphanumeric_input_stream_id() {
|
||||
let source =
|
||||
HlsOriginSource::new(7, Arc::from("input"), "m3u-channel_A42", HlsOriginSourceKind::M3uMediaPlaylist);
|
||||
|
||||
assert_eq!(source.stream_ref, "m3u-channel_A42");
|
||||
assert_eq!(source.session_key().stable_value(), "input:7|hls|m3u-channel_A42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_overlap_timing_uses_target_duration_or_fallback() {
|
||||
let timing = HlsAccountOverlapTiming::from_target_duration_secs(Some(12));
|
||||
|
||||
@@ -222,8 +222,7 @@ impl HlsSession {
|
||||
return report;
|
||||
}
|
||||
|
||||
let tail_index =
|
||||
known_sequences.len().saturating_sub(1).saturating_sub(self.initial_prefetch_gap_segments);
|
||||
let tail_index = known_sequences.len().saturating_sub(1).saturating_sub(self.initial_prefetch_gap_segments);
|
||||
let render_window_len = known_sequences.len().min(6).min(tail_index.saturating_add(1));
|
||||
let render_start_index = tail_index.saturating_add(1).saturating_sub(render_window_len);
|
||||
|
||||
@@ -260,13 +259,7 @@ impl HlsSession {
|
||||
pub fn configure_segment_prefetch_queue(&mut self, max_prefetch_depth: usize) {
|
||||
for proxy_seq in self.segment_prefetch_queue.set_max_prefetch_depth(max_prefetch_depth) {
|
||||
if let Some(entry) = self.segments.get_mut(&proxy_seq) {
|
||||
if matches!(
|
||||
entry.status,
|
||||
SegmentCacheStatus::Queued {
|
||||
priority: SegmentFetchPriority::Prefetch,
|
||||
..
|
||||
}
|
||||
) {
|
||||
if matches!(entry.status, SegmentCacheStatus::Queued { priority: SegmentFetchPriority::Prefetch, .. }) {
|
||||
entry.status = SegmentCacheStatus::Discovered;
|
||||
}
|
||||
}
|
||||
@@ -395,13 +388,7 @@ mod tests {
|
||||
|
||||
session.configure_segment_prefetch_queue(1);
|
||||
|
||||
assert!(matches!(
|
||||
session.segments.get(&4).expect("trimmed segment").status,
|
||||
SegmentCacheStatus::Discovered
|
||||
));
|
||||
assert!(matches!(
|
||||
session.segments.get(&5).expect("trimmed segment").status,
|
||||
SegmentCacheStatus::Discovered
|
||||
));
|
||||
assert!(matches!(session.segments.get(&4).expect("trimmed segment").status, SegmentCacheStatus::Discovered));
|
||||
assert!(matches!(session.segments.get(&5).expect("trimmed segment").status, SegmentCacheStatus::Discovered));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
use super::{HlsAccessLeaseId, ProxySessionId};
|
||||
use crate::{
|
||||
api::model::StreamMeterHandle,
|
||||
model::AppConfig,
|
||||
};
|
||||
use crate::{api::model::StreamMeterHandle, model::AppConfig};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
@@ -18,9 +15,7 @@ impl HlsQosRuntimeConfig {
|
||||
let Some(reverse_proxy) = config.reverse_proxy.as_ref() else {
|
||||
return Self::default();
|
||||
};
|
||||
Self {
|
||||
live_metering_enabled: reverse_proxy.stream.as_ref().is_some_and(|stream| stream.metrics_enabled),
|
||||
}
|
||||
Self { live_metering_enabled: reverse_proxy.stream.as_ref().is_some_and(|stream| stream.metrics_enabled) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,13 +39,12 @@ struct HlsAccessLeaseQosState {
|
||||
}
|
||||
|
||||
impl HlsAccessLeaseQosState {
|
||||
fn registration(&self, emit_connect_record: bool, register_meter: Option<Arc<StreamMeterHandle>>) -> HlsQosRegistration {
|
||||
HlsQosRegistration {
|
||||
meter_uid: self.meter_uid,
|
||||
meter: self.meter.clone(),
|
||||
register_meter,
|
||||
emit_connect_record,
|
||||
}
|
||||
fn registration(
|
||||
&self,
|
||||
emit_connect_record: bool,
|
||||
register_meter: Option<Arc<StreamMeterHandle>>,
|
||||
) -> HlsQosRegistration {
|
||||
HlsQosRegistration { meter_uid: self.meter_uid, meter: self.meter.clone(), register_meter, emit_connect_record }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,11 +68,7 @@ impl HlsQosRegistry {
|
||||
|
||||
let (meter_uid, meter) = meter_init.map_or((0, None), |init| (init.meter_uid, Some(init.meter)));
|
||||
let register_meter = meter.clone();
|
||||
let state = HlsAccessLeaseQosState {
|
||||
proxy_session_id: proxy_session_id.clone(),
|
||||
meter_uid,
|
||||
meter,
|
||||
};
|
||||
let state = HlsAccessLeaseQosState { proxy_session_id: proxy_session_id.clone(), meter_uid, meter };
|
||||
let registration = state.registration(true, register_meter);
|
||||
states.insert(lease_id.clone(), state);
|
||||
registration
|
||||
@@ -94,10 +84,7 @@ impl HlsQosRegistry {
|
||||
|
||||
pub async fn remove_access_leases(&self, lease_ids: &[HlsAccessLeaseId]) -> usize {
|
||||
let mut states = self.states.write().await;
|
||||
lease_ids
|
||||
.iter()
|
||||
.filter(|lease_id| states.remove(*lease_id).is_some())
|
||||
.count()
|
||||
lease_ids.iter().filter(|lease_id| states.remove(*lease_id).is_some()).count()
|
||||
}
|
||||
|
||||
pub async fn remove_proxy_session_state(&self, proxy_session_id: &ProxySessionId) -> usize {
|
||||
@@ -122,8 +109,7 @@ impl HlsQosRegistry {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{HlsQosMeterInit, HlsQosRegistry};
|
||||
use crate::api::model::{EventManager, StreamMeterHandle};
|
||||
use crate::api::model::{HlsAccessLeaseId, ProxySessionId};
|
||||
use crate::api::model::{EventManager, HlsAccessLeaseId, ProxySessionId, StreamMeterHandle};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
begin_hls_origin_account_io, finish_hls_origin_account_io, is_hls_provisioning_gap_segment,
|
||||
is_hls_provisioning_segment,
|
||||
begin_hls_origin_account_io, finish_hls_origin_account_io, hls_origin_headers_with_provider_session,
|
||||
is_hls_provisioning_gap_segment, is_hls_provisioning_segment,
|
||||
manifest_fetch::{
|
||||
commit_error_to_fetch_error, evaluate_manifest_origin_quality_with_mode, fetch_hls_origin_manifest_request,
|
||||
fetched_effective_manifest_host, log_hls_manifest_initial_selected, manifest_host_switch_failure_threshold,
|
||||
@@ -12,12 +12,11 @@ use super::{
|
||||
OriginManifestFetchError, RetryPolicy,
|
||||
},
|
||||
safe_hls_access_lease_id, safe_origin_log_value, safe_proxy_session_id, safe_session_key,
|
||||
hls_origin_headers_with_provider_session, sanitized_hls_origin_headers,
|
||||
HlsAccessLeaseChannelUnavailableReason, HlsAccessLeaseId, HlsFreshManifestRequiredReason, HlsManifestRenderer,
|
||||
HlsManifestTemporaryFailureKind, HlsManifestTemporaryFailureTransition, HlsMapWorkerPool, HlsOriginIoContext,
|
||||
HlsOriginWorkClass, HlsProxyManager, HlsSegmentCache, HlsSegmentRepairManager, HlsSegmentWorkerPool, HlsSessionHandle,
|
||||
HlsSessionMode, MapFetchContext, RenderedManifestStoreOutcome, RenderedManifestStoreRejectReason,
|
||||
SegmentFetchContext, TransientPassthroughReason,
|
||||
sanitized_hls_origin_headers, HlsAccessLeaseChannelUnavailableReason, HlsAccessLeaseId,
|
||||
HlsFreshManifestRequiredReason, HlsManifestRenderer, HlsManifestTemporaryFailureKind,
|
||||
HlsManifestTemporaryFailureTransition, HlsMapWorkerPool, HlsOriginIoContext, HlsOriginWorkClass, HlsProxyManager,
|
||||
HlsSegmentCache, HlsSegmentRepairManager, HlsSegmentWorkerPool, HlsSessionHandle, HlsSessionMode, MapFetchContext,
|
||||
RenderedManifestStoreOutcome, RenderedManifestStoreRejectReason, SegmentFetchContext, TransientPassthroughReason,
|
||||
};
|
||||
use crate::{
|
||||
model::{AppConfig, HlsManifestRecoveryBurstConfig, ReverseProxyDisabledHeaderConfig, StripConfig},
|
||||
@@ -38,8 +37,7 @@ use crate::{
|
||||
use axum::http::HeaderMap;
|
||||
use log::{debug, info, warn};
|
||||
use reqwest::Client;
|
||||
use shared::model::HlsStripMode;
|
||||
use shared::utils::sanitize_sensitive_info;
|
||||
use shared::{model::HlsStripMode, utils::sanitize_sensitive_info};
|
||||
use std::sync::Arc;
|
||||
use url::Url;
|
||||
|
||||
@@ -404,7 +402,7 @@ async fn refresh_and_commit(mut request: OriginRefreshRequest, fetch_started_at_
|
||||
"HLS origin manifest refresh completed: session={} proxy_session_id={} result=failed error={}",
|
||||
safe_session_key(&session.key),
|
||||
safe_proxy_session_id(&session.proxy_session_id),
|
||||
safe_origin_log_value(format!("{err:?}"))
|
||||
err.log_label()
|
||||
);
|
||||
(
|
||||
false,
|
||||
@@ -513,11 +511,7 @@ async fn mark_fresh_manifest_commit_failed_access_leases(
|
||||
let marked = origin_io
|
||||
.app_state
|
||||
.hls_proxy
|
||||
.mark_access_leases_channel_unavailable_for_session(
|
||||
&proxy_session_id,
|
||||
failed_at_ms,
|
||||
unavailable_reason,
|
||||
)
|
||||
.mark_access_leases_channel_unavailable_for_session(&proxy_session_id, failed_at_ms, unavailable_reason)
|
||||
.await;
|
||||
if marked > 0 {
|
||||
debug!(
|
||||
@@ -574,19 +568,33 @@ fn manifest_temporary_failure_kind(err: &OriginManifestFetchError) -> Option<Hls
|
||||
| OriginManifestFetchError::NonRetryableStatus(_)
|
||||
| OriginManifestFetchError::Request(_)
|
||||
| OriginManifestFetchError::Redirect(_)
|
||||
| OriginManifestFetchError::ProviderUnavailable(_) => None,
|
||||
| OriginManifestFetchError::ProviderUnavailable(_)
|
||||
| OriginManifestFetchError::ContentCoding(_)
|
||||
| OriginManifestFetchError::ContentDecoding { .. }
|
||||
| OriginManifestFetchError::DecodedBodyLimitExceeded { .. }
|
||||
| OriginManifestFetchError::InvalidUtf8 { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_hard_fetch_error(err: &OriginManifestFetchError) -> bool {
|
||||
match err {
|
||||
OriginManifestFetchError::PermanentStatus(_) | OriginManifestFetchError::NonRetryableStatus(_) => true,
|
||||
OriginManifestFetchError::PermanentStatus(_)
|
||||
| OriginManifestFetchError::NonRetryableStatus(_)
|
||||
| OriginManifestFetchError::ContentCoding(
|
||||
crate::utils::content_coding::ContentCodingError::InvalidHeader
|
||||
| crate::utils::content_coding::ContentCodingError::Unsupported(_)
|
||||
| crate::utils::content_coding::ContentCodingError::EncodedPartialContent,
|
||||
)
|
||||
| OriginManifestFetchError::DecodedBodyLimitExceeded { .. }
|
||||
| OriginManifestFetchError::InvalidUtf8 { .. } => true,
|
||||
OriginManifestFetchError::ProviderUnavailable(kind) => !kind.is_retryable_resource_failure(),
|
||||
OriginManifestFetchError::RetryableStatus(_, _)
|
||||
| OriginManifestFetchError::RetryExhausted
|
||||
| OriginManifestFetchError::Request(_)
|
||||
| OriginManifestFetchError::Redirect(_)
|
||||
| OriginManifestFetchError::Timeout => false,
|
||||
| OriginManifestFetchError::Timeout
|
||||
| OriginManifestFetchError::ContentCoding(crate::utils::content_coding::ContentCodingError::PrefixRead(_))
|
||||
| OriginManifestFetchError::ContentDecoding { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,7 +669,7 @@ async fn finish_refresh_failure(request: &OriginRefreshRequest, err: OriginManif
|
||||
"HLS origin manifest refresh completed: session={} proxy_session_id={} result=failed error={}",
|
||||
safe_session_key(&session.key),
|
||||
safe_proxy_session_id(&session.proxy_session_id),
|
||||
safe_origin_log_value(format!("{err:?}"))
|
||||
err.log_label()
|
||||
);
|
||||
(request.manifest_commit_requirement.fresh_reason(), temporary_manifest_failure_reason)
|
||||
};
|
||||
@@ -731,9 +739,7 @@ async fn fetch_and_commit_manifest_with_policy(
|
||||
) -> Result<CommittedOriginManifest, OriginManifestFetchError> {
|
||||
let fetch_context = manifest_fetch_context(request);
|
||||
let fetched =
|
||||
fetch_hls_origin_manifest_request(HlsOriginManifestFetchRequest::initial_global_policy(&fetch_context))
|
||||
.await?
|
||||
.with_attempts(1);
|
||||
fetch_hls_origin_manifest_request(HlsOriginManifestFetchRequest::initial_global_policy(&fetch_context)).await?;
|
||||
let acceptance_mode = request.manifest_commit_requirement.acceptance_mode();
|
||||
let selected_report =
|
||||
score_hls_manifest_candidate_for_selection_log(&fetch_context, &fetched, acceptance_mode).await;
|
||||
@@ -1057,10 +1063,7 @@ fn evaluate_manifest_acceptance(
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_manifest_handoff_discontinuity_if_needed(
|
||||
session: &mut super::HlsSession,
|
||||
quality: &HlsManifestOriginQuality,
|
||||
) {
|
||||
fn mark_manifest_handoff_discontinuity_if_needed(session: &mut super::HlsSession, quality: &HlsManifestOriginQuality) {
|
||||
if !quality.requires_handoff_discontinuity {
|
||||
return;
|
||||
}
|
||||
@@ -1264,7 +1267,8 @@ fn commit_normal_manifest(
|
||||
let provisioning_handoff = session.pending_handoff_discontinuity_sequence.is_some()
|
||||
&& session.segments.values().any(is_hls_provisioning_segment);
|
||||
let segment_durations = manifest.segments.iter().map(|segment| segment.duration_ms).collect::<Vec<_>>();
|
||||
session.initial_prefetch_gap_segments = initial_hls_strip_segments_for_durations(&request.strip, &segment_durations);
|
||||
session.initial_prefetch_gap_segments =
|
||||
initial_hls_strip_segments_for_durations(&request.strip, &segment_durations);
|
||||
session
|
||||
.apply_origin_manifest(manifest)
|
||||
.map_err(|err| HlsManifestCommitError::TimelineRejected { reason: HlsManifestRejectLogReason::from(err) })?;
|
||||
@@ -1511,41 +1515,49 @@ fn current_time_millis() -> u64 { chrono::Utc::now().timestamp_millis().try_into
|
||||
mod tests {
|
||||
use super::{
|
||||
super::manifest_fetch::{
|
||||
classify_origin_manifest_status, hls_manifest_redirect_host,
|
||||
classify_origin_manifest_status, fetch_hls_origin_manifest_request, hls_manifest_redirect_host,
|
||||
manifest_host_switch_failure_threshold_for_strip_segments, origin_highwater_policy_limit,
|
||||
refresh_from_live_hls_entrypoint_with_retries, resolved_hls_manifest_request_url_from_input,
|
||||
retry_after_delay_ms, retry_hls_origin_manifest_recovery_chain,
|
||||
score_hls_manifest_recovery_candidate as score_manifest_recovery_candidate, FetchedOriginManifest,
|
||||
HlsManifestCommitAcceptanceMode, HlsManifestCommitError, HlsManifestOriginQualityScore,
|
||||
HlsManifestRejectLogReason, HlsManifestSequenceRelation, LiveHlsOriginEntry,
|
||||
ManifestRecoverySelectionLogPhase, OriginManifestFetchError, OriginManifestStatusClass, RetryPolicy,
|
||||
HlsManifestRejectLogReason, HlsManifestSequenceRelation, HlsOriginManifestFetchContext,
|
||||
HlsOriginManifestFetchRequest, LiveHlsOriginEntry, ManifestRecoverySelectionLogPhase,
|
||||
OriginManifestFetchError, OriginManifestStatusClass, RetryPolicy,
|
||||
},
|
||||
build_manifest_refresh_timing, commit_fetched_manifest, compute_origin_refresh_interval_ms,
|
||||
fetched_effective_manifest_host, format_millis_as_seconds, format_optional_millis_as_seconds,
|
||||
manifest_fetch_context, manifest_hard_fetch_error, manifest_progress_from_highwater, mark_origin_refresh_started,
|
||||
manifest_temporary_failure_kind, record_pinned_host_recovery_chain_failed, request_error_indicates_timeout,
|
||||
transient_reason_log_fields, HlsManifestAcceptanceDecision, HlsManifestCommitRequirement, HlsManifestProgress,
|
||||
trigger_origin_refresh_sync, OriginRefreshRequest, OriginRefreshState,
|
||||
manifest_fetch_context, manifest_hard_fetch_error, manifest_progress_from_highwater,
|
||||
manifest_temporary_failure_kind, mark_origin_refresh_started, record_pinned_host_recovery_chain_failed,
|
||||
request_error_indicates_timeout, transient_reason_log_fields, trigger_origin_refresh_sync,
|
||||
HlsManifestAcceptanceDecision, HlsManifestCommitRequirement, HlsManifestProgress, OriginRefreshRequest,
|
||||
OriginRefreshState,
|
||||
};
|
||||
use crate::{
|
||||
api::model::{
|
||||
maybe_trigger_origin_refresh, HlsAccessLease, HlsAccessLeaseId, HlsAccessLeasePendingDeadline,
|
||||
HlsFreshManifestRequiredReason, HlsMapWorkerPool, HlsOriginAccountBinding, HlsPlaybackFamilyKey,
|
||||
HlsProxyManager, HlsSegmentCache, HlsSegmentRepairManager, HlsSegmentWorkerPool, HlsSession,
|
||||
HlsSessionKey, HlsSessionMode, HlsBoundAccountAcquireErrorKind, TimelineMapError,
|
||||
TransientPassthroughReason,
|
||||
HlsBoundAccountAcquireErrorKind, HlsFreshManifestRequiredReason, HlsMapWorkerPool, HlsOriginAccountBinding,
|
||||
HlsPlaybackFamilyKey, HlsProxyManager, HlsSegmentCache, HlsSegmentRepairManager, HlsSegmentWorkerPool,
|
||||
HlsSession, HlsSessionKey, HlsSessionMode, TimelineMapError, TransientPassthroughReason,
|
||||
},
|
||||
model::{
|
||||
AppConfig, Config, ConfigProvider, HlsManifestRecoveryBurstConfig,
|
||||
HlsSegmentRepairConfig, ReverseProxyDisabledHeaderConfig, SourcesConfig, StripConfig,
|
||||
AppConfig, Config, ConfigProvider, HlsManifestRecoveryBurstConfig, HlsSegmentRepairConfig,
|
||||
ReverseProxyDisabledHeaderConfig, SourcesConfig, StripConfig,
|
||||
},
|
||||
utils::content_coding::{ContentCoding, ContentCodingError},
|
||||
};
|
||||
use arc_swap::{ArcSwap, ArcSwapOption};
|
||||
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use shared::model::{ConfigPaths, ConfigProviderDto, HlsManifestRecoveryBurstLevel, HlsSegmentRepairMode, HlsStripMode, ProviderUrlSelectionPolicy};
|
||||
use std::sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
use shared::model::{
|
||||
ConfigPaths, ConfigProviderDto, HlsManifestRecoveryBurstLevel, HlsSegmentRepairMode, HlsStripMode,
|
||||
ProviderUrlSelectionPolicy,
|
||||
};
|
||||
use std::{
|
||||
io,
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
},
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
@@ -1736,6 +1748,72 @@ mod tests {
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_content_failures_have_explicit_hard_and_temporary_classification() {
|
||||
let errors = vec![
|
||||
(
|
||||
"invalid content-coding header",
|
||||
OriginManifestFetchError::ContentCoding(ContentCodingError::InvalidHeader),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"unsupported content coding",
|
||||
OriginManifestFetchError::ContentCoding(ContentCodingError::Unsupported("unknown".to_string())),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"encoded partial content",
|
||||
OriginManifestFetchError::ContentCoding(ContentCodingError::EncodedPartialContent),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"content-coding prefix read",
|
||||
OriginManifestFetchError::ContentCoding(ContentCodingError::PrefixRead(io::Error::other(
|
||||
"prefix read failed",
|
||||
))),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"gzip decoding",
|
||||
OriginManifestFetchError::ContentDecoding { coding: ContentCoding::Gzip },
|
||||
false,
|
||||
),
|
||||
(
|
||||
"deflate decoding",
|
||||
OriginManifestFetchError::ContentDecoding { coding: ContentCoding::Deflate },
|
||||
false,
|
||||
),
|
||||
(
|
||||
"brotli decoding",
|
||||
OriginManifestFetchError::ContentDecoding { coding: ContentCoding::Brotli },
|
||||
false,
|
||||
),
|
||||
(
|
||||
"zstandard decoding",
|
||||
OriginManifestFetchError::ContentDecoding { coding: ContentCoding::Zstd },
|
||||
false,
|
||||
),
|
||||
(
|
||||
"decoded body limit",
|
||||
OriginManifestFetchError::DecodedBodyLimitExceeded { limit: 1024 },
|
||||
true,
|
||||
),
|
||||
(
|
||||
"invalid utf-8",
|
||||
OriginManifestFetchError::InvalidUtf8 { valid_up_to: 7, error_len: Some(1) },
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
for (label, error, expected_hard) in errors {
|
||||
assert_eq!(manifest_hard_fetch_error(&error), expected_hard, "unexpected hard classification: {label}");
|
||||
assert!(
|
||||
manifest_temporary_failure_kind(&error).is_none(),
|
||||
"content failure must not affect the temporary host-switch counter: {label}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_error_timeout_detection_matches_global_helper_wording() {
|
||||
assert!(request_error_indicates_timeout("Request timed out and no retries left"));
|
||||
@@ -2125,9 +2203,8 @@ mod tests {
|
||||
let session = test_session();
|
||||
session.write().await.origin_refresh.next_fetch_allowed_at_ms = 10_000;
|
||||
let mut request = test_origin_refresh_request(Arc::clone(&session));
|
||||
request.manifest_commit_requirement = HlsManifestCommitRequirement::FreshCommitRequired {
|
||||
reason: HlsFreshManifestRequiredReason::ColdStart,
|
||||
};
|
||||
request.manifest_commit_requirement =
|
||||
HlsManifestCommitRequirement::FreshCommitRequired { reason: HlsFreshManifestRequiredReason::ColdStart };
|
||||
|
||||
assert!(mark_origin_refresh_started(&mut request, 1_000).await);
|
||||
assert!(session.read().await.origin_refresh.in_flight);
|
||||
@@ -2560,6 +2637,7 @@ mod tests {
|
||||
struct TestOriginServer {
|
||||
base_url: String,
|
||||
requests: Arc<Mutex<Vec<String>>>,
|
||||
raw_requests: Arc<Mutex<Vec<String>>>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
@@ -2574,12 +2652,15 @@ mod tests {
|
||||
let addr = listener.local_addr().expect("local addr");
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let requests_for_task = Arc::clone(&requests);
|
||||
let raw_requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let raw_requests_for_task = Arc::clone(&raw_requests);
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut socket, _)) = listener.accept().await else {
|
||||
break;
|
||||
};
|
||||
let requests = Arc::clone(&requests_for_task);
|
||||
let raw_requests = Arc::clone(&raw_requests_for_task);
|
||||
let handler = Arc::clone(&handler);
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0_u8; 4096];
|
||||
@@ -2599,7 +2680,7 @@ mod tests {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let request = String::from_utf8_lossy(&buf[..used]);
|
||||
let request = String::from_utf8_lossy(&buf[..used]).into_owned();
|
||||
let path = request
|
||||
.lines()
|
||||
.next()
|
||||
@@ -2607,6 +2688,7 @@ mod tests {
|
||||
.unwrap_or("/")
|
||||
.to_string();
|
||||
requests.lock().await.push(path.clone());
|
||||
raw_requests.lock().await.push(request);
|
||||
let (status, headers, body) = handler(path);
|
||||
let reason = match status {
|
||||
200 => "OK",
|
||||
@@ -2632,7 +2714,14 @@ mod tests {
|
||||
});
|
||||
}
|
||||
});
|
||||
TestOriginServer { base_url: format!("http://{addr}"), requests, task }
|
||||
TestOriginServer { base_url: format!("http://{addr}"), requests, raw_requests, task }
|
||||
}
|
||||
|
||||
fn request_header_value<'a>(request: &'a str, expected_name: &str) -> Option<&'a str> {
|
||||
request.lines().find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case(expected_name).then_some(value.trim())
|
||||
})
|
||||
}
|
||||
|
||||
fn no_delay_policy() -> RetryPolicy { RetryPolicy { delays_ms: [0, 0, 0, 0, 0], jitter_max_ms: 0 } }
|
||||
@@ -2720,6 +2809,281 @@ mod tests {
|
||||
|
||||
fn manifest_body() -> String { "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXTINF:4.0,\nseg.ts\n".to_string() }
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_initial_manifest_decoder_failure_retries_until_success() {
|
||||
let hits = Arc::new(AtomicUsize::new(0));
|
||||
let hits_for_handler = Arc::clone(&hits);
|
||||
let server = spawn_test_origin(Arc::new(move |_path| {
|
||||
if hits_for_handler.fetch_add(1, Ordering::SeqCst) < 2 {
|
||||
return (200, vec![("Content-Encoding", "gzip".to_string())], "corrupt-gzip".to_string());
|
||||
}
|
||||
(200, Vec::new(), manifest_body())
|
||||
}))
|
||||
.await;
|
||||
let origin_entry =
|
||||
LiveHlsOriginEntry::parse(&format!("{}/live/user/pass/12345.m3u8", server.base_url)).expect("entry url");
|
||||
let context = HlsOriginManifestFetchContext {
|
||||
app_config: test_app_config(),
|
||||
session: test_session(),
|
||||
origin_entry,
|
||||
headers: HeaderMap::new(),
|
||||
client: reqwest::Client::new(),
|
||||
no_redirect_client: reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("no-redirect client"),
|
||||
use_manual_redirects: false,
|
||||
origin_manifest_timeout_ms: 2_000,
|
||||
manifest_recovery_burst: HlsManifestRecoveryBurstConfig::default(),
|
||||
retry_policy: no_delay_policy(),
|
||||
};
|
||||
|
||||
let fetched = fetch_hls_origin_manifest_request(HlsOriginManifestFetchRequest::initial_global_policy(&context))
|
||||
.await
|
||||
.expect("shared initial manifest should retry decoder failures");
|
||||
|
||||
assert_eq!(fetched.body, manifest_body());
|
||||
assert_eq!(fetched.attempts, 3);
|
||||
assert_eq!(server.requests.lock().await.len(), 3);
|
||||
assert!(server
|
||||
.raw_requests
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.all(|request| request_header_value(request, "accept-encoding") == Some("identity")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_initial_manifest_waits_for_next_attempt_base_delay() {
|
||||
let hits = Arc::new(AtomicUsize::new(0));
|
||||
let hits_for_handler = Arc::clone(&hits);
|
||||
let server = spawn_test_origin(Arc::new(move |_path| {
|
||||
if hits_for_handler.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
return (200, vec![("Content-Encoding", "gzip".to_string())], "corrupt-gzip".to_string());
|
||||
}
|
||||
(200, Vec::new(), manifest_body())
|
||||
}))
|
||||
.await;
|
||||
let origin_entry =
|
||||
LiveHlsOriginEntry::parse(&format!("{}/live/user/pass/12345.m3u8", server.base_url)).expect("entry url");
|
||||
let context = HlsOriginManifestFetchContext {
|
||||
app_config: test_app_config(),
|
||||
session: test_session(),
|
||||
origin_entry,
|
||||
headers: HeaderMap::new(),
|
||||
client: reqwest::Client::new(),
|
||||
no_redirect_client: reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("no-redirect client"),
|
||||
use_manual_redirects: false,
|
||||
origin_manifest_timeout_ms: 2_000,
|
||||
manifest_recovery_burst: HlsManifestRecoveryBurstConfig::default(),
|
||||
retry_policy: RetryPolicy { delays_ms: [0, 100, 250, 500, 750], jitter_max_ms: 0 },
|
||||
};
|
||||
let started_at = std::time::Instant::now();
|
||||
|
||||
let fetched = fetch_hls_origin_manifest_request(HlsOriginManifestFetchRequest::initial_global_policy(&context))
|
||||
.await
|
||||
.expect("second logical attempt should succeed after its base delay");
|
||||
|
||||
assert_eq!(fetched.attempts, 2);
|
||||
assert_eq!(server.requests.lock().await.len(), 2);
|
||||
assert!(started_at.elapsed() >= std::time::Duration::from_millis(100));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_initial_manifest_automatic_cross_origin_redirect_keeps_identity_and_scrubs_credentials() {
|
||||
let target = spawn_test_origin(Arc::new(|_path| (200, Vec::new(), manifest_body()))).await;
|
||||
let target_url = format!("{}/final/manifest.m3u8", target.base_url);
|
||||
let expected_target_url = target_url.clone();
|
||||
let redirect =
|
||||
spawn_test_origin(Arc::new(move |_path| (302, vec![("Location", target_url.clone())], String::new())))
|
||||
.await;
|
||||
let origin_entry =
|
||||
LiveHlsOriginEntry::parse(&format!("{}/live/user/pass/12345.m3u8", redirect.base_url)).expect("entry url");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br"));
|
||||
headers.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer origin-secret"));
|
||||
headers.insert(header::COOKIE, HeaderValue::from_static("sid=origin-secret"));
|
||||
let context = HlsOriginManifestFetchContext {
|
||||
app_config: test_app_config(),
|
||||
session: test_session(),
|
||||
origin_entry,
|
||||
headers,
|
||||
client: reqwest::Client::builder().no_proxy().build().expect("client"),
|
||||
no_redirect_client: reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("no-redirect client"),
|
||||
use_manual_redirects: false,
|
||||
origin_manifest_timeout_ms: 2_000,
|
||||
manifest_recovery_burst: HlsManifestRecoveryBurstConfig::default(),
|
||||
retry_policy: no_delay_policy(),
|
||||
};
|
||||
|
||||
let fetched = fetch_hls_origin_manifest_request(HlsOriginManifestFetchRequest::initial_global_policy(&context))
|
||||
.await
|
||||
.expect("automatic redirect should return the decoded manifest");
|
||||
|
||||
assert_eq!(fetched.body, manifest_body());
|
||||
assert_eq!(fetched.attempts, 1);
|
||||
assert_eq!(fetched.final_manifest_url, expected_target_url);
|
||||
let redirect_requests = redirect.raw_requests.lock().await;
|
||||
let target_requests = target.raw_requests.lock().await;
|
||||
assert_eq!(redirect_requests.len(), 1);
|
||||
assert_eq!(target_requests.len(), 1);
|
||||
assert_eq!(request_header_value(&redirect_requests[0], "accept-encoding"), Some("identity"));
|
||||
assert_eq!(request_header_value(&target_requests[0], "accept-encoding"), Some("identity"));
|
||||
assert_eq!(request_header_value(&redirect_requests[0], "authorization"), Some("Bearer origin-secret"));
|
||||
assert_eq!(request_header_value(&redirect_requests[0], "cookie"), Some("sid=origin-secret"));
|
||||
assert!(request_header_value(&target_requests[0], "authorization").is_none());
|
||||
assert!(request_header_value(&target_requests[0], "cookie").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_initial_manifest_budget_covers_status_decoder_redirect_and_success() {
|
||||
let target_hits = Arc::new(AtomicUsize::new(0));
|
||||
let target_hits_for_handler = Arc::clone(&target_hits);
|
||||
let server = spawn_test_origin(Arc::new(move |path| {
|
||||
if path == "/live/user/pass/12345.m3u8" {
|
||||
return (302, vec![("Location", "/live/play/once/12345".to_string())], String::new());
|
||||
}
|
||||
if path == "/live/play/once/12345" {
|
||||
return match target_hits_for_handler.fetch_add(1, Ordering::SeqCst) {
|
||||
0 => (500, Vec::new(), "temporary".to_string()),
|
||||
1 => (200, vec![("Content-Encoding", "gzip".to_string())], "corrupt-gzip".to_string()),
|
||||
_ => (200, Vec::new(), manifest_body()),
|
||||
};
|
||||
}
|
||||
(404, Vec::new(), String::new())
|
||||
}))
|
||||
.await;
|
||||
let origin_entry =
|
||||
LiveHlsOriginEntry::parse(&format!("{}/live/user/pass/12345.m3u8", server.base_url)).expect("entry url");
|
||||
let context = HlsOriginManifestFetchContext {
|
||||
app_config: test_app_config(),
|
||||
session: test_session(),
|
||||
origin_entry,
|
||||
headers: HeaderMap::new(),
|
||||
client: reqwest::Client::new(),
|
||||
no_redirect_client: reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("no-redirect client"),
|
||||
use_manual_redirects: true,
|
||||
origin_manifest_timeout_ms: 2_000,
|
||||
manifest_recovery_burst: HlsManifestRecoveryBurstConfig::default(),
|
||||
retry_policy: no_delay_policy(),
|
||||
};
|
||||
|
||||
let fetched = fetch_hls_origin_manifest_request(HlsOriginManifestFetchRequest::initial_global_policy(&context))
|
||||
.await
|
||||
.expect("third logical attempt should succeed");
|
||||
|
||||
assert_eq!(fetched.body, manifest_body());
|
||||
assert_eq!(fetched.attempts, 3);
|
||||
assert_eq!(
|
||||
*server.requests.lock().await,
|
||||
vec![
|
||||
"/live/user/pass/12345.m3u8",
|
||||
"/live/play/once/12345",
|
||||
"/live/user/pass/12345.m3u8",
|
||||
"/live/play/once/12345",
|
||||
"/live/user/pass/12345.m3u8",
|
||||
"/live/play/once/12345",
|
||||
]
|
||||
);
|
||||
assert!(server
|
||||
.raw_requests
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.all(|request| request_header_value(request, "accept-encoding") == Some("identity")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_initial_manifest_decoder_failures_stop_at_attempt_budget() {
|
||||
let server = spawn_test_origin(Arc::new(|_path| {
|
||||
(200, vec![("Content-Encoding", "gzip".to_string())], "corrupt-gzip".to_string())
|
||||
}))
|
||||
.await;
|
||||
let origin_entry =
|
||||
LiveHlsOriginEntry::parse(&format!("{}/live/user/pass/12345.m3u8", server.base_url)).expect("entry url");
|
||||
let retry_policy = no_delay_policy();
|
||||
let expected_attempts = retry_policy.attempt_count();
|
||||
let context = HlsOriginManifestFetchContext {
|
||||
app_config: test_app_config(),
|
||||
session: test_session(),
|
||||
origin_entry,
|
||||
headers: HeaderMap::new(),
|
||||
client: reqwest::Client::new(),
|
||||
no_redirect_client: reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("no-redirect client"),
|
||||
use_manual_redirects: false,
|
||||
origin_manifest_timeout_ms: 2_000,
|
||||
manifest_recovery_burst: HlsManifestRecoveryBurstConfig::default(),
|
||||
retry_policy,
|
||||
};
|
||||
|
||||
let error = fetch_hls_origin_manifest_request(HlsOriginManifestFetchRequest::initial_global_policy(&context))
|
||||
.await
|
||||
.expect_err("decoder failures must exhaust the logical attempt budget");
|
||||
|
||||
assert!(matches!(error, OriginManifestFetchError::ContentDecoding { .. }));
|
||||
assert_eq!(server.requests.lock().await.len(), expected_attempts);
|
||||
assert!(server
|
||||
.raw_requests
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.all(|request| request_header_value(request, "accept-encoding") == Some("identity")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_refresh_metrics_use_successful_manifest_attempt_count() {
|
||||
let manifest_hits = Arc::new(AtomicUsize::new(0));
|
||||
let manifest_hits_for_handler = Arc::clone(&manifest_hits);
|
||||
let server = spawn_test_origin(Arc::new(move |path| {
|
||||
if path != "/live/user/pass/12345.m3u8" {
|
||||
return (404, Vec::new(), String::new());
|
||||
}
|
||||
if manifest_hits_for_handler.fetch_add(1, Ordering::SeqCst) < 2 {
|
||||
return (200, vec![("Content-Encoding", "gzip".to_string())], "corrupt-gzip".to_string());
|
||||
}
|
||||
(200, Vec::new(), manifest_body())
|
||||
}))
|
||||
.await;
|
||||
let session = test_session();
|
||||
let mut request = test_origin_refresh_request(session);
|
||||
request.origin_entry =
|
||||
LiveHlsOriginEntry::parse(&format!("{}/live/user/pass/12345.m3u8", server.base_url)).expect("entry url");
|
||||
let metrics = Arc::clone(request.segment_worker_pool.metrics());
|
||||
|
||||
assert!(trigger_origin_refresh_sync(request).await);
|
||||
|
||||
let snapshot = metrics.snapshot();
|
||||
assert_eq!(snapshot.refresh_started, 1);
|
||||
assert_eq!(snapshot.refresh_completed, 1);
|
||||
assert_eq!(snapshot.refresh_retried, 2);
|
||||
assert_eq!(snapshot.refresh_failed, 0);
|
||||
let manifest_requests = server
|
||||
.raw_requests
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|request| request.lines().next().is_some_and(|line| line.contains(" /live/user/pass/12345.m3u8 ")))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(manifest_requests.len(), 3);
|
||||
assert!(manifest_requests
|
||||
.iter()
|
||||
.all(|request| request_header_value(request, "accept-encoding") == Some("identity")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manifest_retry_starts_at_entrypoint_after_redirect_failure() {
|
||||
let redirect_hits = Arc::new(AtomicUsize::new(0));
|
||||
@@ -2879,14 +3243,26 @@ mod tests {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
let first_requests = first.requests.lock().await;
|
||||
let second_requests = second.requests.lock().await;
|
||||
let first_manifest_requests =
|
||||
first_requests.iter().filter(|path| path.as_str() == "/live/user/pass/12345.m3u8").count();
|
||||
first.requests.lock().await.iter().filter(|path| path.as_str() == "/live/user/pass/12345.m3u8").count();
|
||||
let second_manifest_requests =
|
||||
second_requests.iter().filter(|path| path.as_str() == "/live/user/pass/12345.m3u8").count();
|
||||
second.requests.lock().await.iter().filter(|path| path.as_str() == "/live/user/pass/12345.m3u8").count();
|
||||
assert_eq!(first_manifest_requests, 1);
|
||||
assert_eq!(second_manifest_requests, 1);
|
||||
for server in [&first, &second] {
|
||||
let manifest_requests = server
|
||||
.raw_requests
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|request| {
|
||||
request.lines().next().is_some_and(|line| line.contains(" /live/user/pass/12345.m3u8 "))
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(manifest_requests.len(), 1);
|
||||
assert_eq!(request_header_value(&manifest_requests[0], "accept-encoding"), Some("identity"));
|
||||
}
|
||||
let session = session.read().await;
|
||||
assert_eq!(session.key.stable_value(), initial_session_key);
|
||||
assert_eq!(session.proxy_session_id, initial_proxy_session_id);
|
||||
@@ -3043,9 +3419,8 @@ mod tests {
|
||||
score_manifest_recovery_candidate(&session, &same_host_next, &fetch_context).expect("score").quality.score,
|
||||
HlsManifestOriginQualityScore::SameHostNextSequence
|
||||
);
|
||||
let other_host_score = score_manifest_recovery_candidate(&session, &other_host_next, &fetch_context)
|
||||
.expect("score")
|
||||
.quality;
|
||||
let other_host_score =
|
||||
score_manifest_recovery_candidate(&session, &other_host_next, &fetch_context).expect("score").quality;
|
||||
assert_eq!(other_host_score.score, HlsManifestOriginQualityScore::OtherHostNextSequence);
|
||||
assert!(other_host_score.requires_handoff_discontinuity);
|
||||
}
|
||||
|
||||
@@ -64,10 +64,7 @@ pub enum RenderedManifestStoreOutcome {
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum RenderedManifestStoreRejectReason {
|
||||
RegressiveMediaSequence {
|
||||
previous_first_proxy_seq: u64,
|
||||
candidate_first_proxy_seq: u64,
|
||||
},
|
||||
RegressiveMediaSequence { previous_first_proxy_seq: u64, candidate_first_proxy_seq: u64 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
@@ -164,10 +161,7 @@ fn select_window(session: &HlsSession, render_gap_segments: usize) -> Option<Vec
|
||||
|
||||
let current_origin_window_len = tail_seq.saturating_sub(head_seq).saturating_add(1);
|
||||
let target_window_len = current_origin_window_len.min(u64::try_from(TARGET_VISIBLE_SEGMENTS).ok()?);
|
||||
let start_seq = tail_seq
|
||||
.saturating_add(1)
|
||||
.saturating_sub(target_window_len)
|
||||
.max(head_seq);
|
||||
let start_seq = tail_seq.saturating_add(1).saturating_sub(target_window_len).max(head_seq);
|
||||
let mut window = Vec::new();
|
||||
let mut not_ready_count = 0_usize;
|
||||
|
||||
@@ -188,7 +182,8 @@ fn select_window(session: &HlsSession, render_gap_segments: usize) -> Option<Vec
|
||||
if window.len() < MIN_VISIBLE_SEGMENTS {
|
||||
return None;
|
||||
}
|
||||
if session.render_policy.initial_render_gap_segments == 0 && !window_contains_provisioning_segment(session, &window) {
|
||||
if session.render_policy.initial_render_gap_segments == 0 && !window_contains_provisioning_segment(session, &window)
|
||||
{
|
||||
let target_duration_ms = u64::from(resolve_target_duration(session, &window)).saturating_mul(1_000);
|
||||
if playlist_duration_ms(session, &window) < target_duration_ms.saturating_mul(3) {
|
||||
return None;
|
||||
@@ -220,8 +215,7 @@ fn render_window(
|
||||
}
|
||||
writeln!(body, "#EXT-X-TARGETDURATION:{target_duration}").map_err(|_| RenderError::InvalidState)?;
|
||||
writeln!(body, "#EXT-X-MEDIA-SEQUENCE:{first_proxy_seq}").map_err(|_| RenderError::InvalidState)?;
|
||||
writeln!(body, "#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuity_sequence}")
|
||||
.map_err(|_| RenderError::InvalidState)?;
|
||||
writeln!(body, "#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuity_sequence}").map_err(|_| RenderError::InvalidState)?;
|
||||
|
||||
let mut current_map_ref = None;
|
||||
let contains_provisioning = window_contains_provisioning_segment(session, window);
|
||||
@@ -236,8 +230,7 @@ fn render_window(
|
||||
body.push('\n');
|
||||
}
|
||||
if let Some(program_date_time) = &entry.program_date_time {
|
||||
writeln!(body, "#EXT-X-PROGRAM-DATE-TIME:{program_date_time}")
|
||||
.map_err(|_| RenderError::InvalidState)?;
|
||||
writeln!(body, "#EXT-X-PROGRAM-DATE-TIME:{program_date_time}").map_err(|_| RenderError::InvalidState)?;
|
||||
}
|
||||
if entry.discontinuity_before {
|
||||
if contains_provisioning {
|
||||
@@ -263,8 +256,7 @@ fn render_window(
|
||||
}
|
||||
current_map_ref = entry.map_ref;
|
||||
}
|
||||
writeln!(body, "#EXTINF:{},", format_duration_ms(entry.duration_ms))
|
||||
.map_err(|_| RenderError::InvalidState)?;
|
||||
writeln!(body, "#EXTINF:{},", format_duration_ms(entry.duration_ms)).map_err(|_| RenderError::InvalidState)?;
|
||||
if is_local_provisioning_segment(entry) {
|
||||
writeln!(
|
||||
body,
|
||||
@@ -345,10 +337,7 @@ fn resolve_target_duration(session: &HlsSession, window: &[u64]) -> u32 {
|
||||
}
|
||||
|
||||
fn window_contains_provisioning_segment(session: &HlsSession, window: &[u64]) -> bool {
|
||||
window
|
||||
.iter()
|
||||
.filter_map(|proxy_seq| session.segments.get(proxy_seq))
|
||||
.any(is_local_provisioning_segment)
|
||||
window.iter().filter_map(|proxy_seq| session.segments.get(proxy_seq)).any(is_local_provisioning_segment)
|
||||
}
|
||||
|
||||
fn window_contains_origin_segment(session: &HlsSession, window: &[u64]) -> bool {
|
||||
@@ -380,7 +369,6 @@ fn format_duration_ms(duration_ms: u64) -> String { format!("{}.{:03}", duration
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use shared::model::HlsStripMode;
|
||||
use super::{
|
||||
HlsManifestRenderer, RenderError, RenderPolicy, RenderedManifest, RenderedManifestStoreOutcome,
|
||||
RenderedManifestStoreRejectReason,
|
||||
@@ -390,6 +378,7 @@ mod tests {
|
||||
model::StripConfig,
|
||||
processing::parser::hls::origin_manifest::{parse_origin_media_manifest, OriginManifestParseOutcome},
|
||||
};
|
||||
use shared::model::HlsStripMode;
|
||||
|
||||
const BASE_URL: &str = "http://origin.example.com/live/final/index.m3u8";
|
||||
|
||||
@@ -433,9 +422,8 @@ mod tests {
|
||||
fn rendering_an_empty_internal_window_does_not_panic() {
|
||||
let session = session();
|
||||
|
||||
let rendered = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
super::render_window(&session, &[], 0, 0)
|
||||
}));
|
||||
let rendered =
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| super::render_window(&session, &[], 0, 0)));
|
||||
|
||||
assert!(rendered.is_ok());
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,10 @@
|
||||
|
||||
use super::{
|
||||
hls_client_body_send_deadline, refresh_hls_client_body_send_deadline, safe_hls_access_lease_id,
|
||||
safe_proxy_session_id, CacheAccessState,
|
||||
HlsAccessLeaseId, HlsCacheMetrics, HlsMapFile, HlsProxyManager, HlsRepairRenderedObjectId, HlsSegmentCache,
|
||||
HlsSegmentFile, HlsSegmentRepairManager, HlsSegmentRepairObjectContext, HlsSegmentRepairSource,
|
||||
HlsSessionHandle, MapCacheKey, MapCacheStatus, ProxyMapId, SegmentCacheKey, SegmentCacheStatus,
|
||||
TransientObjectCacheKey, TransientResourceFile, TransientResourceKind,
|
||||
safe_proxy_session_id, CacheAccessState, HlsAccessLeaseId, HlsCacheMetrics, HlsMapFile, HlsProxyManager,
|
||||
HlsRepairRenderedObjectId, HlsSegmentCache, HlsSegmentFile, HlsSegmentRepairManager, HlsSegmentRepairObjectContext,
|
||||
HlsSegmentRepairSource, HlsSessionHandle, MapCacheKey, MapCacheStatus, ProxyMapId, SegmentCacheKey,
|
||||
SegmentCacheStatus, TransientObjectCacheKey, TransientResourceFile, TransientResourceKind,
|
||||
};
|
||||
use crate::api::{api_utils::mark_response_as_uncompressed, model::StreamMeterHandle};
|
||||
use arc_swap::ArcSwapOption;
|
||||
@@ -19,8 +18,8 @@ use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use log::debug;
|
||||
use std::{
|
||||
io,
|
||||
future::Future,
|
||||
io,
|
||||
pin::Pin,
|
||||
sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
@@ -29,8 +28,10 @@ use std::{
|
||||
task::{Context, Poll},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::time::{sleep, Sleep};
|
||||
use tokio::{
|
||||
io::AsyncReadExt,
|
||||
time::{sleep, Sleep},
|
||||
};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
const ACCEPT_RANGES_VALUE: &str = "bytes";
|
||||
@@ -123,9 +124,7 @@ impl HlsCacheResponseContext {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_qos_meter(&self, qos_meter: Option<Arc<StreamMeterHandle>>) {
|
||||
self.qos_meter.store(qos_meter);
|
||||
}
|
||||
pub fn set_qos_meter(&self, qos_meter: Option<Arc<StreamMeterHandle>>) { self.qos_meter.store(qos_meter); }
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -196,15 +195,15 @@ pub async fn serve_hls_segment_cache_outcome(
|
||||
context: &HlsCacheResponseContext,
|
||||
) -> HlsResourceServeOutcome {
|
||||
match lookup_segment_cache_object(&session, &segment_file, &context.hls_access_lease_id).await {
|
||||
CacheObjectLookup::Ready(object) => {
|
||||
HlsResourceServeOutcome::Ready(serve_cache_object(
|
||||
CacheObjectLookup::Ready(object) => HlsResourceServeOutcome::Ready(
|
||||
serve_cache_object(
|
||||
segment_cache,
|
||||
object,
|
||||
range_header,
|
||||
CacheObjectServeContext::from_response_context(context),
|
||||
)
|
||||
.await)
|
||||
}
|
||||
.await,
|
||||
),
|
||||
CacheObjectLookup::Failure(failure) => HlsResourceServeOutcome::Failure(failure),
|
||||
}
|
||||
}
|
||||
@@ -232,15 +231,15 @@ pub async fn serve_hls_map_cache_outcome(
|
||||
context: &HlsCacheResponseContext,
|
||||
) -> HlsResourceServeOutcome {
|
||||
match lookup_map_cache_object(&session, &map_file, &context.hls_access_lease_id).await {
|
||||
CacheObjectLookup::Ready(object) => {
|
||||
HlsResourceServeOutcome::Ready(serve_cache_object(
|
||||
CacheObjectLookup::Ready(object) => HlsResourceServeOutcome::Ready(
|
||||
serve_cache_object(
|
||||
segment_cache,
|
||||
object,
|
||||
range_header,
|
||||
CacheObjectServeContext::from_response_context(context),
|
||||
)
|
||||
.await)
|
||||
}
|
||||
.await,
|
||||
),
|
||||
CacheObjectLookup::Failure(failure) => HlsResourceServeOutcome::Failure(failure),
|
||||
}
|
||||
}
|
||||
@@ -270,15 +269,15 @@ pub async fn serve_hls_transient_object_cache_outcome(
|
||||
match lookup_transient_object_cache_object(&session, &resource_file, &context.hls_access_lease_id, context.now_ms)
|
||||
.await
|
||||
{
|
||||
CacheObjectLookup::Ready(object) => {
|
||||
HlsResourceServeOutcome::Ready(serve_cache_object(
|
||||
CacheObjectLookup::Ready(object) => HlsResourceServeOutcome::Ready(
|
||||
serve_cache_object(
|
||||
segment_cache,
|
||||
object,
|
||||
range_header,
|
||||
CacheObjectServeContext::from_response_context(context),
|
||||
)
|
||||
.await)
|
||||
}
|
||||
.await,
|
||||
),
|
||||
CacheObjectLookup::Failure(failure) => HlsResourceServeOutcome::Failure(failure),
|
||||
}
|
||||
}
|
||||
@@ -305,10 +304,8 @@ where
|
||||
{
|
||||
let guard = CacheReadGuard::new(Arc::clone(&object.access), context.now_ms);
|
||||
if let Some(repair_context) = object.repair_context.clone() {
|
||||
if let Err(err) = context
|
||||
.segment_repair
|
||||
.repair_ready_cache_hit(&segment_cache, &object.key, repair_context)
|
||||
.await
|
||||
if let Err(err) =
|
||||
context.segment_repair.repair_ready_cache_hit(&segment_cache, &object.key, repair_context).await
|
||||
{
|
||||
debug!(
|
||||
"HLS segment repair skipped for ready cache hit: session={} resource={} error={err}",
|
||||
@@ -425,9 +422,7 @@ async fn lookup_segment_cache_object(
|
||||
}
|
||||
match entry.status {
|
||||
SegmentCacheStatus::Ready { .. } => {}
|
||||
SegmentCacheStatus::Fetching { .. }
|
||||
| SegmentCacheStatus::Queued { .. }
|
||||
| SegmentCacheStatus::Discovered => {
|
||||
SegmentCacheStatus::Fetching { .. } | SegmentCacheStatus::Queued { .. } | SegmentCacheStatus::Discovered => {
|
||||
return CacheObjectLookup::Failure(HlsResourceServeFailure::TemporaryUnavailable {
|
||||
retry_after_ms: NOT_READY_RETRY_AFTER_MS,
|
||||
});
|
||||
@@ -446,13 +441,13 @@ async fn lookup_segment_cache_object(
|
||||
key: entry.cache_key.clone(),
|
||||
access: Arc::clone(&entry.access),
|
||||
content_type: entry.content_type.clone(),
|
||||
log_context: CacheObjectLogContext {
|
||||
lease: safe_hls_access_lease_id(hls_access_lease_id),
|
||||
session: safe_proxy_session_id(&session.proxy_session_id),
|
||||
resource_id: format!("{:06}", segment_file.proxy_seq),
|
||||
object_kind: "Segment",
|
||||
body_source: "normal",
|
||||
},
|
||||
log_context: CacheObjectLogContext {
|
||||
lease: safe_hls_access_lease_id(hls_access_lease_id),
|
||||
session: safe_proxy_session_id(&session.proxy_session_id),
|
||||
resource_id: format!("{:06}", segment_file.proxy_seq),
|
||||
object_kind: "Segment",
|
||||
body_source: "normal",
|
||||
},
|
||||
repair_context: Some(HlsSegmentRepairObjectContext {
|
||||
source: HlsSegmentRepairSource::Normal,
|
||||
proxy_session_id: session.proxy_session_id.clone(),
|
||||
@@ -492,9 +487,7 @@ async fn lookup_map_cache_object(
|
||||
}
|
||||
match entry.status {
|
||||
MapCacheStatus::Ready { .. } => {}
|
||||
MapCacheStatus::Fetching { .. }
|
||||
| MapCacheStatus::Queued { .. }
|
||||
| MapCacheStatus::Discovered => {
|
||||
MapCacheStatus::Fetching { .. } | MapCacheStatus::Queued { .. } | MapCacheStatus::Discovered => {
|
||||
return CacheObjectLookup::Failure(HlsResourceServeFailure::TemporaryUnavailable {
|
||||
retry_after_ms: NOT_READY_RETRY_AFTER_MS,
|
||||
});
|
||||
@@ -620,11 +613,7 @@ fn service_unavailable_not_ready_response(retry_after_ms: u64) -> Response<Body>
|
||||
let mut response = Response::new(Body::empty());
|
||||
*response.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
|
||||
let headers = response.headers_mut();
|
||||
insert_header_value(
|
||||
headers,
|
||||
header::RETRY_AFTER,
|
||||
&super::retry_after_secs_from_ms(retry_after_ms).to_string(),
|
||||
);
|
||||
insert_header_value(headers, header::RETRY_AFTER, &super::retry_after_secs_from_ms(retry_after_ms).to_string());
|
||||
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
mark_response_as_uncompressed(&mut response);
|
||||
response
|
||||
@@ -777,10 +766,7 @@ impl Stream for ActiveReaderStream {
|
||||
if self.send_deadline.as_mut().poll(cx).is_ready() {
|
||||
self.finished = true;
|
||||
self.log_completed("timeout");
|
||||
return Poll::Ready(Some(Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"hls client body send timed out",
|
||||
))));
|
||||
return Poll::Ready(Some(Err(io::Error::new(io::ErrorKind::TimedOut, "hls client body send timed out"))));
|
||||
}
|
||||
match self.inner.as_mut().poll_next(cx) {
|
||||
Poll::Ready(Some(Ok(chunk))) => {
|
||||
@@ -849,9 +835,13 @@ fn transient_body_object_kind(resource_kind: Option<TransientResourceKind>, exte
|
||||
match resource_kind {
|
||||
Some(TransientResourceKind::Key) => "Key",
|
||||
Some(TransientResourceKind::Map) => "Map",
|
||||
Some(TransientResourceKind::Segment | TransientResourceKind::Other) => "Segment",
|
||||
Some(TransientResourceKind::Segment | TransientResourceKind::Part | TransientResourceKind::Other) => "Segment",
|
||||
None => {
|
||||
if extension.eq_ignore_ascii_case("key") { "Key" } else { "Segment" }
|
||||
if extension.eq_ignore_ascii_case("key") {
|
||||
"Key"
|
||||
} else {
|
||||
"Segment"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -880,9 +870,9 @@ mod tests {
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use bytes::Bytes;
|
||||
use http_body_util::BodyExt;
|
||||
use shared::model::HlsSegmentRepairMode;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::sync::RwLock;
|
||||
use shared::model::HlsSegmentRepairMode;
|
||||
|
||||
fn header(value: &str) -> HeaderValue { HeaderValue::from_str(value).expect("valid header") }
|
||||
|
||||
@@ -941,6 +931,7 @@ mod tests {
|
||||
assert_eq!(transient_body_object_kind(Some(TransientResourceKind::Key), "bin"), "Key");
|
||||
assert_eq!(transient_body_object_kind(Some(TransientResourceKind::Map), "bin"), "Map");
|
||||
assert_eq!(transient_body_object_kind(Some(TransientResourceKind::Segment), "key"), "Segment");
|
||||
assert_eq!(transient_body_object_kind(Some(TransientResourceKind::Part), "m4s"), "Segment");
|
||||
assert_eq!(transient_body_object_kind(Some(TransientResourceKind::Other), "bin"), "Segment");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,30 +2,30 @@
|
||||
|
||||
use super::{
|
||||
begin_hls_origin_account_io_bounded, build_hls_origin_resource_headers, classify_hls_backpressure,
|
||||
finish_hls_origin_account_io, hls_object_body_deadline,
|
||||
run_hls_origin_resource_retry_loop_with_attempt_prepare, CachedSegmentMetadata,
|
||||
HlsAccessLeaseChannelUnavailableReason, HlsAccessLeaseId, HlsAccessLeaseStore, HlsBackpressureState,
|
||||
HlsBoundAccountAcquireErrorKind, HlsCacheMetrics, HlsOriginAccountIoLeaseGuard, HlsOriginByteRangeExpectation,
|
||||
HlsOriginIoContext, HlsOriginResourceClients, HlsOriginResourceFetchError, HlsOriginResourceFetchTarget,
|
||||
HlsRepairRenderedObjectId, HlsResourceFetchKind, HlsResourceFetchSource, HlsSegmentCache, HlsSegmentFailureObject,
|
||||
HlsSegmentFailureTransition, HlsSegmentFile, HlsSegmentRepairManager, HlsSegmentRepairObjectContext,
|
||||
HlsSegmentRepairSource, HlsSessionHandle, OriginSegmentFetchRef, SegmentCacheKey, SegmentCacheStatus,
|
||||
SegmentFetchPriority,
|
||||
finish_hls_origin_account_io, hls_object_body_deadline, run_hls_origin_resource_retry_loop_with_attempt_prepare,
|
||||
CachedSegmentMetadata, HlsAccessLeaseChannelUnavailableReason, HlsAccessLeaseId, HlsAccessLeaseStore,
|
||||
HlsBackpressureState, HlsBoundAccountAcquireErrorKind, HlsCacheMetrics, HlsOriginAccountIoLeaseGuard,
|
||||
HlsOriginByteRangeExpectation, HlsOriginIoContext, HlsOriginResourceBodyDeadline, HlsOriginResourceClients,
|
||||
HlsOriginResourceFetchError, HlsOriginResourceFetchTarget, HlsRepairRenderedObjectId, HlsResourceFetchKind,
|
||||
HlsResourceFetchSource, HlsSegmentCache, HlsSegmentFailureObject, HlsSegmentFailureTransition, HlsSegmentFile,
|
||||
HlsSegmentRepairManager, HlsSegmentRepairObjectContext, HlsSegmentRepairSource, HlsSessionHandle,
|
||||
OriginSegmentFetchRef, SegmentCacheKey, SegmentCacheStatus, SegmentFetchPriority,
|
||||
};
|
||||
use crate::{
|
||||
model::HlsCacheConfig, processing::parser::hls::origin_manifest::ParsedByteRange,
|
||||
utils::content_coding::DecodedHttpResponse,
|
||||
};
|
||||
use crate::model::HlsCacheConfig;
|
||||
use crate::processing::parser::hls::origin_manifest::ParsedByteRange;
|
||||
use shared::model::{HlsSegmentRepairMode, HlsStripMode};
|
||||
use arc_swap::ArcSwap;
|
||||
use axum::http::HeaderMap;
|
||||
use futures::{FutureExt, TryStreamExt};
|
||||
use futures::FutureExt;
|
||||
use log::{debug, warn};
|
||||
use reqwest::Client;
|
||||
use std::{fmt, io, sync::Arc, time::Duration};
|
||||
use shared::model::{HlsSegmentRepairMode, HlsStripMode};
|
||||
use std::{fmt, sync::Arc, time::Duration};
|
||||
use tokio::{
|
||||
sync::{OwnedSemaphorePermit, RwLock, Semaphore},
|
||||
time::timeout,
|
||||
};
|
||||
use tokio_util::io::StreamReader;
|
||||
|
||||
const DEFAULT_MAX_GLOBAL_SEGMENT_FETCHES: usize = 64;
|
||||
const DEFAULT_MAX_SESSION_SEGMENT_FETCHES: usize = 2;
|
||||
@@ -483,14 +483,12 @@ impl HlsSegmentWorkerPool {
|
||||
threshold
|
||||
);
|
||||
invalidate_queued_origin_work = true;
|
||||
response_flag_reason = Some(HlsAccessLeaseChannelUnavailableReason::SegmentTemporaryFailureThreshold {
|
||||
failures,
|
||||
threshold,
|
||||
});
|
||||
SegmentCacheStatus::FailedPermanent {
|
||||
failed_at_ms: finished_at_ms,
|
||||
status: None,
|
||||
}
|
||||
response_flag_reason =
|
||||
Some(HlsAccessLeaseChannelUnavailableReason::SegmentTemporaryFailureThreshold {
|
||||
failures,
|
||||
threshold,
|
||||
});
|
||||
SegmentCacheStatus::FailedPermanent { failed_at_ms: finished_at_ms, status: None }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -508,10 +506,8 @@ impl HlsSegmentWorkerPool {
|
||||
if invalidate_queued_origin_work {
|
||||
session.invalidate_queued_origin_work();
|
||||
if let Some(entry) = session.segments.get_mut(&snapshot.proxy_seq) {
|
||||
entry.status = SegmentCacheStatus::FailedPermanent {
|
||||
failed_at_ms: finished_at_ms,
|
||||
status: None,
|
||||
};
|
||||
entry.status =
|
||||
SegmentCacheStatus::FailedPermanent { failed_at_ms: finished_at_ms, status: None };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -553,10 +549,7 @@ impl HlsSegmentWorkerPool {
|
||||
reason: HlsAccessLeaseChannelUnavailableReason,
|
||||
) -> usize {
|
||||
let proxy_session_id = session.read().await.proxy_session_id.clone();
|
||||
self.access_leases
|
||||
.write()
|
||||
.await
|
||||
.mark_channel_unavailable_for_session(&proxy_session_id, now_ms, reason)
|
||||
self.access_leases.write().await.mark_channel_unavailable_for_session(&proxy_session_id, now_ms, reason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,7 +693,6 @@ async fn fetch_segment_with_retries_into_cache(
|
||||
let session_log_id = context.session.read().await.proxy_session_id.0.clone();
|
||||
let context = context.clone();
|
||||
let snapshot = snapshot.clone();
|
||||
let commit_policy = policy.clone();
|
||||
let policy_for_prepare = policy.clone();
|
||||
let prepare_context = context.clone();
|
||||
let cleanup_context = context.clone();
|
||||
@@ -721,12 +713,12 @@ async fn fetch_segment_with_retries_into_cache(
|
||||
}
|
||||
.boxed()
|
||||
},
|
||||
move |response, _attempt, guard| {
|
||||
move |response, _attempt, body_deadline, guard| {
|
||||
let context = context.clone();
|
||||
let snapshot = snapshot.clone();
|
||||
let policy = commit_policy.clone();
|
||||
async move {
|
||||
let commit_result = commit_segment_response_into_cache(&context, &snapshot, &policy, response).await;
|
||||
let commit_result =
|
||||
commit_segment_response_into_cache(&context, &snapshot, response, body_deadline).await;
|
||||
let origin_work = finish_segment_origin_attempt(context, guard).await;
|
||||
commit_result.map(|metadata| SegmentFetchCommit {
|
||||
content_length: metadata.size,
|
||||
@@ -742,11 +734,9 @@ async fn fetch_segment_with_retries_into_cache(
|
||||
async fn commit_segment_response_into_cache(
|
||||
context: &SegmentFetchContext,
|
||||
snapshot: &SegmentFetchSnapshot,
|
||||
policy: &SegmentFetchPolicy,
|
||||
response: reqwest::Response,
|
||||
response: DecodedHttpResponse,
|
||||
body_deadline: HlsOriginResourceBodyDeadline,
|
||||
) -> Result<CachedSegmentMetadata, SegmentFetchError> {
|
||||
let deadline = hls_object_body_deadline(policy.origin_segment_timeout_ms);
|
||||
let stream_reader = StreamReader::new(response.bytes_stream().map_err(io::Error::other));
|
||||
let proxy_session_id = context.session.read().await.proxy_session_id.clone();
|
||||
let repair_context = HlsSegmentRepairObjectContext {
|
||||
source: HlsSegmentRepairSource::Normal,
|
||||
@@ -765,15 +755,15 @@ async fn commit_segment_response_into_cache(
|
||||
};
|
||||
context
|
||||
.segment_repair
|
||||
.commit_origin_response(&context.segment_cache, &snapshot.cache_key, stream_reader, deadline, repair_context)
|
||||
.commit_origin_response(
|
||||
&context.segment_cache,
|
||||
&snapshot.cache_key,
|
||||
response.body,
|
||||
body_deadline.deadline(),
|
||||
repair_context,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
if err.kind() == io::ErrorKind::TimedOut {
|
||||
SegmentFetchError::Timeout
|
||||
} else {
|
||||
SegmentFetchError::cache_commit(&err)
|
||||
}
|
||||
})
|
||||
.map_err(|err| SegmentFetchError::cache_body(&err))
|
||||
}
|
||||
|
||||
fn build_segment_origin_headers(
|
||||
@@ -798,9 +788,13 @@ mod tests {
|
||||
model::HlsSegmentRepairConfig,
|
||||
processing::parser::hls::origin_manifest::{parse_origin_media_manifest, OriginManifestParseOutcome},
|
||||
};
|
||||
use async_compression::tokio::bufread::{BrotliEncoder, DeflateEncoder, GzipEncoder, ZstdEncoder};
|
||||
use axum::http::{header, HeaderMap, HeaderValue};
|
||||
use shared::model::HlsSegmentRepairMode;
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
io::Cursor,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
@@ -808,11 +802,10 @@ mod tests {
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
io::{AsyncReadExt, AsyncWriteExt, BufReader},
|
||||
net::TcpListener,
|
||||
sync::Mutex,
|
||||
};
|
||||
use shared::model::HlsSegmentRepairMode;
|
||||
|
||||
const BASE_URL: &str = "http://origin.example.com/live/final/index.m3u8";
|
||||
|
||||
@@ -913,10 +906,8 @@ mod tests {
|
||||
origin_io: None,
|
||||
};
|
||||
|
||||
let snapshot = worker
|
||||
.next_fetch_snapshot(&context, 11, &SegmentFetchPolicy::default())
|
||||
.await
|
||||
.expect("segment snapshot");
|
||||
let snapshot =
|
||||
worker.next_fetch_snapshot(&context, 11, &SegmentFetchPolicy::default()).await.expect("segment snapshot");
|
||||
|
||||
assert_eq!(snapshot.fetch_ref.resolved_origin_url, "https://cdn.example.net/live/redirected/media/seg001.ts");
|
||||
assert_eq!(snapshot.fetch_ref.byte_range, None);
|
||||
@@ -934,6 +925,128 @@ mod tests {
|
||||
fn drop(&mut self) { self.task.abort(); }
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestOriginResponse {
|
||||
status: u16,
|
||||
headers: Vec<(String, String)>,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl TestOriginResponse {
|
||||
fn encoded(content_encoding: &str, body: Vec<u8>) -> Self {
|
||||
Self { status: 200, headers: vec![("Content-Encoding".to_string(), content_encoding.to_string())], body }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum TestContentEncoding {
|
||||
Gzip,
|
||||
RawDeflate,
|
||||
Brotli,
|
||||
Zstd,
|
||||
}
|
||||
|
||||
impl TestContentEncoding {
|
||||
const fn header_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Gzip => "gzip",
|
||||
Self::RawDeflate => "deflate",
|
||||
Self::Brotli => "br",
|
||||
Self::Zstd => "zstd",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn encode_test_body(body: &[u8], encoding: TestContentEncoding) -> Vec<u8> {
|
||||
let reader = BufReader::new(Cursor::new(body.to_vec()));
|
||||
let mut encoded = Vec::new();
|
||||
match encoding {
|
||||
TestContentEncoding::Gzip => GzipEncoder::new(reader).read_to_end(&mut encoded).await,
|
||||
TestContentEncoding::RawDeflate => DeflateEncoder::new(reader).read_to_end(&mut encoded).await,
|
||||
TestContentEncoding::Brotli => BrotliEncoder::new(reader).read_to_end(&mut encoded).await,
|
||||
TestContentEncoding::Zstd => ZstdEncoder::new(reader).read_to_end(&mut encoded).await,
|
||||
}
|
||||
.expect("test body encodes");
|
||||
encoded
|
||||
}
|
||||
|
||||
async fn spawn_sequence_response_server(responses: Vec<TestOriginResponse>) -> TestSegmentServer {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds");
|
||||
let addr = listener.local_addr().expect("local addr");
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
let max_active = Arc::new(AtomicUsize::new(0));
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let responses = Arc::new(Mutex::new(VecDeque::from(responses)));
|
||||
let task_active = Arc::clone(&active);
|
||||
let task_max = Arc::clone(&max_active);
|
||||
let task_requests = Arc::clone(&requests);
|
||||
let task_responses = Arc::clone(&responses);
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut socket, _)) = listener.accept().await else {
|
||||
break;
|
||||
};
|
||||
let active = Arc::clone(&task_active);
|
||||
let max_active = Arc::clone(&task_max);
|
||||
let requests = Arc::clone(&task_requests);
|
||||
let responses = Arc::clone(&task_responses);
|
||||
tokio::spawn(async move {
|
||||
let mut request = Vec::new();
|
||||
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
let mut chunk = [0_u8; 2048];
|
||||
let Ok(read) = socket.read(&mut chunk).await else {
|
||||
return;
|
||||
};
|
||||
if read == 0 {
|
||||
return;
|
||||
}
|
||||
request.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
requests.lock().await.push(String::from_utf8_lossy(&request).to_string());
|
||||
let current = active.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
max_active.fetch_max(current, Ordering::SeqCst);
|
||||
let response = responses.lock().await.pop_front().unwrap_or(TestOriginResponse {
|
||||
status: 500,
|
||||
headers: Vec::new(),
|
||||
body: Vec::new(),
|
||||
});
|
||||
active.fetch_sub(1, Ordering::SeqCst);
|
||||
let reason = if response.status == 200 { "OK" } else { "Status" };
|
||||
let mut head =
|
||||
format!("HTTP/1.1 {} {reason}\r\nContent-Length: {}\r\n", response.status, response.body.len());
|
||||
for (name, value) in response.headers {
|
||||
head.push_str(&format!("{name}: {value}\r\n"));
|
||||
}
|
||||
head.push_str("Connection: close\r\n\r\n");
|
||||
let _ = socket.write_all(head.as_bytes()).await;
|
||||
let _ = socket.write_all(&response.body).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
TestSegmentServer { base_url: format!("http://{addr}"), max_active, requests, task }
|
||||
}
|
||||
|
||||
fn temp_cache_files(root: &Path) -> Vec<PathBuf> {
|
||||
fn visit(path: &Path, found: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(path) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
visit(&path, found);
|
||||
} else if path.file_name().and_then(|name| name.to_str()).is_some_and(|name| name.contains(".tmp.")) {
|
||||
found.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut found = Vec::new();
|
||||
visit(root, &mut found);
|
||||
found
|
||||
}
|
||||
|
||||
async fn spawn_segment_server(delay_ms: u64) -> TestSegmentServer {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds");
|
||||
let addr = listener.local_addr().expect("local addr");
|
||||
@@ -1189,6 +1302,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
async fn committed_segment(
|
||||
context: &SegmentFetchContext,
|
||||
proxy_seq: u64,
|
||||
) -> (crate::api::model::SegmentCacheKey, Vec<u8>) {
|
||||
let cache_key = context.session.read().await.segments.get(&proxy_seq).expect("segment").cache_key.clone();
|
||||
let metadata = context
|
||||
.segment_cache
|
||||
.metadata(&cache_key)
|
||||
.await
|
||||
.expect("cache metadata reads")
|
||||
.expect("cache object exists");
|
||||
let bytes = tokio::fs::read(metadata.path).await.expect("cache object reads");
|
||||
(cache_key, bytes)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn demand_fetch_writes_cache_and_sets_ready_after_commit() {
|
||||
let server = spawn_segment_server(0).await;
|
||||
@@ -1207,6 +1335,133 @@ mod tests {
|
||||
assert!(matches!(session.segments.get(&1).expect("segment").status, SegmentCacheStatus::Ready { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn segment_declared_codings_decode_to_identity_before_cache_and_preserve_opaque_bytes() {
|
||||
let identity_bytes = b"\x00\xffopaque-hls-ciphertext\x1f\x8bmedia".to_vec();
|
||||
|
||||
for encoding in [
|
||||
TestContentEncoding::Gzip,
|
||||
TestContentEncoding::RawDeflate,
|
||||
TestContentEncoding::Brotli,
|
||||
TestContentEncoding::Zstd,
|
||||
] {
|
||||
let encoded = encode_test_body(&identity_bytes, encoding).await;
|
||||
let server =
|
||||
spawn_sequence_response_server(vec![TestOriginResponse::encoded(encoding.header_value(), encoded)])
|
||||
.await;
|
||||
let temp_dir = tempfile::tempdir().expect("temp dir");
|
||||
let policy = SegmentFetchPolicy {
|
||||
retry_delays_ms: [0, 0, 0, 0, 0],
|
||||
retry_jitter_max_ms: 0,
|
||||
..SegmentFetchPolicy::default()
|
||||
};
|
||||
let (worker, context, segment_file) = fetch_context(&server, &temp_dir, &policy).await;
|
||||
clear_scheduled_prefetch(&context, &policy).await;
|
||||
|
||||
let outcome = worker.demand_fetch_and_wait(context.clone(), &segment_file, 20).await;
|
||||
|
||||
assert_eq!(outcome, super::SegmentDemandFetchOutcome::Ready, "failed for {encoding:?}");
|
||||
let (cache_key, cached) = committed_segment(&context, 1).await;
|
||||
assert_eq!(cached, identity_bytes, "cache retained HTTP coding for {encoding:?}");
|
||||
let mut range = context.segment_cache.open_range(&cache_key, 5).await.expect("cache range opens");
|
||||
let mut ranged = Vec::new();
|
||||
range.read_to_end(&mut ranged).await.expect("cache range reads");
|
||||
assert_eq!(ranged, identity_bytes[5..], "range did not address Identity bytes for {encoding:?}");
|
||||
let requests = server.requests.lock().await;
|
||||
assert_eq!(requests.len(), 1, "unexpected retries for {encoding:?}");
|
||||
assert!(
|
||||
requests[0].to_ascii_lowercase().contains("accept-encoding: identity"),
|
||||
"request did not enforce identity for {encoding:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn segment_decoder_failure_retries_then_commits_and_cleans_temp_file() {
|
||||
let identity_bytes = b"identity-media-after-retry".to_vec();
|
||||
let valid = encode_test_body(&identity_bytes, TestContentEncoding::Gzip).await;
|
||||
let mut corrupt = valid.clone();
|
||||
corrupt.truncate(corrupt.len() / 2);
|
||||
let server = spawn_sequence_response_server(vec![
|
||||
TestOriginResponse::encoded("gzip", corrupt),
|
||||
TestOriginResponse::encoded("gzip", valid),
|
||||
])
|
||||
.await;
|
||||
let temp_dir = tempfile::tempdir().expect("temp dir");
|
||||
let policy = SegmentFetchPolicy {
|
||||
retry_delays_ms: [0, 0, 0, 0, 0],
|
||||
retry_jitter_max_ms: 0,
|
||||
..SegmentFetchPolicy::default()
|
||||
};
|
||||
let (worker, context, segment_file) = fetch_context(&server, &temp_dir, &policy).await;
|
||||
clear_scheduled_prefetch(&context, &policy).await;
|
||||
|
||||
let outcome = worker.demand_fetch_and_wait(context.clone(), &segment_file, 20).await;
|
||||
|
||||
assert_eq!(outcome, super::SegmentDemandFetchOutcome::Ready);
|
||||
assert_eq!(committed_segment(&context, 1).await.1, identity_bytes);
|
||||
let requests = server.requests.lock().await;
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert!(requests.iter().all(|request| request.to_ascii_lowercase().contains("accept-encoding: identity")));
|
||||
drop(requests);
|
||||
assert!(!context.segment_cache.has_active_temp_files().await);
|
||||
assert!(temp_cache_files(temp_dir.path()).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn segment_decoder_failure_exhausts_attempt_budget_without_cache_or_temp_file() {
|
||||
let valid = encode_test_body(b"never committed", TestContentEncoding::Gzip).await;
|
||||
let mut corrupt = valid;
|
||||
corrupt.truncate(corrupt.len() / 2);
|
||||
let server = spawn_sequence_response_server(
|
||||
(0..5).map(|_| TestOriginResponse::encoded("gzip", corrupt.clone())).collect(),
|
||||
)
|
||||
.await;
|
||||
let temp_dir = tempfile::tempdir().expect("temp dir");
|
||||
let policy = SegmentFetchPolicy {
|
||||
retry_delays_ms: [0, 0, 0, 0, 0],
|
||||
retry_jitter_max_ms: 0,
|
||||
..SegmentFetchPolicy::default()
|
||||
};
|
||||
let (worker, context, segment_file) = fetch_context(&server, &temp_dir, &policy).await;
|
||||
clear_scheduled_prefetch(&context, &policy).await;
|
||||
|
||||
let outcome = worker.demand_fetch_and_wait(context.clone(), &segment_file, 20).await;
|
||||
|
||||
assert_eq!(outcome, super::SegmentDemandFetchOutcome::TimedOut);
|
||||
assert_eq!(server.requests.lock().await.len(), 5);
|
||||
let cache_key = context.session.read().await.segments.get(&1).expect("segment").cache_key.clone();
|
||||
assert!(context.segment_cache.metadata(&cache_key).await.expect("metadata reads").is_none());
|
||||
assert!(!context.segment_cache.has_active_temp_files().await);
|
||||
assert!(temp_cache_files(temp_dir.path()).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn segment_decoded_object_limit_is_authoritative_and_non_retryable() {
|
||||
let identity_bytes = vec![b'x'; 512];
|
||||
let encoded = encode_test_body(&identity_bytes, TestContentEncoding::Gzip).await;
|
||||
assert!(encoded.len() < 64, "fixture must be smaller on the wire than the decoded limit");
|
||||
let server = spawn_sequence_response_server(vec![TestOriginResponse::encoded("gzip", encoded)]).await;
|
||||
let temp_dir = tempfile::tempdir().expect("temp dir");
|
||||
let policy = SegmentFetchPolicy {
|
||||
retry_delays_ms: [0, 0, 0, 0, 0],
|
||||
retry_jitter_max_ms: 0,
|
||||
..SegmentFetchPolicy::default()
|
||||
};
|
||||
let (worker, context, segment_file) = fetch_context(&server, &temp_dir, &policy).await;
|
||||
context.segment_cache.update_cache_limits(64, 64);
|
||||
clear_scheduled_prefetch(&context, &policy).await;
|
||||
|
||||
let outcome = worker.demand_fetch_and_wait(context.clone(), &segment_file, 20).await;
|
||||
|
||||
assert_eq!(outcome, super::SegmentDemandFetchOutcome::Unavailable);
|
||||
assert_eq!(server.requests.lock().await.len(), 1);
|
||||
let cache_key = context.session.read().await.segments.get(&1).expect("segment").cache_key.clone();
|
||||
assert!(context.segment_cache.metadata(&cache_key).await.expect("metadata reads").is_none());
|
||||
assert!(!context.segment_cache.has_active_temp_files().await);
|
||||
assert!(temp_cache_files(temp_dir.path()).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn demand_fetch_is_blocked_for_gc_marked_session() {
|
||||
let server = spawn_segment_server(0).await;
|
||||
|
||||
@@ -5,11 +5,11 @@ use super::{
|
||||
CachedSegmentMetadata, HlsAccessLeaseId, HlsCacheObjectKey, HlsSegmentCache, ProxySessionId, StagedCacheObject,
|
||||
};
|
||||
use crate::model::{HlsCorruptSegmentWatchdogConfig, HlsSegmentRepairConfig};
|
||||
use shared::model::{HlsSegmentRepairExecutionPlan, HlsSegmentRepairMode};
|
||||
use arc_swap::ArcSwap;
|
||||
use log::{debug, warn};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use shared::model::{HlsSegmentRepairExecutionPlan, HlsSegmentRepairMode};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet, VecDeque},
|
||||
fmt, io,
|
||||
@@ -542,14 +542,14 @@ impl HlsSegmentRepairManager {
|
||||
segment_cache: &HlsSegmentCache,
|
||||
key: &K,
|
||||
reader: R,
|
||||
deadline: Duration,
|
||||
deadline: tokio::time::Instant,
|
||||
context: HlsSegmentRepairObjectContext,
|
||||
) -> io::Result<CachedSegmentMetadata>
|
||||
where
|
||||
K: HlsCacheObjectKey,
|
||||
R: AsyncRead + Unpin,
|
||||
{
|
||||
let raw = segment_cache.stage_temp_with_timeout(key, reader, deadline).await?;
|
||||
let raw = segment_cache.stage_temp_with_deadline(key, reader, deadline).await?;
|
||||
let runtime = self.runtime.load_full();
|
||||
if !runtime.postprocessing_enabled() {
|
||||
return segment_cache.commit_staged(key, raw).await;
|
||||
@@ -940,7 +940,8 @@ impl HlsSegmentRepairManager {
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let validation = validate_repair(&runtime.config, codec, &raw_scan, &fixed_scan, executed_level, &stream_selection);
|
||||
let validation =
|
||||
validate_repair(&runtime.config, codec, &raw_scan, &fixed_scan, executed_level, &stream_selection);
|
||||
if let Err(reason) = validation {
|
||||
debug_repair_event(context, executed_level, "validation failed", Some(&reason));
|
||||
let _ = fs::remove_file(&fixed_path).await;
|
||||
@@ -1176,10 +1177,7 @@ struct RepairRemuxStreamSelection {
|
||||
#[cfg(test)]
|
||||
impl RepairRemuxStreamSelection {
|
||||
fn preserve_all(probe: &SegmentProbe) -> Self {
|
||||
Self {
|
||||
mapped_streams: probe.streams.iter().map(|stream| stream.index).collect(),
|
||||
dropped_streams: Vec::new(),
|
||||
}
|
||||
Self { mapped_streams: probe.streams.iter().map(|stream| stream.index).collect(), dropped_streams: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1774,10 +1772,8 @@ fn parse_probe(json: &str, warnings: WarningCounters) -> Result<SegmentProbe, St
|
||||
.get("extradata_size")
|
||||
.and_then(Value::as_u64)
|
||||
.or_else(|| stream.get("extradata_size").and_then(Value::as_str).and_then(|value| value.parse().ok()));
|
||||
let stream_index = stream
|
||||
.get("index")
|
||||
.and_then(parse_u32_value)
|
||||
.map_or(probe.streams.len(), |value| value as usize);
|
||||
let stream_index =
|
||||
stream.get("index").and_then(parse_u32_value).map_or(probe.streams.len(), |value| value as usize);
|
||||
let probe_stream = SegmentProbeStream {
|
||||
index: stream_index,
|
||||
stream_type: match codec_type {
|
||||
@@ -1839,18 +1835,12 @@ fn select_repair_remux_streams(probe: &SegmentProbe) -> Result<RepairRemuxStream
|
||||
SegmentProbeStreamType::Audio if valid_audio_stream(stream) => {
|
||||
mapped_streams.push(stream.index);
|
||||
}
|
||||
SegmentProbeStreamType::Video => dropped_streams.push(RepairRemuxDroppedStream {
|
||||
index: stream.index,
|
||||
reason: "invalid-video-parameters",
|
||||
}),
|
||||
SegmentProbeStreamType::Audio => dropped_streams.push(RepairRemuxDroppedStream {
|
||||
index: stream.index,
|
||||
reason: "invalid-audio-parameters",
|
||||
}),
|
||||
SegmentProbeStreamType::Other => dropped_streams.push(RepairRemuxDroppedStream {
|
||||
index: stream.index,
|
||||
reason: "unsupported-stream-type",
|
||||
}),
|
||||
SegmentProbeStreamType::Video => dropped_streams
|
||||
.push(RepairRemuxDroppedStream { index: stream.index, reason: "invalid-video-parameters" }),
|
||||
SegmentProbeStreamType::Audio => dropped_streams
|
||||
.push(RepairRemuxDroppedStream { index: stream.index, reason: "invalid-audio-parameters" }),
|
||||
SegmentProbeStreamType::Other => dropped_streams
|
||||
.push(RepairRemuxDroppedStream { index: stream.index, reason: "unsupported-stream-type" }),
|
||||
}
|
||||
}
|
||||
if !has_video {
|
||||
@@ -2001,10 +1991,10 @@ impl fmt::Display for RepairStatus {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
detect_video_codec, parse_ffmpeg_warnings, parse_probe, repair_object_metadata_key, select_repair_remux_streams,
|
||||
sha256_file, validate_repair, HlsRepairObjectMetadata, HlsRepairRenderedObjectId, HlsSegmentRepairManager,
|
||||
HlsSegmentRepairObjectContext, HlsSegmentRepairSource, RepairIdentity, RepairRemuxStreamSelection,
|
||||
RepairStatus, RepairVideoCodec, WarningCounters, REPAIR_METADATA_MAX_ENTRIES,
|
||||
detect_video_codec, parse_ffmpeg_warnings, parse_probe, repair_object_metadata_key,
|
||||
select_repair_remux_streams, sha256_file, validate_repair, HlsRepairObjectMetadata, HlsRepairRenderedObjectId,
|
||||
HlsSegmentRepairManager, HlsSegmentRepairObjectContext, HlsSegmentRepairSource, RepairIdentity,
|
||||
RepairRemuxStreamSelection, RepairStatus, RepairVideoCodec, WarningCounters, REPAIR_METADATA_MAX_ENTRIES,
|
||||
};
|
||||
use crate::{
|
||||
api::model::{
|
||||
@@ -2013,8 +2003,8 @@ mod tests {
|
||||
},
|
||||
model::HlsSegmentRepairConfig,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use shared::model::HlsSegmentRepairMode;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn repair_config(mode: HlsSegmentRepairMode, apply_to_first_segments: u8) -> HlsSegmentRepairConfig {
|
||||
HlsSegmentRepairConfig {
|
||||
@@ -2550,10 +2540,7 @@ mod tests {
|
||||
|
||||
assert_eq!(selected_repair_mode(&manager, &first).await, Some(HlsSegmentRepairMode::Low));
|
||||
assert_eq!(selected_repair_mode(&manager, &same_rendered_object_other_fetch_uri).await, None);
|
||||
assert_eq!(
|
||||
selected_repair_mode(&manager, &second_rendered_object).await,
|
||||
Some(HlsSegmentRepairMode::Low)
|
||||
);
|
||||
assert_eq!(selected_repair_mode(&manager, &second_rendered_object).await, Some(HlsSegmentRepairMode::Low));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -8,9 +8,9 @@ use super::{
|
||||
CachedSegmentMetadata, HlsCacheObjectKey, HlsSegmentCache, StagedCacheObject,
|
||||
};
|
||||
use crate::model::HlsCorruptSegmentWatchdogConfig;
|
||||
use shared::model::HlsCorruptSegmentWatchdogMode;
|
||||
use log::debug;
|
||||
use serde_json::Value;
|
||||
use shared::model::HlsCorruptSegmentWatchdogMode;
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
ffi::OsString,
|
||||
@@ -261,7 +261,12 @@ impl HlsCorruptSegmentWatchdogManager {
|
||||
Some(format!("unsupported_container:{extension}")),
|
||||
)
|
||||
.await;
|
||||
debug_watchdog_event(context, config.mode, "detected corrupt", Some("action=raw_commit reason=unsupported_container"));
|
||||
debug_watchdog_event(
|
||||
context,
|
||||
config.mode,
|
||||
"detected corrupt",
|
||||
Some("action=raw_commit reason=unsupported_container"),
|
||||
);
|
||||
return segment_cache.commit_staged(key, raw).await;
|
||||
}
|
||||
let fixed_path = watchdog_output_path(&raw.path);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use super::{
|
||||
build_proxy_session_id, classify_account_binding_protection, HlsAccountBindingProtection, HlsAccountOverlapTiming,
|
||||
HlsEffectiveOriginAcquirePolicy, HlsEffectiveOriginAcquirePolicyState, HlsFreshManifestRequiredReason,
|
||||
HlsOriginAccountBinding, HlsOriginAccountIoLease, HlsOriginAccountRebindState, HlsOriginSource, HlsSessionKey,
|
||||
HlsBoundAccountAcquireErrorKind, MapCacheStatus, MapEntry, OriginMapKey, OriginRefreshState, OriginSegmentKey,
|
||||
ProxyMapId, ProxySessionId, RenderPolicy, RenderedManifest, SegmentCacheStatus, SegmentEntry,
|
||||
SegmentFetchPriority, SegmentPrefetchQueue, TransientPassthroughState,
|
||||
HlsBoundAccountAcquireErrorKind, HlsEffectiveOriginAcquirePolicy, HlsEffectiveOriginAcquirePolicyState,
|
||||
HlsFreshManifestRequiredReason, HlsOriginAccountBinding, HlsOriginAccountIoLease, HlsOriginAccountRebindState,
|
||||
HlsOriginSource, HlsSessionKey, MapCacheStatus, MapEntry, OriginMapKey, OriginRefreshState, OriginSegmentKey,
|
||||
ProxyMapId, ProxySessionId, RenderPolicy, RenderedManifest, SegmentCacheStatus, SegmentEntry, SegmentFetchPriority,
|
||||
SegmentPrefetchQueue, TransientPassthroughState,
|
||||
};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use std::{
|
||||
|
||||
@@ -271,10 +271,10 @@ impl TimelineDraft {
|
||||
self.target_duration = manifest.target_duration;
|
||||
self.independent_segments = manifest.independent_segments;
|
||||
self.apply_forward_jump(manifest)?;
|
||||
let epoch_transition_discontinuity = self.apply_origin_epoch_transition_for_manifest(manifest, origin_epoch_handoff)?;
|
||||
let epoch_transition_discontinuity =
|
||||
self.apply_origin_epoch_transition_for_manifest(manifest, origin_epoch_handoff)?;
|
||||
|
||||
let mut mark_handoff_discontinuity =
|
||||
handoff_discontinuity_sequence.is_some() || epoch_transition_discontinuity;
|
||||
let mut mark_handoff_discontinuity = handoff_discontinuity_sequence.is_some() || epoch_transition_discontinuity;
|
||||
let mut manifest_head_proxy_seq = None;
|
||||
let mut manifest_tail_proxy_seq = None;
|
||||
for parsed in &manifest.segments {
|
||||
@@ -295,19 +295,15 @@ impl TimelineDraft {
|
||||
origin_epoch_handoff: bool,
|
||||
) -> Result<bool, TimelineMapError> {
|
||||
if origin_epoch_handoff && self.should_start_new_origin_epoch_for_handoff(manifest) {
|
||||
let next_origin_seq = manifest
|
||||
.segments
|
||||
.first()
|
||||
.map_or(manifest.origin_manifest_sequence, |segment| segment.origin_seq);
|
||||
let next_origin_seq =
|
||||
manifest.segments.first().map_or(manifest.origin_manifest_sequence, |segment| segment.origin_seq);
|
||||
self.start_new_origin_epoch(next_origin_seq, OriginEpochTransitionReason::Handoff)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if self.should_start_new_origin_epoch_for_rollover(manifest) {
|
||||
let next_origin_seq = manifest
|
||||
.segments
|
||||
.first()
|
||||
.map_or(manifest.origin_manifest_sequence, |segment| segment.origin_seq);
|
||||
let next_origin_seq =
|
||||
manifest.segments.first().map_or(manifest.origin_manifest_sequence, |segment| segment.origin_seq);
|
||||
self.start_new_origin_epoch(next_origin_seq, OriginEpochTransitionReason::Rollover)?;
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -484,9 +480,7 @@ fn proxy_extension_from_url(url: &str, allowed_extensions: &[&str]) -> Option<St
|
||||
mod tests {
|
||||
use super::{SegmentCacheStatus, TimelineMapError};
|
||||
use crate::{
|
||||
api::model::{
|
||||
HlsSession, HlsSessionKey, MapCacheStatus, OriginSegmentKey, ProxyMapId, SegmentFetchPriority,
|
||||
},
|
||||
api::model::{HlsSession, HlsSessionKey, MapCacheStatus, OriginSegmentKey, ProxyMapId, SegmentFetchPriority},
|
||||
processing::parser::hls::origin_manifest::{parse_origin_media_manifest, OriginManifestParseOutcome},
|
||||
};
|
||||
|
||||
@@ -654,9 +648,7 @@ mod tests {
|
||||
#[test]
|
||||
fn host_handoff_starts_new_epoch_when_range_overlaps_highwater() {
|
||||
let mut session = session();
|
||||
let first = normal_manifest(
|
||||
"#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:100\n#EXTINF:4.0,\n100.ts\n#EXTINF:4.0,\n101.ts\n",
|
||||
);
|
||||
let first = normal_manifest("#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:100\n#EXTINF:4.0,\n100.ts\n#EXTINF:4.0,\n101.ts\n");
|
||||
let second = normal_manifest(
|
||||
"#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:99\n#EXTINF:4.0,\n99.ts\n#EXTINF:4.0,\n100.ts\n#EXTINF:4.0,\n101.ts\n",
|
||||
);
|
||||
|
||||
@@ -39,6 +39,7 @@ pub enum TransientResourceKind {
|
||||
Segment,
|
||||
Key,
|
||||
Map,
|
||||
Part,
|
||||
Other,
|
||||
}
|
||||
|
||||
@@ -230,12 +231,7 @@ impl TransientPassthroughState {
|
||||
self.last_manifest_valid_until_ms = None;
|
||||
}
|
||||
|
||||
pub fn replace_manifest_with_validity(
|
||||
&mut self,
|
||||
body: String,
|
||||
rendered_at_ms: u64,
|
||||
playlist_duration_ms: u64,
|
||||
) {
|
||||
pub fn replace_manifest_with_validity(&mut self, body: String, rendered_at_ms: u64, playlist_duration_ms: u64) {
|
||||
self.last_manifest_body = Some(body);
|
||||
self.last_manifest_rendered_at_ms = Some(rendered_at_ms);
|
||||
self.last_manifest_playlist_duration_ms = Some(playlist_duration_ms);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4250,6 +4250,7 @@ mod tests {
|
||||
input_name: Arc::from("input"),
|
||||
channel_no: 0,
|
||||
source_ordinal: 0,
|
||||
input_stream_id: Arc::from("42"),
|
||||
};
|
||||
|
||||
InputWorker::apply_pending_generic_probe_base(&batch, XtreamCluster::Video, 42, &mut item);
|
||||
|
||||
@@ -23,7 +23,10 @@ mod streams;
|
||||
mod update_guard;
|
||||
mod xtream;
|
||||
|
||||
pub(crate) use self::streams::*;
|
||||
#[cfg(test)]
|
||||
pub(in crate::api) use self::hls_provisioning::{
|
||||
build_hls_custom_video_manifest_body, hls_panel_provisioning_manifest_path,
|
||||
};
|
||||
pub use self::{
|
||||
active_provider_manager::*, app_state::*, connection_manager::*, event_manager::*, hls_cache::*,
|
||||
hls_provisioning::HlsProvisioningState, metadata_update_manager::*, playlist_mem_cache::*, provider_dns_manager::*,
|
||||
@@ -37,8 +40,7 @@ pub(in crate::api) use self::{
|
||||
hls_custom_video_manifest_path, hls_custom_video_manifest_response_with_virtual_id,
|
||||
hls_provisioning_discontinuity_sequence, hls_virtual_entry_redirect_response,
|
||||
parse_hls_panel_provisioning_segment_route_name, start_hls_panel_provisioning_once,
|
||||
try_hls_panel_provisioning_manifest_response, HlsPanelProvisioningRedirectPaths,
|
||||
HlsProvisioningStatus,
|
||||
try_hls_panel_provisioning_manifest_response, HlsPanelProvisioningRedirectPaths, HlsProvisioningStatus,
|
||||
},
|
||||
model_utils::*,
|
||||
provider_config::*,
|
||||
@@ -48,9 +50,9 @@ pub(in crate::api) use self::{
|
||||
stream_error::*,
|
||||
xtream::*,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(in crate::api) use self::hls_provisioning::{
|
||||
build_hls_custom_video_manifest_body, hls_panel_provisioning_manifest_path,
|
||||
pub(crate) use self::{
|
||||
hls_cache::{log_hls_origin_content_coding, HlsOriginContentCodingObjectKind, HlsOriginContentCodingSource},
|
||||
streams::*,
|
||||
};
|
||||
mod batch_result_collector;
|
||||
pub use self::batch_result_collector::*;
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
use reqwest::{header::HeaderMap, StatusCode};
|
||||
use crate::{
|
||||
api::model::{ProviderContentRepresentationMode, ProviderStreamHeader},
|
||||
utils::content_coding::parse_content_encoding_tokens,
|
||||
};
|
||||
use reqwest::{
|
||||
header::{HeaderMap, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, TRANSFER_ENCODING},
|
||||
StatusCode,
|
||||
};
|
||||
use shared::utils::filter_response_header;
|
||||
use std::{collections::HashSet, str::FromStr};
|
||||
|
||||
@@ -10,6 +17,96 @@ pub fn get_response_headers(headers: &HeaderMap) -> Vec<(String, String)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Failures raised while selecting representation-sensitive provider response headers.
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
|
||||
pub(crate) enum ProviderResponseHeaderError {
|
||||
#[error("invalid Content-Encoding header")]
|
||||
InvalidContentEncoding,
|
||||
|
||||
#[error("invalid Content-Length header")]
|
||||
InvalidContentLength,
|
||||
|
||||
#[error("invalid Content-Range header")]
|
||||
InvalidContentRange,
|
||||
}
|
||||
|
||||
/// Selects representation-consistent provider headers without widening the global response allowlist.
|
||||
pub(crate) fn provider_response_headers(
|
||||
headers: &HeaderMap,
|
||||
mode: ProviderContentRepresentationMode,
|
||||
) -> Result<ProviderStreamHeader, ProviderResponseHeaderError> {
|
||||
let mut selected = headers
|
||||
.iter()
|
||||
.filter(|(key, _)| {
|
||||
let name = key.as_str();
|
||||
name != CONTENT_ENCODING.as_str()
|
||||
&& name != CONTENT_LENGTH.as_str()
|
||||
&& name != CONTENT_RANGE.as_str()
|
||||
&& name != TRANSFER_ENCODING.as_str()
|
||||
})
|
||||
.filter(|(key, _)| filter_response_header(key.as_str()))
|
||||
.filter_map(|(key, value)| value.to_str().ok().map(|value| (key.to_string(), value.to_string())))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if matches!(mode, ProviderContentRepresentationMode::PreserveOrigin) {
|
||||
let content_encoding =
|
||||
parse_content_encoding_tokens(headers).map_err(|_| ProviderResponseHeaderError::InvalidContentEncoding)?;
|
||||
if !content_encoding.is_empty() {
|
||||
selected.push((CONTENT_ENCODING.as_str().to_owned(), content_encoding.join(", ")));
|
||||
}
|
||||
}
|
||||
if let Some(content_length) = validated_content_length(headers)? {
|
||||
selected.push((CONTENT_LENGTH.as_str().to_owned(), content_length));
|
||||
}
|
||||
if let Some(content_range) = validated_content_range(headers)? {
|
||||
selected.push((CONTENT_RANGE.as_str().to_owned(), content_range));
|
||||
}
|
||||
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
fn validated_content_length(headers: &HeaderMap) -> Result<Option<String>, ProviderResponseHeaderError> {
|
||||
let mut content_length = None;
|
||||
|
||||
for value in headers.get_all(CONTENT_LENGTH) {
|
||||
let value = value.to_str().map_err(|_| ProviderResponseHeaderError::InvalidContentLength)?;
|
||||
for value in value.split(',') {
|
||||
let value = value.trim_matches(|character| matches!(character, ' ' | '\t'));
|
||||
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(ProviderResponseHeaderError::InvalidContentLength);
|
||||
}
|
||||
let value = value.parse::<u64>().map_err(|_| ProviderResponseHeaderError::InvalidContentLength)?;
|
||||
match content_length {
|
||||
Some(expected) if value != expected => {
|
||||
return Err(ProviderResponseHeaderError::InvalidContentLength);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => content_length = Some(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(content_length.map(|value| value.to_string()))
|
||||
}
|
||||
|
||||
fn validated_content_range(headers: &HeaderMap) -> Result<Option<String>, ProviderResponseHeaderError> {
|
||||
let mut values = headers.get_all(CONTENT_RANGE).iter();
|
||||
let Some(value) = values.next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if values.next().is_some() {
|
||||
return Err(ProviderResponseHeaderError::InvalidContentRange);
|
||||
}
|
||||
|
||||
let value = value.to_str().map_err(|_| ProviderResponseHeaderError::InvalidContentRange)?;
|
||||
let value = value.trim_matches(|character| matches!(character, ' ' | '\t'));
|
||||
if value.is_empty() || value.contains(',') {
|
||||
return Err(ProviderResponseHeaderError::InvalidContentRange);
|
||||
}
|
||||
Ok(Some(value.to_owned()))
|
||||
}
|
||||
|
||||
pub fn get_stream_response_with_headers(
|
||||
custom: Option<(Vec<(String, String)>, StatusCode)>,
|
||||
) -> (axum::http::StatusCode, axum::http::HeaderMap) {
|
||||
@@ -20,6 +117,9 @@ pub fn get_stream_response_with_headers(
|
||||
if let Some((custom_headers, status_code)) = custom {
|
||||
status = status_code;
|
||||
for (key, value) in custom_headers {
|
||||
if key.eq_ignore_ascii_case(TRANSFER_ENCODING.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if let (Ok(name), Ok(val)) =
|
||||
(axum::http::HeaderName::from_str(&key), axum::http::HeaderValue::from_str(&value))
|
||||
{
|
||||
@@ -47,3 +147,222 @@ pub fn get_stream_response_with_headers(
|
||||
|
||||
(status, headers)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::utils::content_coding::normalize_headers_after_content_decoding;
|
||||
use reqwest::header::{
|
||||
ACCEPT_RANGES, AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE, ETAG, PROXY_AUTHENTICATE, SET_COOKIE,
|
||||
WWW_AUTHENTICATE,
|
||||
};
|
||||
|
||||
fn selected_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
|
||||
headers.iter().find_map(|(key, value)| key.eq_ignore_ascii_case(name).then_some(value.as_str()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_preserve_origin_keeps_representation_headers_and_drops_unsafe_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(CONTENT_ENCODING, "gzip".parse().unwrap());
|
||||
headers.append(CONTENT_ENCODING, "br".parse().unwrap());
|
||||
headers.insert(CONTENT_LENGTH, "321".parse().unwrap());
|
||||
headers.insert(CONTENT_RANGE, "bytes 10-330/1000".parse().unwrap());
|
||||
headers.insert(TRANSFER_ENCODING, "chunked".parse().unwrap());
|
||||
headers.insert(SET_COOKIE, "provider_session=secret".parse().unwrap());
|
||||
headers.insert(AUTHORIZATION, "Bearer secret".parse().unwrap());
|
||||
headers.insert("x-provider-secret", "secret".parse().unwrap());
|
||||
|
||||
let selected = provider_response_headers(&headers, ProviderContentRepresentationMode::PreserveOrigin)
|
||||
.expect("valid provider headers");
|
||||
|
||||
assert_eq!(selected_value(&selected, CONTENT_ENCODING.as_str()), Some("gzip, br"));
|
||||
assert_eq!(selected_value(&selected, CONTENT_LENGTH.as_str()), Some("321"));
|
||||
assert_eq!(selected_value(&selected, CONTENT_RANGE.as_str()), Some("bytes 10-330/1000"));
|
||||
for rejected in [TRANSFER_ENCODING.as_str(), SET_COOKIE.as_str(), AUTHORIZATION.as_str(), "x-provider-secret"] {
|
||||
assert_eq!(selected_value(&selected, rejected), None, "unexpected header {rejected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_preserve_origin_keeps_unknown_valid_content_coding() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(CONTENT_ENCODING, "gzip, X-Provider-Coding".parse().unwrap());
|
||||
headers.append(CONTENT_ENCODING, "compress".parse().unwrap());
|
||||
|
||||
let selected = provider_response_headers(&headers, ProviderContentRepresentationMode::PreserveOrigin)
|
||||
.expect("valid provider headers");
|
||||
|
||||
assert_eq!(selected_value(&selected, CONTENT_ENCODING.as_str()), Some("gzip, X-Provider-Coding, compress"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_preserve_origin_rejects_malformed_or_non_text_content_encoding() {
|
||||
for value in [
|
||||
"gzip,,br".parse().unwrap(),
|
||||
reqwest::header::HeaderValue::from_bytes(&[0xff]).expect("opaque header value"),
|
||||
] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_ENCODING, value);
|
||||
|
||||
assert_eq!(
|
||||
provider_response_headers(&headers, ProviderContentRepresentationMode::PreserveOrigin),
|
||||
Err(ProviderResponseHeaderError::InvalidContentEncoding)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_preserve_origin_canonicalizes_identical_content_lengths() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(CONTENT_LENGTH, "00321".parse().unwrap());
|
||||
headers.append(CONTENT_LENGTH, "321, 321".parse().unwrap());
|
||||
|
||||
let selected = provider_response_headers(&headers, ProviderContentRepresentationMode::PreserveOrigin)
|
||||
.expect("matching content lengths");
|
||||
|
||||
assert_eq!(selected_value(&selected, CONTENT_LENGTH.as_str()), Some("321"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_preserve_origin_rejects_invalid_or_conflicting_content_lengths() {
|
||||
for values in [&["321", "322"][..], &["32x"][..], &[""][..]] {
|
||||
let mut headers = HeaderMap::new();
|
||||
for value in values {
|
||||
headers.append(CONTENT_LENGTH, (*value).parse().unwrap());
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
provider_response_headers(&headers, ProviderContentRepresentationMode::PreserveOrigin),
|
||||
Err(ProviderResponseHeaderError::InvalidContentLength)
|
||||
);
|
||||
}
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_LENGTH, reqwest::header::HeaderValue::from_bytes(&[0xff]).expect("opaque header value"));
|
||||
assert_eq!(
|
||||
provider_response_headers(&headers, ProviderContentRepresentationMode::PreserveOrigin),
|
||||
Err(ProviderResponseHeaderError::InvalidContentLength)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_preserve_origin_rejects_ambiguous_or_non_text_content_range() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(CONTENT_RANGE, "bytes 0-9/20".parse().unwrap());
|
||||
headers.append(CONTENT_RANGE, "bytes 10-19/20".parse().unwrap());
|
||||
assert_eq!(
|
||||
provider_response_headers(&headers, ProviderContentRepresentationMode::PreserveOrigin),
|
||||
Err(ProviderResponseHeaderError::InvalidContentRange)
|
||||
);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_RANGE, reqwest::header::HeaderValue::from_bytes(&[0xff]).expect("opaque header value"));
|
||||
assert_eq!(
|
||||
provider_response_headers(&headers, ProviderContentRepresentationMode::PreserveOrigin),
|
||||
Err(ProviderResponseHeaderError::InvalidContentRange)
|
||||
);
|
||||
|
||||
for value in ["", "bytes 0-9/20, bytes 10-19/20"] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_RANGE, value.parse().unwrap());
|
||||
assert_eq!(
|
||||
provider_response_headers(&headers, ProviderContentRepresentationMode::PreserveOrigin),
|
||||
Err(ProviderResponseHeaderError::InvalidContentRange)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_identity_keeps_untransformed_content_length_and_content_range() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_LENGTH, "10".parse().unwrap());
|
||||
headers.insert(CONTENT_RANGE, "bytes 10-19/100".parse().unwrap());
|
||||
headers.insert(TRANSFER_ENCODING, "chunked".parse().unwrap());
|
||||
|
||||
let selected = provider_response_headers(&headers, ProviderContentRepresentationMode::Identity)
|
||||
.expect("valid untransformed identity headers");
|
||||
|
||||
assert_eq!(selected_value(&selected, CONTENT_LENGTH.as_str()), Some("10"));
|
||||
assert_eq!(selected_value(&selected, CONTENT_RANGE.as_str()), Some("bytes 10-19/100"));
|
||||
assert_eq!(selected_value(&selected, CONTENT_ENCODING.as_str()), None);
|
||||
assert_eq!(selected_value(&selected, TRANSFER_ENCODING.as_str()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_identity_rejects_invalid_untransformed_length_and_range() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_LENGTH, "32x".parse().unwrap());
|
||||
assert_eq!(
|
||||
provider_response_headers(&headers, ProviderContentRepresentationMode::Identity),
|
||||
Err(ProviderResponseHeaderError::InvalidContentLength)
|
||||
);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(CONTENT_RANGE, "bytes 0-9/20".parse().unwrap());
|
||||
headers.append(CONTENT_RANGE, "bytes 10-19/20".parse().unwrap());
|
||||
assert_eq!(
|
||||
provider_response_headers(&headers, ProviderContentRepresentationMode::Identity),
|
||||
Err(ProviderResponseHeaderError::InvalidContentRange)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_identity_uses_only_safe_normalized_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_ENCODING, "zstd".parse().unwrap());
|
||||
headers.insert(CONTENT_LENGTH, "99".parse().unwrap());
|
||||
headers.insert(CONTENT_RANGE, "bytes 0-98/99".parse().unwrap());
|
||||
headers.insert(ACCEPT_RANGES, "bytes".parse().unwrap());
|
||||
headers.insert(ETAG, "\"encoded\"".parse().unwrap());
|
||||
headers.insert(TRANSFER_ENCODING, "chunked".parse().unwrap());
|
||||
headers.insert(CONTENT_TYPE, "video/mp2t".parse().unwrap());
|
||||
headers.insert(CACHE_CONTROL, "public, max-age=30".parse().unwrap());
|
||||
headers.insert(SET_COOKIE, "provider_session=secret".parse().unwrap());
|
||||
headers.insert(PROXY_AUTHENTICATE, "Basic realm=provider".parse().unwrap());
|
||||
headers.insert(WWW_AUTHENTICATE, "Basic realm=provider".parse().unwrap());
|
||||
headers.insert("x-provider-secret", "secret".parse().unwrap());
|
||||
normalize_headers_after_content_decoding(&mut headers);
|
||||
|
||||
let selected =
|
||||
provider_response_headers(&headers, ProviderContentRepresentationMode::for_playback_extension(".m3u8"))
|
||||
.expect("valid normalized provider headers");
|
||||
|
||||
assert_eq!(selected_value(&selected, CONTENT_TYPE.as_str()), Some("video/mp2t"));
|
||||
assert_eq!(selected_value(&selected, CACHE_CONTROL.as_str()), Some("public, max-age=30"));
|
||||
for rejected in [
|
||||
CONTENT_ENCODING.as_str(),
|
||||
CONTENT_LENGTH.as_str(),
|
||||
CONTENT_RANGE.as_str(),
|
||||
ACCEPT_RANGES.as_str(),
|
||||
ETAG.as_str(),
|
||||
TRANSFER_ENCODING.as_str(),
|
||||
SET_COOKIE.as_str(),
|
||||
PROXY_AUTHENTICATE.as_str(),
|
||||
WWW_AUTHENTICATE.as_str(),
|
||||
"x-provider-secret",
|
||||
] {
|
||||
assert_eq!(selected_value(&selected, rejected), None, "unexpected header {rejected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuilt_stream_responses_never_forward_transfer_encoding() {
|
||||
let mut origin_headers = HeaderMap::new();
|
||||
origin_headers.insert(TRANSFER_ENCODING, "chunked".parse().unwrap());
|
||||
origin_headers.insert(CONTENT_TYPE, "video/mp2t".parse().unwrap());
|
||||
|
||||
let selected = get_response_headers(&origin_headers);
|
||||
assert_eq!(selected_value(&selected, TRANSFER_ENCODING.as_str()), None);
|
||||
|
||||
let (_, rebuilt) = get_stream_response_with_headers(Some((
|
||||
vec![
|
||||
(TRANSFER_ENCODING.as_str().to_owned(), "chunked".to_owned()),
|
||||
(CONTENT_TYPE.as_str().to_owned(), "video/mp2t".to_owned()),
|
||||
],
|
||||
StatusCode::OK,
|
||||
)));
|
||||
assert!(!rebuilt.contains_key(TRANSFER_ENCODING));
|
||||
assert_eq!(rebuilt[CONTENT_TYPE], "video/mp2t");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ use crate::{
|
||||
use axum::http::StatusCode;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::BoxStream;
|
||||
use shared::model::{PlaylistItemType, StreamChannel};
|
||||
use shared::{
|
||||
defaults::HLS_EXT,
|
||||
model::{PlaylistItemType, StreamChannel},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use url::Url;
|
||||
@@ -18,6 +21,24 @@ pub type ProviderStreamResponse = (Option<BoxedProviderStream>, ProviderStreamIn
|
||||
|
||||
pub type ProviderStreamFactoryResponse = (BoxedProviderStream, ProviderStreamInfo);
|
||||
|
||||
/// Controls whether a provider stream preserves its origin representation or normalizes it to identity bytes.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub(crate) enum ProviderContentRepresentationMode {
|
||||
#[default]
|
||||
PreserveOrigin,
|
||||
Identity,
|
||||
}
|
||||
|
||||
impl ProviderContentRepresentationMode {
|
||||
pub(crate) fn for_playback_extension(extension: &str) -> Self {
|
||||
if extension.eq_ignore_ascii_case(HLS_EXT) {
|
||||
Self::Identity
|
||||
} else {
|
||||
Self::PreserveOrigin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn uses_direct_body_idle_timeout(stream_channel: &StreamChannel) -> bool {
|
||||
!stream_channel.shared
|
||||
&& matches!(
|
||||
@@ -39,10 +60,7 @@ pub enum ProviderStreamCustomReason {
|
||||
}
|
||||
|
||||
pub enum ProviderStreamState {
|
||||
Custom {
|
||||
response: ProviderStreamResponse,
|
||||
reason: ProviderStreamCustomReason,
|
||||
},
|
||||
Custom { response: ProviderStreamResponse, reason: ProviderStreamCustomReason },
|
||||
Available(Option<ProviderName>, StreamUrl),
|
||||
GracePeriod(Option<ProviderName>, StreamUrl),
|
||||
}
|
||||
@@ -58,6 +76,7 @@ pub struct StreamDetails {
|
||||
pub disable_provider_grace: bool,
|
||||
pub reconnect_flag: Option<CancellationToken>,
|
||||
pub provider_handle: Option<ProviderHandle>,
|
||||
pub(crate) content_representation: ProviderContentRepresentationMode,
|
||||
/// Set when the stream was admitted via a user-grace strategy. Carried through to
|
||||
/// `stream_grace_period` so remaining strategies can be evaluated if the grace fails.
|
||||
pub(crate) grace_resolution_context: Option<crate::api::api_utils::GraceResolutionContext>,
|
||||
@@ -79,6 +98,7 @@ impl Clone for StreamDetails {
|
||||
disable_provider_grace: self.disable_provider_grace,
|
||||
reconnect_flag: self.reconnect_flag.clone(),
|
||||
provider_handle: self.provider_handle.clone(),
|
||||
content_representation: self.content_representation,
|
||||
grace_resolution_context: self.grace_resolution_context.clone(),
|
||||
}
|
||||
}
|
||||
@@ -97,6 +117,7 @@ impl StreamDetails {
|
||||
disable_provider_grace: false,
|
||||
reconnect_flag: None,
|
||||
provider_handle: None,
|
||||
content_representation: ProviderContentRepresentationMode::PreserveOrigin,
|
||||
grace_resolution_context: None,
|
||||
}
|
||||
}
|
||||
@@ -122,3 +143,26 @@ pub struct StreamingStrategy {
|
||||
pub provider_stream_state: ProviderStreamState,
|
||||
pub input_headers: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ProviderContentRepresentationMode;
|
||||
|
||||
#[test]
|
||||
fn provider_representation_mode_uses_hls_extension_not_playlist_item_type() {
|
||||
assert_eq!(
|
||||
ProviderContentRepresentationMode::for_playback_extension(".m3u8"),
|
||||
ProviderContentRepresentationMode::Identity
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderContentRepresentationMode::for_playback_extension(".M3U8"),
|
||||
ProviderContentRepresentationMode::Identity
|
||||
);
|
||||
for extension in [".ts", ".mp4", ".mkv", ""] {
|
||||
assert_eq!(
|
||||
ProviderContentRepresentationMode::for_playback_extension(extension),
|
||||
ProviderContentRepresentationMode::PreserveOrigin
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
pub enum StreamError {
|
||||
Reqwest { message: String, class: &'static str, status: Option<u16> },
|
||||
StdIo(String),
|
||||
ContentDecoding(String),
|
||||
// ReceiverClosed,
|
||||
ReceiverError(BroadcastStreamRecvError),
|
||||
LockError(String),
|
||||
@@ -81,6 +82,7 @@ impl StreamError {
|
||||
match self {
|
||||
Self::Reqwest { class, .. } => class,
|
||||
Self::StdIo(_) => "io",
|
||||
Self::ContentDecoding(_) => "content_decoding",
|
||||
Self::ReceiverError(_) => "receiver",
|
||||
Self::LockError(_) => "lock",
|
||||
Self::Stream(_) => "stream",
|
||||
@@ -105,6 +107,7 @@ impl std::fmt::Display for StreamError {
|
||||
match self {
|
||||
StreamError::Reqwest { message, .. } => write!(f, "Reqwest error: {message}"),
|
||||
StreamError::StdIo(e) => write!(f, "IO error: {e}"),
|
||||
StreamError::ContentDecoding(e) => write!(f, "Content decoding error: {e}"),
|
||||
// StreamError::ReceiverClosed => write!(f, "Receiver closed"),
|
||||
StreamError::ReceiverError(e) => write!(f, "Receiver error {e}"),
|
||||
StreamError::Stream(e) => write!(f, "Stream: {e}"),
|
||||
|
||||
@@ -2,11 +2,11 @@ use crate::{
|
||||
api::{
|
||||
api_utils::get_stream_options,
|
||||
model::{
|
||||
AppState, BoxedProviderStream, CleanupEvent, ConnectionManager, CustomVideoStreamType, EventManager,
|
||||
MeteringStream, PendingProviderWakeSource, ProviderHandle, ProviderStreamFactoryOptions, StreamDetails,
|
||||
StreamError, StreamMeterHandle, TimedClientStream, TransportStreamBuffer,
|
||||
connection_manager::{PROVIDER_END_CLOSED, PROVIDER_END_ERROR, PROVIDER_END_NOT_SET},
|
||||
create_provider_stream, uses_direct_body_idle_timeout,
|
||||
create_provider_stream, uses_direct_body_idle_timeout, AppState, BoxedProviderStream, CleanupEvent,
|
||||
ConnectionManager, CustomVideoStreamType, EventManager, MeteringStream, PendingProviderWakeSource,
|
||||
ProviderHandle, ProviderStreamFactoryOptions, StreamDetails, StreamError, StreamMeterHandle,
|
||||
TimedClientStream, TransportStreamBuffer,
|
||||
},
|
||||
panel_api::{can_provision_on_exhausted, find_input_by_provider_name, run_panel_api_provisioning_probe},
|
||||
},
|
||||
@@ -14,22 +14,20 @@ use crate::{
|
||||
model::{ConfigInput, ProxyUserCredentials},
|
||||
utils::debug_if_enabled,
|
||||
};
|
||||
use axum::http::{HeaderMap, header::USER_AGENT};
|
||||
use axum::http::{header::USER_AGENT, HeaderMap};
|
||||
use bytes::Bytes;
|
||||
use futures::{Future, Stream, StreamExt, task::AtomicWaker};
|
||||
use futures::{task::AtomicWaker, Future, Stream, StreamExt};
|
||||
use log::{error, info, warn};
|
||||
use shared::model::FailureStage;
|
||||
use shared::utils::Internable;
|
||||
use shared::{
|
||||
model::{StreamChannel, UserConnectionPermission, VirtualId},
|
||||
utils::sanitize_sensitive_info,
|
||||
model::{FailureStage, StreamChannel, UserConnectionPermission, VirtualId},
|
||||
utils::{sanitize_sensitive_info, Internable},
|
||||
};
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
pin::Pin,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU8, Ordering},
|
||||
Arc,
|
||||
},
|
||||
task::{Context, Poll},
|
||||
};
|
||||
@@ -170,9 +168,7 @@ impl DirectBodyIdleTimeout {
|
||||
Self { enabled: false, deadline: None, sleep: None, last_socket_activity_touch: None }
|
||||
}
|
||||
|
||||
const fn enabled() -> Self {
|
||||
Self { enabled: true, deadline: None, sleep: None, last_socket_activity_touch: None }
|
||||
}
|
||||
const fn enabled() -> Self { Self { enabled: true, deadline: None, sleep: None, last_socket_activity_touch: None } }
|
||||
|
||||
fn mark_progress(&mut self) -> bool {
|
||||
if !self.enabled {
|
||||
@@ -528,7 +524,11 @@ fn wrap_timed_client_stream_if_needed(
|
||||
None => stream,
|
||||
Some(mins) => {
|
||||
let secs = u32::try_from((u64::from(mins) * 60).min(u64::from(u32::MAX))).unwrap_or(0);
|
||||
if secs > 0 { TimedClientStream::new(app_state, stream, secs, addr, virtual_id).boxed() } else { stream }
|
||||
if secs > 0 {
|
||||
TimedClientStream::new(app_state, stream, secs, addr, virtual_id).boxed()
|
||||
} else {
|
||||
stream
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -567,7 +567,9 @@ fn create_deferred_provider_open_future(
|
||||
client_ip: Some(&fingerprint.client_ip),
|
||||
stream_channel: Some(stream_channel),
|
||||
connect_failure_stage: Some(FailureStage::ProviderOpen),
|
||||
});
|
||||
content_representation: stream_details.content_representation,
|
||||
})
|
||||
.for_deferred_open();
|
||||
provider_stream_factory_options.set_provider(input.get_resolve_provider(stream_url.as_ref()));
|
||||
|
||||
Some(DeferredProviderOpenState::Pending(Box::new(DeferredProviderOpenContext {
|
||||
@@ -797,9 +799,7 @@ impl Stream for ActiveClientStream {
|
||||
}
|
||||
|
||||
impl Drop for ActiveClientStream {
|
||||
fn drop(&mut self) {
|
||||
self.state.release_stream_and_provider_handle_once();
|
||||
}
|
||||
fn drop(&mut self) { self.state.release_stream_and_provider_handle_once(); }
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
@@ -1334,18 +1334,20 @@ fn stream_grace_period(request: GracePeriodParams) -> (Option<Arc<AtomicU8>>, Op
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ActiveClientStream, ActiveClientStreamParams, ActiveClientStreamState, CustomVideoBuffers,
|
||||
DIRECT_BODY_IDLE_TIMEOUT_SECS, DeferredProviderOpenOutcome, DeferredProviderOpenState, DirectBodyIdleTimeout,
|
||||
GracePeriodParams, StreamMode, TimedStreamContext, create_active_client_stream,
|
||||
should_use_direct_body_idle_timeout, stream_grace_period,
|
||||
create_active_client_stream, create_deferred_provider_open_future, should_use_direct_body_idle_timeout,
|
||||
stream_grace_period, ActiveClientStream, ActiveClientStreamParams, ActiveClientStreamState, CustomVideoBuffers,
|
||||
DeferredProviderOpenOutcome, DeferredProviderOpenState, DirectBodyIdleTimeout, GracePeriodParams, StreamMode,
|
||||
TimedStreamContext, DIRECT_BODY_IDLE_TIMEOUT_SECS,
|
||||
};
|
||||
use crate::api::api_utils::GraceResolutionContext;
|
||||
use crate::api::model::connection_manager::PROVIDER_END_NOT_SET;
|
||||
use crate::{
|
||||
api::model::{
|
||||
ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, ConnectionManager,
|
||||
CreateUserSessionParams, CustomVideoStreamType, DownloadQueue, EventManager, MetadataUpdateManager,
|
||||
PlaylistStorageState, SharedStreamManager, StreamDetails, StreamError, UpdateGuard,
|
||||
api::{
|
||||
api_utils::GraceResolutionContext,
|
||||
model::{
|
||||
connection_manager::PROVIDER_END_NOT_SET, ActiveProviderManager, ActiveUserManager, AppState,
|
||||
CancelTokens, ConnectionManager, CreateUserSessionParams, CustomVideoStreamType, DownloadQueue,
|
||||
EventManager, MetadataUpdateManager, PlaylistStorageState, ProviderContentRepresentationMode,
|
||||
SharedStreamManager, StreamDetails, StreamError, UpdateGuard,
|
||||
},
|
||||
},
|
||||
auth::Fingerprint,
|
||||
model::{
|
||||
@@ -1357,7 +1359,7 @@ mod tests {
|
||||
use arc_swap::{ArcSwap, ArcSwapOption};
|
||||
use axum::http::HeaderMap;
|
||||
use bytes::Bytes;
|
||||
use futures::{StreamExt, pin_mut};
|
||||
use futures::{pin_mut, StreamExt};
|
||||
use reqwest::Client;
|
||||
use shared::{
|
||||
model::{
|
||||
@@ -1369,8 +1371,8 @@ mod tests {
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU8, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
@@ -1610,15 +1612,12 @@ mod tests {
|
||||
provider_name: Some(Arc::clone(provider_name)),
|
||||
request_url: Some("http://provider-1.example/live/1".intern()),
|
||||
session_headers: None,
|
||||
grace_period: GracePeriodOptions {
|
||||
period_millis: 100,
|
||||
timeout_secs: 0,
|
||||
hold_stream: true,
|
||||
},
|
||||
grace_period: GracePeriodOptions { period_millis: 100, timeout_secs: 0, hold_stream: true },
|
||||
provider_grace_active: true,
|
||||
disable_provider_grace: false,
|
||||
reconnect_flag: None,
|
||||
provider_handle: Some(provider_handle),
|
||||
content_representation: ProviderContentRepresentationMode::PreserveOrigin,
|
||||
grace_resolution_context: None,
|
||||
}
|
||||
}
|
||||
@@ -1979,6 +1978,49 @@ mod tests {
|
||||
app_state.connection_manager.release_provider_handle(Some(deferred_handle)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deferred_provider_open_forces_identity_when_original_mode_preserves_origin() {
|
||||
let app_state = create_test_app_state();
|
||||
let provider_name = "provider_1".intern();
|
||||
let addr = "127.0.0.1:55015".parse().unwrap();
|
||||
let provider_handle = app_state
|
||||
.active_provider
|
||||
.acquire_exact_connection_with_grace(
|
||||
&provider_name,
|
||||
&addr,
|
||||
true,
|
||||
0,
|
||||
crate::api::model::ConnectionKind::Normal,
|
||||
)
|
||||
.await
|
||||
.expect("deferred provider allocation");
|
||||
let stream_details = create_deferred_provider_grace_details(&provider_name, provider_handle.clone());
|
||||
assert_eq!(stream_details.content_representation, ProviderContentRepresentationMode::PreserveOrigin);
|
||||
let fingerprint = create_test_fingerprint(addr);
|
||||
let mut stream_channel = create_test_stream_channel(1, "http://provider-1.example/live/1");
|
||||
stream_channel.item_type = PlaylistItemType::Catchup;
|
||||
|
||||
let deferred = create_deferred_provider_open_future(
|
||||
&app_state,
|
||||
&stream_details,
|
||||
&fingerprint,
|
||||
&stream_channel,
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.expect("deferred provider open context");
|
||||
|
||||
let DeferredProviderOpenState::Pending(context) = deferred else {
|
||||
panic!("new deferred open must start pending")
|
||||
};
|
||||
assert_eq!(
|
||||
context.provider_stream_factory_options.content_representation(),
|
||||
ProviderContentRepresentationMode::Identity
|
||||
);
|
||||
assert!(!context.provider_stream_factory_options.response_head_is_available());
|
||||
drop(context);
|
||||
app_state.connection_manager.release_provider_handle(Some(provider_handle)).await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn test_active_client_stream_deferred_provider_grace_retains_provider_handle_while_grace_pending() {
|
||||
let app_state = create_test_app_state();
|
||||
@@ -2472,15 +2514,12 @@ mod tests {
|
||||
provider_name: Some(provider_name),
|
||||
request_url: Some("http://provider-1.example/live/2.ts".intern()),
|
||||
session_headers: None,
|
||||
grace_period: GracePeriodOptions {
|
||||
period_millis: 100,
|
||||
timeout_secs: 0,
|
||||
hold_stream: true,
|
||||
},
|
||||
grace_period: GracePeriodOptions { period_millis: 100, timeout_secs: 0, hold_stream: true },
|
||||
provider_grace_active: false,
|
||||
disable_provider_grace: false,
|
||||
reconnect_flag: None,
|
||||
provider_handle: None,
|
||||
content_representation: ProviderContentRepresentationMode::PreserveOrigin,
|
||||
grace_resolution_context: Some(grace_context.clone()),
|
||||
};
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ use axum::response::IntoResponse;
|
||||
use log::trace;
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use shared::error::TuliproxError;
|
||||
use shared::model::PlaylistItemType;
|
||||
use shared::{error::TuliproxError, model::PlaylistItemType};
|
||||
use std::{fmt, net::SocketAddr, str::FromStr, sync::Arc};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
@@ -87,7 +86,8 @@ fn prepare_video_headers(headers: &[(String, String)]) -> Vec<(String, String)>
|
||||
|| key.eq_ignore_ascii_case("content-length")
|
||||
|| key.eq_ignore_ascii_case("range")
|
||||
|| key.eq_ignore_ascii_case("content-range")
|
||||
|| key.eq_ignore_ascii_case("accept-ranges"))
|
||||
|| key.eq_ignore_ascii_case("accept-ranges")
|
||||
|| key.eq_ignore_ascii_case("transfer-encoding"))
|
||||
})
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect();
|
||||
@@ -146,12 +146,11 @@ fn create_video_stream(
|
||||
}
|
||||
if let Some(video) = video_buffer {
|
||||
trace!("{log_message}");
|
||||
let stream =
|
||||
apply_custom_stream_timeout(cfg, Box::pin(ThrottledStream::new(CustomVideoStream::new(video.clone()), 8000)));
|
||||
(
|
||||
Some(stream),
|
||||
Some((prepare_video_headers(headers), status, None, Some(stream_type))),
|
||||
)
|
||||
let stream = apply_custom_stream_timeout(
|
||||
cfg,
|
||||
Box::pin(ThrottledStream::new(CustomVideoStream::new(video.clone()), 8000)),
|
||||
);
|
||||
(Some(stream), Some((prepare_video_headers(headers), status, None, Some(stream_type))))
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
@@ -190,10 +189,7 @@ pub fn create_channel_unavailable_stream(
|
||||
/// and human-readable description.
|
||||
macro_rules! ok_custom_stream_factory {
|
||||
($fn_name:ident, $field:ident, $stream_type:expr, $description:expr) => {
|
||||
pub fn $fn_name(
|
||||
cfg: &AppConfig,
|
||||
headers: &[(String, String)],
|
||||
) -> ProviderStreamResponse {
|
||||
pub fn $fn_name(cfg: &AppConfig, headers: &[(String, String)]) -> ProviderStreamResponse {
|
||||
let custom_stream_response = cfg.custom_stream_response.load();
|
||||
let video = custom_stream_response.as_ref().and_then(|c| c.$field.as_ref());
|
||||
create_ok_video_stream(cfg, $stream_type, video, headers, $description)
|
||||
@@ -273,9 +269,7 @@ pub fn create_custom_video_stream_response(
|
||||
) -> impl axum::response::IntoResponse + Send {
|
||||
let config = &app_state.app_config;
|
||||
if let (Some(stream), Some((headers, status_code, _, _))) = match video_response {
|
||||
CustomVideoStreamType::ChannelUnavailable => {
|
||||
create_channel_unavailable_stream(config, &[], StatusCode::OK)
|
||||
}
|
||||
CustomVideoStreamType::ChannelUnavailable => create_channel_unavailable_stream(config, &[], StatusCode::OK),
|
||||
CustomVideoStreamType::UserConnectionsExhausted => create_user_connections_exhausted_stream(config, &[]),
|
||||
CustomVideoStreamType::ProviderConnectionsExhausted => {
|
||||
create_provider_connections_exhausted_stream(config, &[])
|
||||
@@ -316,8 +310,8 @@ pub fn get_header_filter_for_item_type(item_type: PlaylistItemType) -> HeaderFil
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
create_channel_unavailable_stream, is_custom_video_stream_enabled, get_custom_stream_response_error_status,
|
||||
CustomVideoStreamType,
|
||||
create_channel_unavailable_stream, get_custom_stream_response_error_status, is_custom_video_stream_enabled,
|
||||
prepare_video_headers, CustomVideoStreamType,
|
||||
};
|
||||
use crate::{
|
||||
api::model::TransportStreamBuffer,
|
||||
@@ -351,7 +345,10 @@ mod tests {
|
||||
let sources = SourcesConfig { inputs: vec![input], ..SourcesConfig::default() };
|
||||
|
||||
let app_cfg = AppConfig {
|
||||
config: Arc::new(ArcSwap::from_pointee(Config { custom_stream_response_enabled: true, ..Config::default()})),
|
||||
config: Arc::new(ArcSwap::from_pointee(Config {
|
||||
custom_stream_response_enabled: true,
|
||||
..Config::default()
|
||||
})),
|
||||
sources: Arc::new(ArcSwap::from_pointee(sources)),
|
||||
hdhomerun: Arc::new(ArcSwapOption::default()),
|
||||
api_proxy: Arc::new(ArcSwapOption::default()),
|
||||
@@ -404,6 +401,17 @@ mod tests {
|
||||
assert_eq!(parsed.to_string(), "hls_session_or_lease_expired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_video_headers_drop_transfer_encoding() {
|
||||
let headers = prepare_video_headers(&[
|
||||
("Transfer-Encoding".to_string(), "chunked".to_string()),
|
||||
("cache-control".to_string(), "no-store".to_string()),
|
||||
]);
|
||||
|
||||
assert!(headers.iter().all(|(name, _)| !name.eq_ignore_ascii_case("transfer-encoding")));
|
||||
assert!(headers.iter().any(|(name, value)| name == "cache-control" && value == "no-store"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_channel_unavailable_preserves_supplied_status_code() {
|
||||
let app_cfg = create_test_app_config_with_channel_unavailable();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4074,6 +4074,7 @@ pub fn create_panel_api_provisioning_stream_details(
|
||||
disable_provider_grace: true,
|
||||
reconnect_flag: None,
|
||||
provider_handle: None,
|
||||
content_representation: crate::api::model::ProviderContentRepresentationMode::PreserveOrigin,
|
||||
grace_resolution_context: None,
|
||||
};
|
||||
}
|
||||
@@ -4100,6 +4101,7 @@ pub fn create_panel_api_provisioning_stream_details(
|
||||
disable_provider_grace: true,
|
||||
reconnect_flag: None,
|
||||
provider_handle: None,
|
||||
content_representation: crate::api::model::ProviderContentRepresentationMode::PreserveOrigin,
|
||||
grace_resolution_context: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -192,6 +192,7 @@ mod tests {
|
||||
input_name: "lib".intern(),
|
||||
channel_no: 0,
|
||||
source_ordinal: 0,
|
||||
input_stream_id: "1".intern(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +234,7 @@ mod tests {
|
||||
input_name: "lib".intern(),
|
||||
channel_no: 0,
|
||||
source_ordinal: 0,
|
||||
input_stream_id: "2".intern(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -440,6 +440,7 @@ mod tests {
|
||||
t_catchup_mode: None,
|
||||
source_ordinal: 0,
|
||||
additional_properties: None,
|
||||
input_stream_id: "813294".intern(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1158,6 +1158,7 @@ mod tests {
|
||||
input_name: Arc::clone(&input_name),
|
||||
channel_no: 0,
|
||||
source_ordinal: 0,
|
||||
input_stream_id: "9001".intern(),
|
||||
};
|
||||
let provider_parent_code = xtream_series_info.get_uuid().intern();
|
||||
let xtream_provider_episode = XtreamPlaylistItem {
|
||||
@@ -1179,6 +1180,7 @@ mod tests {
|
||||
input_name,
|
||||
channel_no: 0,
|
||||
source_ordinal: 0,
|
||||
input_stream_id: "201".intern(),
|
||||
};
|
||||
let provider_episode = PlaylistItem::from(&xtream_provider_episode);
|
||||
let mut series_info = PlaylistItem::from(&xtream_series_info);
|
||||
|
||||
@@ -1716,6 +1716,7 @@ mod tests {
|
||||
input_name: "input_a".intern(),
|
||||
channel_no: 0,
|
||||
source_ordinal: 0,
|
||||
input_stream_id: provider_id.to_string().intern(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
use crate::utils::{
|
||||
compression::compression_utils::{is_gzip, is_zlib_header},
|
||||
file_reader, open_readonly_file,
|
||||
};
|
||||
use flate2::bufread::{GzDecoder, ZlibDecoder};
|
||||
use crate::utils::compression::compression_utils::{is_deflate, is_gzip};
|
||||
use crate::utils::{file_reader, open_readonly_file};
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Read, Seek, SeekFrom},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
pub struct CompressedFileReader {
|
||||
reader: BufReader<Box<dyn Read>>,
|
||||
@@ -19,38 +23,29 @@ impl CompressedFileReader {
|
||||
|
||||
let reader: Box<dyn Read> = if is_gzip(&header) {
|
||||
Box::new(GzDecoder::new(buffered_file))
|
||||
} else if is_deflate(&header) {
|
||||
} else if is_zlib_header(&header) {
|
||||
Box::new(ZlibDecoder::new(buffered_file))
|
||||
} else {
|
||||
Box::new(buffered_file)
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
reader: file_reader(reader),
|
||||
})
|
||||
Ok(Self { reader: file_reader(reader) })
|
||||
}
|
||||
}
|
||||
|
||||
// Implement the Read trait for CompressedFileReader
|
||||
impl Read for CompressedFileReader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.reader.read(buf)
|
||||
}
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { self.reader.read(buf) }
|
||||
}
|
||||
|
||||
// Implement BufRead for CompressedFileReader
|
||||
impl BufRead for CompressedFileReader {
|
||||
fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
|
||||
self.reader.fill_buf()
|
||||
}
|
||||
fn fill_buf(&mut self) -> std::io::Result<&[u8]> { self.reader.fill_buf() }
|
||||
|
||||
fn consume(&mut self, amt: usize) {
|
||||
self.reader.consume(amt);
|
||||
}
|
||||
fn consume(&mut self, amt: usize) { self.reader.consume(amt); }
|
||||
}
|
||||
|
||||
impl Iterator for CompressedFileReader
|
||||
{
|
||||
impl Iterator for CompressedFileReader {
|
||||
type Item = std::io::Result<String>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
use crate::utils::async_file_reader;
|
||||
use crate::utils::compression::compression_utils::{is_deflate, is_gzip};
|
||||
use crate::utils::{
|
||||
async_file_reader,
|
||||
compression::compression_utils::{is_gzip, is_zlib_header},
|
||||
};
|
||||
use async_compression::tokio::bufread::{GzipDecoder, ZlibDecoder};
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::fs::File;
|
||||
use tokio::io::{
|
||||
self, AsyncRead, AsyncReadExt, AsyncSeekExt, ReadBuf,
|
||||
use std::{
|
||||
path::Path,
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
use tokio::{
|
||||
fs::File,
|
||||
io::{self, AsyncRead, AsyncReadExt, AsyncSeekExt, ReadBuf},
|
||||
};
|
||||
|
||||
pub struct CompressedFileReaderAsync {
|
||||
@@ -24,7 +28,7 @@ impl CompressedFileReaderAsync {
|
||||
|
||||
if is_gzip(&header) {
|
||||
Ok(Self { reader: Box::new(GzipDecoder::new(buffered_file)) })
|
||||
} else if is_deflate(&header) {
|
||||
} else if is_zlib_header(&header) {
|
||||
Ok(Self { reader: Box::new(ZlibDecoder::new(buffered_file)) })
|
||||
} else {
|
||||
Ok(Self { reader: Box::new(buffered_file) })
|
||||
@@ -33,12 +37,7 @@ impl CompressedFileReaderAsync {
|
||||
}
|
||||
|
||||
impl AsyncRead for CompressedFileReaderAsync {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.reader).poll_read(cx, buf)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
use flate2::{read::GzDecoder, write::GzEncoder, Compression};
|
||||
use std::io::{Read, Write};
|
||||
use flate2::Compression;
|
||||
use flate2::read::GzDecoder;
|
||||
use flate2::write::GzEncoder;
|
||||
|
||||
pub const fn is_gzip(bytes: &[u8]) -> bool {
|
||||
// Gzip files start with the bytes 0x1F 0x8B
|
||||
bytes.len() >= 2 && bytes[0] == 0x1F && bytes[1] == 0x8B
|
||||
}
|
||||
|
||||
pub const fn is_deflate(bytes: &[u8]) -> bool {
|
||||
bytes[0] == 0x78 && (bytes[1] == 0x01 || bytes[1] == 0x9C || bytes[1] == 0xDA)
|
||||
/// Reports whether the prefix is a valid RFC 1950 zlib header.
|
||||
pub const fn is_zlib_header(bytes: &[u8]) -> bool {
|
||||
if bytes.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let compression_method_and_info = bytes[0];
|
||||
let flags = bytes[1];
|
||||
let header = u16::from_be_bytes([compression_method_and_info, flags]);
|
||||
compression_method_and_info & 0x0f == 8 && compression_method_and_info >> 4 <= 7 && header.rem_euclid(31) == 0
|
||||
}
|
||||
|
||||
pub fn compress_string(input: &str) -> std::io::Result<Vec<u8>> {
|
||||
@@ -24,3 +30,25 @@ pub fn decompress_string(input: &[u8]) -> std::io::Result<String> {
|
||||
decoder.read_to_string(&mut decompressed)?;
|
||||
Ok(decompressed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_zlib_header;
|
||||
|
||||
#[test]
|
||||
fn zlib_header_validation_covers_method_window_checksum_and_short_prefixes() {
|
||||
for valid in [[0x78, 0x01], [0x78, 0x5e], [0x78, 0x9c], [0x78, 0xda]] {
|
||||
assert!(is_zlib_header(&valid), "expected valid zlib header {valid:02x?}");
|
||||
}
|
||||
|
||||
for invalid in [
|
||||
&[][..],
|
||||
&[0x78][..],
|
||||
&[0x79, 0x18][..], // unsupported compression method
|
||||
&[0x88, 0x1c][..], // CINFO exceeds the RFC 1950 maximum window size
|
||||
&[0x78, 0x02][..], // invalid FCHECK remainder
|
||||
] {
|
||||
assert!(!is_zlib_header(invalid), "expected invalid zlib header {invalid:02x?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,6 +335,7 @@ mod tests {
|
||||
input_name: "input".intern(),
|
||||
channel_no: 0,
|
||||
source_ordinal: 0,
|
||||
input_stream_id: "52568".intern(),
|
||||
};
|
||||
|
||||
let value = to_human_readable_json_value(&item).expect("dump value should serialize");
|
||||
|
||||
@@ -0,0 +1,917 @@
|
||||
use super::DynReader;
|
||||
use crate::utils::compression::compression_utils::{is_gzip, is_zlib_header};
|
||||
use async_compression::tokio::bufread::{BrotliDecoder, DeflateDecoder, GzipDecoder, ZlibDecoder, ZstdDecoder};
|
||||
use futures::TryStreamExt;
|
||||
use reqwest::{
|
||||
header::{
|
||||
self, HeaderMap, HeaderName, HeaderValue, ACCEPT_RANGES, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, ETAG,
|
||||
TRANSFER_ENCODING, VARY,
|
||||
},
|
||||
StatusCode,
|
||||
};
|
||||
use std::{
|
||||
error::Error as StdError,
|
||||
io,
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, BufReader, ReadBuf};
|
||||
use tokio_util::io::StreamReader;
|
||||
use url::Url;
|
||||
|
||||
const MAX_MAGIC_PREFIX_LEN: usize = 4;
|
||||
const ZSTD_MAGIC: [u8; 4] = [0x28, 0xb5, 0x2f, 0xfd];
|
||||
|
||||
/// Controls the final `Accept-Encoding` representation requested from an origin.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(crate) enum OutboundContentCodingPolicy {
|
||||
#[default]
|
||||
Inherit,
|
||||
Identity,
|
||||
}
|
||||
|
||||
/// Supported non-identity HTTP content codings.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ContentCoding {
|
||||
Gzip,
|
||||
Deflate,
|
||||
Brotli,
|
||||
Zstd,
|
||||
}
|
||||
|
||||
impl ContentCoding {
|
||||
/// Returns the fixed HTTP token used in redacted diagnostics.
|
||||
pub(crate) const fn as_http_token(self) -> &'static str {
|
||||
match self {
|
||||
Self::Gzip => "gzip",
|
||||
Self::Deflate => "deflate",
|
||||
Self::Brotli => "br",
|
||||
Self::Zstd => "zstd",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe fixed-field projection of an origin response whose coding was removed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct ContentCodingObservation {
|
||||
pub(crate) content_encoding: &'static str,
|
||||
pub(crate) status: StatusCode,
|
||||
pub(crate) content_length: Option<u64>,
|
||||
}
|
||||
|
||||
/// Selects the narrowly scoped fallback detection used when `Content-Encoding` is absent.
|
||||
///
|
||||
/// The shared `Declared` prefix deliberately makes header precedence explicit at every call site.
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(crate) enum ContentCodingDetection {
|
||||
/// Uses only declared `Content-Encoding` values.
|
||||
#[default]
|
||||
DeclaredOnly,
|
||||
/// Preserves the historical generic-text fallback for headerless gzip and zlib only.
|
||||
DeclaredOrLegacyTextMagic,
|
||||
/// Detects the known headerless gzip, zlib, and Zstandard signatures accepted for HLS manifests.
|
||||
DeclaredOrKnownHlsManifestMagic,
|
||||
}
|
||||
|
||||
/// Origin response normalized to an asynchronous identity-representation body.
|
||||
pub struct DecodedHttpResponse {
|
||||
pub(crate) status: StatusCode,
|
||||
pub(crate) final_url: Url,
|
||||
pub(crate) headers: HeaderMap,
|
||||
pub(crate) body: DynReader,
|
||||
decoded_from: Vec<ContentCoding>,
|
||||
original_content_length: Option<u64>,
|
||||
}
|
||||
|
||||
impl DecodedHttpResponse {
|
||||
/// Reports whether at least one non-identity content coding was removed.
|
||||
pub(crate) fn was_content_decoded(&self) -> bool { !self.decoded_from.is_empty() }
|
||||
|
||||
/// Returns only fixed and numeric fields suitable for HLS origin diagnostics.
|
||||
pub(crate) fn content_coding_observation(&self) -> Option<ContentCodingObservation> {
|
||||
let content_encoding = match self.decoded_from.as_slice() {
|
||||
[] => return None,
|
||||
[coding] => coding.as_http_token(),
|
||||
[_, ..] => "multiple",
|
||||
};
|
||||
Some(ContentCodingObservation {
|
||||
content_encoding,
|
||||
status: self.status,
|
||||
content_length: self.original_content_length,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors raised while parsing or preparing an HTTP content-coding chain.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum ContentCodingError {
|
||||
#[error("invalid Content-Encoding header")]
|
||||
InvalidHeader,
|
||||
|
||||
#[error("unsupported Content-Encoding")]
|
||||
Unsupported(String),
|
||||
|
||||
#[error("encoded partial content cannot be decoded safely")]
|
||||
EncodedPartialContent,
|
||||
|
||||
#[error("failed to inspect encoded response prefix")]
|
||||
PrefixRead(#[from] io::Error),
|
||||
}
|
||||
|
||||
/// Reports that a declared `Content-Encoding` field is not a valid HTTP token list.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
|
||||
#[error("invalid Content-Encoding header")]
|
||||
pub(crate) struct InvalidContentEncodingHeader;
|
||||
|
||||
/// Typed source attached to I/O failures emitted by a streaming decoder.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("content decoding failed: coding={coding:?}")]
|
||||
pub(crate) struct ContentDecodingIoError {
|
||||
pub(crate) coding: ContentCoding,
|
||||
}
|
||||
|
||||
/// Errors raised while fully consuming a decoded, size-bounded text body.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum ContentBodyReadError {
|
||||
#[error("decoded body exceeds configured limit {limit}")]
|
||||
LimitExceeded { limit: usize },
|
||||
|
||||
#[error("decoded body is not valid UTF-8: valid_up_to={valid_up_to} error_len={error_len:?}")]
|
||||
InvalidUtf8 { valid_up_to: usize, error_len: Option<usize> },
|
||||
|
||||
#[error(transparent)]
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
|
||||
pub(crate) fn force_accept_encoding_identity(headers: &mut HeaderMap) {
|
||||
headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("identity"));
|
||||
}
|
||||
|
||||
pub(crate) fn apply_outbound_content_coding_policy(headers: &mut HeaderMap, policy: OutboundContentCodingPolicy) {
|
||||
if matches!(policy, OutboundContentCodingPolicy::Identity) {
|
||||
force_accept_encoding_identity(headers);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses declared content-coding tokens without deciding whether Tuliprox can decode them.
|
||||
pub(crate) fn parse_content_encoding_tokens(headers: &HeaderMap) -> Result<Vec<String>, InvalidContentEncodingHeader> {
|
||||
let mut tokens = Vec::new();
|
||||
for value in headers.get_all(CONTENT_ENCODING) {
|
||||
let value = value.to_str().map_err(|_| InvalidContentEncodingHeader)?;
|
||||
for token in value.split(',') {
|
||||
let token = token.trim_matches(|character| matches!(character, ' ' | '\t'));
|
||||
if !is_http_token(token) {
|
||||
return Err(InvalidContentEncodingHeader);
|
||||
}
|
||||
tokens.push(token.to_owned());
|
||||
}
|
||||
}
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
fn is_http_token(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric()
|
||||
|| matches!(
|
||||
byte,
|
||||
b'!' | b'#'
|
||||
| b'$'
|
||||
| b'%'
|
||||
| b'&'
|
||||
| b'\''
|
||||
| b'*'
|
||||
| b'+'
|
||||
| b'-'
|
||||
| b'.'
|
||||
| b'^'
|
||||
| b'_'
|
||||
| b'`'
|
||||
| b'|'
|
||||
| b'~'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_content_codings(headers: &HeaderMap) -> Result<Vec<ContentCoding>, ContentCodingError> {
|
||||
let mut codings = Vec::new();
|
||||
|
||||
for token in parse_content_encoding_tokens(headers).map_err(|_| ContentCodingError::InvalidHeader)? {
|
||||
let coding = if token.eq_ignore_ascii_case("identity") {
|
||||
None
|
||||
} else if token.eq_ignore_ascii_case("gzip") || token.eq_ignore_ascii_case("x-gzip") {
|
||||
Some(ContentCoding::Gzip)
|
||||
} else if token.eq_ignore_ascii_case("deflate") {
|
||||
Some(ContentCoding::Deflate)
|
||||
} else if token.eq_ignore_ascii_case("br") {
|
||||
Some(ContentCoding::Brotli)
|
||||
} else if token.eq_ignore_ascii_case("zstd") {
|
||||
Some(ContentCoding::Zstd)
|
||||
} else {
|
||||
return Err(ContentCodingError::Unsupported(token));
|
||||
};
|
||||
|
||||
if let Some(coding) = coding {
|
||||
codings.push(coding);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(codings)
|
||||
}
|
||||
|
||||
pub(crate) async fn decode_response_to_identity(
|
||||
response: reqwest::Response,
|
||||
detection: ContentCodingDetection,
|
||||
) -> Result<DecodedHttpResponse, ContentCodingError> {
|
||||
let status = response.status();
|
||||
let final_url = response.url().clone();
|
||||
let headers = response.headers().clone();
|
||||
let original_content_length =
|
||||
headers.get(CONTENT_LENGTH).and_then(|value| value.to_str().ok()).and_then(|value| value.parse::<u64>().ok());
|
||||
let has_content_encoding = headers.contains_key(CONTENT_ENCODING);
|
||||
let mut decoded_from = parse_content_codings(&headers)?;
|
||||
|
||||
let stream = response.bytes_stream().map_err(|error| {
|
||||
let kind = if error.is_timeout() { io::ErrorKind::TimedOut } else { io::ErrorKind::Other };
|
||||
io::Error::new(kind, error)
|
||||
});
|
||||
let mut body: DynReader = Box::pin(StreamReader::new(stream));
|
||||
|
||||
if !has_content_encoding && !matches!(detection, ContentCodingDetection::DeclaredOnly) {
|
||||
let (replayed_body, prefix) = inspect_prefix(body, MAX_MAGIC_PREFIX_LEN).await?;
|
||||
body = replayed_body;
|
||||
let detected = match detection {
|
||||
ContentCodingDetection::DeclaredOnly => None,
|
||||
ContentCodingDetection::DeclaredOrLegacyTextMagic => coding_from_legacy_text_magic(&prefix),
|
||||
ContentCodingDetection::DeclaredOrKnownHlsManifestMagic => coding_from_known_manifest_magic(&prefix),
|
||||
};
|
||||
if let Some(coding) = detected {
|
||||
decoded_from.push(coding);
|
||||
}
|
||||
}
|
||||
|
||||
if status == StatusCode::PARTIAL_CONTENT && !decoded_from.is_empty() {
|
||||
return Err(ContentCodingError::EncodedPartialContent);
|
||||
}
|
||||
|
||||
for coding in decoded_from.iter().rev().copied() {
|
||||
body = decoder_for(body, coding).await?;
|
||||
}
|
||||
|
||||
let mut decoded = DecodedHttpResponse { status, final_url, headers, body, decoded_from, original_content_length };
|
||||
if decoded.was_content_decoded() {
|
||||
normalize_headers_after_content_decoding(&mut decoded.headers);
|
||||
} else if has_content_encoding {
|
||||
decoded.headers.remove(CONTENT_ENCODING);
|
||||
}
|
||||
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
pub(crate) async fn read_to_end_limited<R>(reader: &mut R, max_bytes: usize) -> Result<Vec<u8>, ContentBodyReadError>
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
{
|
||||
let probe_limit = max_bytes.checked_add(1);
|
||||
let mut result = Vec::with_capacity(max_bytes.min(8 * 1024));
|
||||
let mut buffer = [0_u8; 8 * 1024];
|
||||
|
||||
loop {
|
||||
let read_len = probe_limit.map_or(buffer.len(), |limit| limit.saturating_sub(result.len()).min(buffer.len()));
|
||||
if read_len == 0 {
|
||||
return Err(ContentBodyReadError::LimitExceeded { limit: max_bytes });
|
||||
}
|
||||
|
||||
let read = reader.read(&mut buffer[..read_len]).await?;
|
||||
if read == 0 {
|
||||
return Ok(result);
|
||||
}
|
||||
result.extend_from_slice(&buffer[..read]);
|
||||
|
||||
if result.len() > max_bytes {
|
||||
return Err(ContentBodyReadError::LimitExceeded { limit: max_bytes });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn read_utf8_limited<R>(reader: &mut R, max_bytes: usize) -> Result<String, ContentBodyReadError>
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
{
|
||||
let bytes = read_to_end_limited(reader, max_bytes).await?;
|
||||
String::from_utf8(bytes).map_err(|error| {
|
||||
let utf8_error = error.utf8_error();
|
||||
ContentBodyReadError::InvalidUtf8 { valid_up_to: utf8_error.valid_up_to(), error_len: utf8_error.error_len() }
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_headers_after_content_decoding(headers: &mut HeaderMap) {
|
||||
for name in [
|
||||
CONTENT_ENCODING,
|
||||
CONTENT_LENGTH,
|
||||
CONTENT_RANGE,
|
||||
ACCEPT_RANGES,
|
||||
ETAG,
|
||||
HeaderName::from_static("content-md5"),
|
||||
HeaderName::from_static("digest"),
|
||||
HeaderName::from_static("content-digest"),
|
||||
HeaderName::from_static("repr-digest"),
|
||||
TRANSFER_ENCODING,
|
||||
] {
|
||||
headers.remove(name);
|
||||
}
|
||||
|
||||
remove_accept_encoding_from_vary(headers);
|
||||
}
|
||||
|
||||
pub(crate) fn content_decoding_error_from_io(error: &io::Error) -> Option<&ContentDecodingIoError> {
|
||||
find_content_decoding_error(error.get_ref()?)
|
||||
}
|
||||
|
||||
async fn decoder_for(mut reader: DynReader, coding: ContentCoding) -> Result<DynReader, ContentCodingError> {
|
||||
let reader = match coding {
|
||||
ContentCoding::Gzip => tagged_decoder(GzipDecoder::new(BufReader::new(reader)), coding),
|
||||
ContentCoding::Brotli => tagged_decoder(BrotliDecoder::new(BufReader::new(reader)), coding),
|
||||
ContentCoding::Zstd => tagged_decoder(ZstdDecoder::new(BufReader::new(reader)), coding),
|
||||
ContentCoding::Deflate => {
|
||||
let (replayed_reader, prefix) = inspect_prefix(reader, 2).await?;
|
||||
reader = replayed_reader;
|
||||
if is_zlib_header(&prefix) {
|
||||
tagged_decoder(ZlibDecoder::new(BufReader::new(reader)), coding)
|
||||
} else {
|
||||
tagged_decoder(DeflateDecoder::new(BufReader::new(reader)), coding)
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(reader)
|
||||
}
|
||||
|
||||
fn tagged_decoder<R>(reader: R, coding: ContentCoding) -> DynReader
|
||||
where
|
||||
R: AsyncRead + Send + Unpin + 'static,
|
||||
{
|
||||
Box::pin(ContentDecodingReader { inner: reader, coding })
|
||||
}
|
||||
|
||||
async fn inspect_prefix(mut reader: DynReader, max_len: usize) -> io::Result<(DynReader, Vec<u8>)> {
|
||||
let mut prefix = Vec::with_capacity(max_len);
|
||||
while prefix.len() < max_len {
|
||||
let mut chunk = [0_u8; MAX_MAGIC_PREFIX_LEN];
|
||||
let remaining = max_len - prefix.len();
|
||||
let read = reader.read(&mut chunk[..remaining]).await?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
prefix.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
|
||||
let replayed = std::io::Cursor::new(prefix.clone()).chain(reader);
|
||||
Ok((Box::pin(replayed), prefix))
|
||||
}
|
||||
|
||||
fn coding_from_known_manifest_magic(prefix: &[u8]) -> Option<ContentCoding> {
|
||||
coding_from_legacy_text_magic(prefix).or_else(|| prefix.starts_with(&ZSTD_MAGIC).then_some(ContentCoding::Zstd))
|
||||
}
|
||||
|
||||
fn coding_from_legacy_text_magic(prefix: &[u8]) -> Option<ContentCoding> {
|
||||
if is_gzip(prefix) {
|
||||
Some(ContentCoding::Gzip)
|
||||
} else {
|
||||
is_zlib_header(prefix).then_some(ContentCoding::Deflate)
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_accept_encoding_from_vary(headers: &mut HeaderMap) {
|
||||
let values = headers.get_all(VARY).iter().cloned().collect::<Vec<_>>();
|
||||
if values.is_empty() {
|
||||
return;
|
||||
}
|
||||
headers.remove(VARY);
|
||||
|
||||
for value in values {
|
||||
let original_value = value.clone();
|
||||
let Ok(value) = value.to_str() else {
|
||||
headers.append(VARY, original_value);
|
||||
continue;
|
||||
};
|
||||
let remaining = value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|token| !token.eq_ignore_ascii_case("accept-encoding"))
|
||||
.collect::<Vec<_>>();
|
||||
if !remaining.is_empty() {
|
||||
match HeaderValue::from_str(&remaining.join(", ")) {
|
||||
Ok(value) => {
|
||||
headers.append(VARY, value);
|
||||
}
|
||||
Err(_) => {
|
||||
headers.append(VARY, original_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_content_decoding_error<'a>(mut error: &'a (dyn StdError + 'static)) -> Option<&'a ContentDecodingIoError> {
|
||||
loop {
|
||||
if let Some(error) = error.downcast_ref::<ContentDecodingIoError>() {
|
||||
return Some(error);
|
||||
}
|
||||
error = error.source()?;
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentDecodingReader<R> {
|
||||
inner: R,
|
||||
coding: ContentCoding,
|
||||
}
|
||||
|
||||
impl<R> AsyncRead for ContentDecodingReader<R>
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
{
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
context: &mut Context<'_>,
|
||||
buffer: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
match Pin::new(&mut self.inner).poll_read(context, buffer) {
|
||||
Poll::Ready(Err(error))
|
||||
if content_decoding_error_from_io(&error).is_none() && !is_http_body_transport_error(&error) =>
|
||||
{
|
||||
let kind = error.kind();
|
||||
Poll::Ready(Err(io::Error::new(kind, ContentDecodingIoError { coding: self.coding })))
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_http_body_transport_error(error: &io::Error) -> bool {
|
||||
let Some(source) = error.get_ref() else {
|
||||
return false;
|
||||
};
|
||||
let mut source: &(dyn StdError + 'static) = source;
|
||||
loop {
|
||||
if source.downcast_ref::<reqwest::Error>().is_some() {
|
||||
return true;
|
||||
}
|
||||
let Some(next) = source.source() else {
|
||||
return false;
|
||||
};
|
||||
source = next;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_compression::tokio::bufread::{BrotliEncoder, DeflateEncoder, GzipEncoder, ZlibEncoder, ZstdEncoder};
|
||||
use reqwest::redirect::Policy;
|
||||
use std::io::Cursor;
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpListener,
|
||||
};
|
||||
|
||||
const MANIFEST: &[u8] = b"#EXTM3U\n#EXT-X-VERSION:3\nsegment.ts\n";
|
||||
|
||||
#[test]
|
||||
fn content_coding_parses_all_headers_tokens_aliases_and_identity() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(CONTENT_ENCODING, HeaderValue::from_static("gzip, identity"));
|
||||
headers.append(CONTENT_ENCODING, HeaderValue::from_static("BR, x-GZiP, deflate, zstd"));
|
||||
|
||||
assert_eq!(
|
||||
parse_content_codings(&headers).expect("valid codings"),
|
||||
vec![
|
||||
ContentCoding::Gzip,
|
||||
ContentCoding::Brotli,
|
||||
ContentCoding::Gzip,
|
||||
ContentCoding::Deflate,
|
||||
ContentCoding::Zstd,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_coding_rejects_empty_invalid_and_unsupported_tokens() {
|
||||
for value in [HeaderValue::from_static("gzip,"), HeaderValue::from_static("gzip; level=1")] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_ENCODING, value);
|
||||
assert!(matches!(parse_content_codings(&headers), Err(ContentCodingError::InvalidHeader)));
|
||||
}
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_ENCODING, HeaderValue::from_bytes(&[0xff]).expect("opaque header value"));
|
||||
assert!(matches!(parse_content_codings(&headers), Err(ContentCodingError::InvalidHeader)));
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_ENCODING, HeaderValue::from_static("compress"));
|
||||
assert!(matches!(
|
||||
parse_content_codings(&headers),
|
||||
Err(ContentCodingError::Unsupported(coding)) if coding == "compress"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_encoding_token_parser_preserves_unknown_valid_tokens_and_order() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(CONTENT_ENCODING, HeaderValue::from_static("gzip, X-Provider-Coding"));
|
||||
headers.append(CONTENT_ENCODING, HeaderValue::from_static("compress"));
|
||||
|
||||
assert_eq!(
|
||||
parse_content_encoding_tokens(&headers).expect("valid HTTP tokens"),
|
||||
vec!["gzip".to_owned(), "X-Provider-Coding".to_owned(), "compress".to_owned()]
|
||||
);
|
||||
assert!(matches!(
|
||||
parse_content_codings(&headers),
|
||||
Err(ContentCodingError::Unsupported(coding)) if coding == "X-Provider-Coding"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_coding_identity_policy_overrides_existing_value_only_when_requested() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("gzip, br"));
|
||||
|
||||
apply_outbound_content_coding_policy(&mut headers, OutboundContentCodingPolicy::Inherit);
|
||||
assert_eq!(headers[header::ACCEPT_ENCODING], "gzip, br");
|
||||
|
||||
apply_outbound_content_coding_policy(&mut headers, OutboundContentCodingPolicy::Identity);
|
||||
assert_eq!(headers[header::ACCEPT_ENCODING], "identity");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_coding_decodes_all_declared_codings_and_deflate_wrappers() {
|
||||
let cases = [
|
||||
("gzip", Encoding::Gzip),
|
||||
("x-gzip", Encoding::Gzip),
|
||||
("deflate", Encoding::Zlib),
|
||||
("deflate", Encoding::RawDeflate),
|
||||
("br", Encoding::Brotli),
|
||||
("zstd", Encoding::Zstd),
|
||||
];
|
||||
|
||||
for (header_value, encoding) in cases {
|
||||
let encoded = encode(MANIFEST, encoding).await;
|
||||
let response = local_response(StatusCode::OK, &[("Content-Encoding", header_value)], encoded).await;
|
||||
let mut decoded = decode_response_to_identity(response, ContentCodingDetection::DeclaredOnly)
|
||||
.await
|
||||
.expect("decoder setup");
|
||||
|
||||
assert_eq!(
|
||||
read_to_end_limited(&mut decoded.body, 1024).await.expect("decoded body"),
|
||||
MANIFEST,
|
||||
"failed for {header_value} / {encoding:?}"
|
||||
);
|
||||
assert!(!decoded.headers.contains_key(CONTENT_ENCODING));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_coding_decodes_multiple_header_lines_in_reverse_application_order() {
|
||||
let gzip = encode(MANIFEST, Encoding::Gzip).await;
|
||||
let brotli_over_gzip = encode(&gzip, Encoding::Brotli).await;
|
||||
let encoded_length = brotli_over_gzip.len() as u64;
|
||||
let response = local_response(
|
||||
StatusCode::OK,
|
||||
&[("Content-Encoding", "gzip"), ("Content-Encoding", "br")],
|
||||
brotli_over_gzip,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut decoded =
|
||||
decode_response_to_identity(response, ContentCodingDetection::DeclaredOnly).await.expect("decoder setup");
|
||||
assert_eq!(decoded.decoded_from, vec![ContentCoding::Gzip, ContentCoding::Brotli]);
|
||||
assert_eq!(
|
||||
decoded.content_coding_observation(),
|
||||
Some(ContentCodingObservation {
|
||||
content_encoding: "multiple",
|
||||
status: StatusCode::OK,
|
||||
content_length: Some(encoded_length),
|
||||
})
|
||||
);
|
||||
assert_eq!(read_to_end_limited(&mut decoded.body, 1024).await.expect("decoded body"), MANIFEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_coding_decodes_comma_separated_codings_in_reverse_application_order() {
|
||||
let gzip = encode(MANIFEST, Encoding::Gzip).await;
|
||||
let brotli_over_gzip = encode(&gzip, Encoding::Brotli).await;
|
||||
let response = local_response(StatusCode::OK, &[("Content-Encoding", "gzip, br")], brotli_over_gzip).await;
|
||||
|
||||
let mut decoded =
|
||||
decode_response_to_identity(response, ContentCodingDetection::DeclaredOnly).await.expect("decoder setup");
|
||||
assert_eq!(decoded.decoded_from, vec![ContentCoding::Gzip, ContentCoding::Brotli]);
|
||||
assert_eq!(read_to_end_limited(&mut decoded.body, 1024).await.expect("decoded body"), MANIFEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_coding_declared_value_wins_over_contradicting_magic() {
|
||||
for detection in
|
||||
[ContentCodingDetection::DeclaredOrLegacyTextMagic, ContentCodingDetection::DeclaredOrKnownHlsManifestMagic]
|
||||
{
|
||||
let gzip = encode(MANIFEST, Encoding::Gzip).await;
|
||||
let response = local_response(StatusCode::OK, &[("Content-Encoding", "br")], gzip).await;
|
||||
let mut decoded = decode_response_to_identity(response, detection).await.expect("lazy decoder setup");
|
||||
|
||||
let error = read_to_end_limited(&mut decoded.body, 1024)
|
||||
.await
|
||||
.expect_err("gzip bytes must not override declared Brotli");
|
||||
let ContentBodyReadError::Io(error) = error else {
|
||||
panic!("expected decoder I/O error");
|
||||
};
|
||||
assert_eq!(
|
||||
content_decoding_error_from_io(&error).expect("typed decoder error").coding,
|
||||
ContentCoding::Brotli
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_coding_legacy_text_magic_decodes_only_headerless_gzip_and_zlib() {
|
||||
for encoding in [Encoding::Gzip, Encoding::Zlib] {
|
||||
let encoded = encode(MANIFEST, encoding).await;
|
||||
let response = local_response(StatusCode::OK, &[], encoded).await;
|
||||
let mut decoded = decode_response_to_identity(response, ContentCodingDetection::DeclaredOrLegacyTextMagic)
|
||||
.await
|
||||
.expect("decoder setup");
|
||||
|
||||
assert_eq!(
|
||||
read_to_end_limited(&mut decoded.body, 1024).await.expect("magic-decoded body"),
|
||||
MANIFEST,
|
||||
"failed for {encoding:?}"
|
||||
);
|
||||
assert!(decoded.was_content_decoded());
|
||||
}
|
||||
|
||||
for encoding in [Encoding::RawDeflate, Encoding::Brotli, Encoding::Zstd] {
|
||||
let encoded = encode(MANIFEST, encoding).await;
|
||||
let response = local_response(StatusCode::OK, &[], encoded.clone()).await;
|
||||
let mut untouched =
|
||||
decode_response_to_identity(response, ContentCodingDetection::DeclaredOrLegacyTextMagic)
|
||||
.await
|
||||
.expect("identity setup");
|
||||
|
||||
assert!(!untouched.was_content_decoded(), "must not guess {encoding:?}");
|
||||
assert_eq!(read_to_end_limited(&mut untouched.body, 1024).await.expect("untouched body"), encoded);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_coding_hls_manifest_magic_supports_only_known_signatures() {
|
||||
for encoding in [Encoding::Gzip, Encoding::Zlib, Encoding::Zstd] {
|
||||
let encoded = encode(MANIFEST, encoding).await;
|
||||
let response = local_response(StatusCode::OK, &[], encoded).await;
|
||||
let mut decoded =
|
||||
decode_response_to_identity(response, ContentCodingDetection::DeclaredOrKnownHlsManifestMagic)
|
||||
.await
|
||||
.expect("decoder setup");
|
||||
assert_eq!(
|
||||
read_to_end_limited(&mut decoded.body, 1024).await.expect("magic-decoded body"),
|
||||
MANIFEST,
|
||||
"failed for {encoding:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let encoded_binary = encode(b"not a manifest", Encoding::Gzip).await;
|
||||
let response = local_response(StatusCode::OK, &[], encoded_binary.clone()).await;
|
||||
let mut untouched =
|
||||
decode_response_to_identity(response, ContentCodingDetection::DeclaredOnly).await.expect("identity setup");
|
||||
assert!(untouched.decoded_from.is_empty());
|
||||
assert_eq!(read_to_end_limited(&mut untouched.body, 1024).await.expect("untouched body"), encoded_binary);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_coding_header_presence_disables_manifest_magic_even_for_identity() {
|
||||
let encoded = encode(MANIFEST, Encoding::Gzip).await;
|
||||
let response = local_response(StatusCode::OK, &[("Content-Encoding", "identity")], encoded.clone()).await;
|
||||
let mut decoded =
|
||||
decode_response_to_identity(response, ContentCodingDetection::DeclaredOrKnownHlsManifestMagic)
|
||||
.await
|
||||
.expect("identity setup");
|
||||
|
||||
assert!(decoded.decoded_from.is_empty());
|
||||
assert_eq!(decoded.content_coding_observation(), None);
|
||||
assert!(!decoded.headers.contains_key(CONTENT_ENCODING));
|
||||
assert_eq!(read_to_end_limited(&mut decoded.body, 1024).await.expect("identity body"), encoded);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_coding_rejects_encoded_partial_content_but_preserves_identity_range_headers() {
|
||||
let encoded = encode(MANIFEST, Encoding::Gzip).await;
|
||||
let response = local_response(
|
||||
StatusCode::PARTIAL_CONTENT,
|
||||
&[("Content-Encoding", "gzip"), ("Content-Range", "bytes 0-9/20")],
|
||||
encoded,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
decode_response_to_identity(response, ContentCodingDetection::DeclaredOnly).await,
|
||||
Err(ContentCodingError::EncodedPartialContent)
|
||||
));
|
||||
|
||||
let response = local_response(
|
||||
StatusCode::PARTIAL_CONTENT,
|
||||
&[("Content-Encoding", "identity"), ("Content-Range", "bytes 0-2/10")],
|
||||
b"abc".to_vec(),
|
||||
)
|
||||
.await;
|
||||
let decoded = decode_response_to_identity(response, ContentCodingDetection::DeclaredOnly)
|
||||
.await
|
||||
.expect("identity partial response");
|
||||
assert_eq!(decoded.status, StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(decoded.headers[CONTENT_RANGE], "bytes 0-2/10");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_coding_normalizes_only_representation_dependent_headers() {
|
||||
let encoded = encode(MANIFEST, Encoding::Gzip).await;
|
||||
let response = local_response(
|
||||
StatusCode::OK,
|
||||
&[
|
||||
("Content-Encoding", "gzip"),
|
||||
("Content-Range", "bytes 0-9/10"),
|
||||
("Accept-Ranges", "bytes"),
|
||||
("ETag", "origin-tag"),
|
||||
("Content-MD5", "abc"),
|
||||
("Digest", "sha-256=abc"),
|
||||
("Content-Digest", "sha-256=:abc:"),
|
||||
("Repr-Digest", "sha-256=:abc:"),
|
||||
("Vary", "Accept-Encoding, Origin"),
|
||||
("Vary", "*"),
|
||||
("Set-Cookie", "session=secret"),
|
||||
],
|
||||
encoded,
|
||||
)
|
||||
.await;
|
||||
let decoded = decode_response_to_identity(response, ContentCodingDetection::DeclaredOnly)
|
||||
.await
|
||||
.expect("decoded response");
|
||||
|
||||
for name in [
|
||||
CONTENT_ENCODING,
|
||||
CONTENT_LENGTH,
|
||||
CONTENT_RANGE,
|
||||
ACCEPT_RANGES,
|
||||
ETAG,
|
||||
HeaderName::from_static("content-md5"),
|
||||
HeaderName::from_static("digest"),
|
||||
HeaderName::from_static("content-digest"),
|
||||
HeaderName::from_static("repr-digest"),
|
||||
TRANSFER_ENCODING,
|
||||
] {
|
||||
assert!(!decoded.headers.contains_key(&name), "stale header {name}");
|
||||
}
|
||||
let vary =
|
||||
decoded.headers.get_all(VARY).iter().map(|value| value.to_str().expect("ASCII Vary")).collect::<Vec<_>>();
|
||||
assert_eq!(vary, vec!["Origin", "*"]);
|
||||
assert_eq!(decoded.headers[header::SET_COOKIE], "session=secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_coding_normalizer_removes_transfer_encoding_but_keeps_safe_metadata() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(TRANSFER_ENCODING, HeaderValue::from_static("chunked"));
|
||||
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/vnd.apple.mpegurl"));
|
||||
|
||||
normalize_headers_after_content_decoding(&mut headers);
|
||||
|
||||
assert!(!headers.contains_key(TRANSFER_ENCODING));
|
||||
assert_eq!(headers[header::CONTENT_TYPE], "application/vnd.apple.mpegurl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_coding_normalizer_preserves_opaque_vary_without_panicking() {
|
||||
let opaque_vary = HeaderValue::from_bytes(&[0xff]).expect("opaque header value");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(VARY, opaque_vary.clone());
|
||||
|
||||
normalize_headers_after_content_decoding(&mut headers);
|
||||
|
||||
assert_eq!(headers[VARY], opaque_vary);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_coding_surfaces_typed_errors_for_truncated_streams() {
|
||||
for (encoding, coding) in [
|
||||
(Encoding::Gzip, ContentCoding::Gzip),
|
||||
(Encoding::Brotli, ContentCoding::Brotli),
|
||||
(Encoding::Zstd, ContentCoding::Zstd),
|
||||
] {
|
||||
let mut encoded = encode(MANIFEST, encoding).await;
|
||||
encoded.truncate(encoded.len() / 2);
|
||||
let response =
|
||||
local_response(StatusCode::OK, &[("Content-Encoding", encoding.header_value())], encoded).await;
|
||||
let mut decoded = decode_response_to_identity(response, ContentCodingDetection::DeclaredOnly)
|
||||
.await
|
||||
.expect("lazy decoder setup");
|
||||
|
||||
let error = read_to_end_limited(&mut decoded.body, 1024).await.expect_err("truncated stream");
|
||||
let ContentBodyReadError::Io(error) = error else {
|
||||
panic!("expected decoder I/O error for {encoding:?}");
|
||||
};
|
||||
assert_eq!(content_decoding_error_from_io(&error).expect("typed decoder error").coding, coding);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_coding_limit_and_utf8_errors_are_distinct() {
|
||||
let mut exact = Cursor::new(b"abcd".to_vec());
|
||||
assert_eq!(read_to_end_limited(&mut exact, 4).await.expect("exact limit"), b"abcd");
|
||||
|
||||
let mut oversized = Cursor::new(b"abcde".to_vec());
|
||||
assert!(matches!(
|
||||
read_to_end_limited(&mut oversized, 4).await,
|
||||
Err(ContentBodyReadError::LimitExceeded { limit: 4 })
|
||||
));
|
||||
|
||||
let mut invalid_utf8 = Cursor::new(vec![b'a', 0xff]);
|
||||
assert!(matches!(
|
||||
read_utf8_limited(&mut invalid_utf8, 10).await,
|
||||
Err(ContentBodyReadError::InvalidUtf8 { valid_up_to: 1, error_len: Some(1) })
|
||||
));
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Encoding {
|
||||
Gzip,
|
||||
Zlib,
|
||||
RawDeflate,
|
||||
Brotli,
|
||||
Zstd,
|
||||
}
|
||||
|
||||
impl Encoding {
|
||||
const fn header_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Gzip => "gzip",
|
||||
Self::Zlib | Self::RawDeflate => "deflate",
|
||||
Self::Brotli => "br",
|
||||
Self::Zstd => "zstd",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn encode(input: &[u8], encoding: Encoding) -> Vec<u8> {
|
||||
let reader = BufReader::new(Cursor::new(input.to_vec()));
|
||||
let mut result = Vec::new();
|
||||
match encoding {
|
||||
Encoding::Gzip => GzipEncoder::new(reader).read_to_end(&mut result).await.expect("gzip encode"),
|
||||
Encoding::Zlib => ZlibEncoder::new(reader).read_to_end(&mut result).await.expect("zlib encode"),
|
||||
Encoding::RawDeflate => {
|
||||
DeflateEncoder::new(reader).read_to_end(&mut result).await.expect("raw deflate encode")
|
||||
}
|
||||
Encoding::Brotli => BrotliEncoder::new(reader).read_to_end(&mut result).await.expect("Brotli encode"),
|
||||
Encoding::Zstd => ZstdEncoder::new(reader).read_to_end(&mut result).await.expect("Zstandard encode"),
|
||||
};
|
||||
result
|
||||
}
|
||||
|
||||
async fn local_response(status: StatusCode, headers: &[(&str, &str)], body: Vec<u8>) -> reqwest::Response {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test origin");
|
||||
let address = listener.local_addr().expect("test origin address");
|
||||
let has_content_length = headers.iter().any(|(name, _)| name.eq_ignore_ascii_case("content-length"));
|
||||
let owned_headers =
|
||||
headers.iter().map(|(name, value)| ((*name).to_owned(), (*value).to_owned())).collect::<Vec<_>>();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.expect("accept test request");
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 1024];
|
||||
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
let read = socket.read(&mut buffer).await.expect("read test request");
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
}
|
||||
|
||||
let mut response = format!("HTTP/1.1 {} Test\r\nConnection: close\r\n", status.as_u16());
|
||||
if !has_content_length {
|
||||
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
||||
}
|
||||
for (name, value) in owned_headers {
|
||||
response.push_str(&name);
|
||||
response.push_str(": ");
|
||||
response.push_str(&value);
|
||||
response.push_str("\r\n");
|
||||
}
|
||||
response.push_str("\r\n");
|
||||
socket.write_all(response.as_bytes()).await.expect("write test response head");
|
||||
socket.write_all(&body).await.expect("write test response body");
|
||||
});
|
||||
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.redirect(Policy::none())
|
||||
.build()
|
||||
.expect("test client")
|
||||
.get(format!("http://{address}/manifest.m3u8"))
|
||||
.send()
|
||||
.await
|
||||
.expect("fetch test response")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
use std::pin::Pin;
|
||||
use tokio::io::AsyncRead;
|
||||
|
||||
pub(crate) mod content_coding;
|
||||
pub mod epg;
|
||||
pub mod ip_checker;
|
||||
pub mod m3u;
|
||||
pub mod request;
|
||||
pub mod xtream;
|
||||
|
||||
/// Type-erased asynchronous reader used by the existing network download stack.
|
||||
pub type DynReader = Pin<Box<dyn AsyncRead + Send>>;
|
||||
|
||||
pub use request::format_http_status;
|
||||
|
||||
+1223
-457
File diff suppressed because it is too large
Load Diff
@@ -618,6 +618,7 @@ mod tests {
|
||||
input_name: "provider".intern(),
|
||||
channel_no: 1,
|
||||
source_ordinal: 1,
|
||||
input_stream_id: "813563".intern(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,12 +23,21 @@ For an operator-friendly introduction, see [Shared HLS Sessions](./shared-hls-se
|
||||
|
||||
| Identifier | Scope | Meaning |
|
||||
| :--- | :--- | :--- |
|
||||
| `HlsSessionKey` | Shared content | Stable tuple of `input_id`, HLS kind, and `stream_ref`. It does not include the origin URL, provider URL, username, or password. |
|
||||
| `HlsSessionKey` | Shared content | Stable tuple of `input_id`, the literal HLS kind, and `stream_ref`, where `stream_ref` is the unchanged original `input_stream_id`. |
|
||||
| `proxy_session_id` | Shared public URL identity | Opaque token derived from `HlsSessionKey` and the configured secret. It identifies the shared content session in canonical URLs. |
|
||||
| `HlsPlaybackFamilyKey` | User/client family | Tuple of Tuliprox username and client fingerprint key. It groups playback attempts by user and client. |
|
||||
| `hls_access_lease_id` | Per playback URL identity | Random lookup key for one server-side `HlsAccessLease`. It is not a shared-content identity. |
|
||||
| HLS cache user session token | Per playback admission | Internal Tuliprox user-session token associated with an access lease. |
|
||||
|
||||
The canonical internal key is `input:<input_id>|hls|<input_stream_id>`. The input stream ID is captured from the input
|
||||
playlist item before target mapping: the provider/origin stream ID as a decimal string for Xtream, or the parser-resolved
|
||||
string ID for M3U. The latter can be alphanumeric or a stable URL-derived hash. `target_id`, `virtual_id`, origin URLs,
|
||||
credentials, and provider mirrors do not participate in session identity. Therefore, different targets reuse one
|
||||
`HlsSession` when both `input_id` and `input_stream_id` match, while their access leases remain separate.
|
||||
|
||||
The runtime assumes that `(input_id, input_stream_id)` uniquely identifies one input stream. A missing input stream ID is
|
||||
not replaced with `virtual_id`; the Shared HLS entry fails instead of joining an ambiguously identified session.
|
||||
|
||||
The public canonical paths are:
|
||||
|
||||
```text
|
||||
|
||||
@@ -9,13 +9,16 @@ reference, see [HLS Cache State Machines](./hls-cache-state-machine.md).
|
||||
|
||||
| Identifier | Scope | Created by | Purpose |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `HlsSessionKey` | Shared content | Built from `input_id`, the literal HLS kind, and `stream_ref`. | Stable internal key for one shared live HLS session. It does not include provider username, password, or origin URL. |
|
||||
| `proxy_session_id` | Public shared content URL | Derived from `HlsSessionKey` and the configured secret. | Opaque URL token for the shared session. The same input and stream reference produce the same public session ID while the secret stays stable. |
|
||||
| `HlsSessionKey` | Shared content | Built as `input:<input_id>\|hls\|<input_stream_id>`. | Stable internal key for one shared live HLS session. The input stream ID is captured before target mapping. |
|
||||
| `proxy_session_id` | Public shared content URL | Derived from `HlsSessionKey` and the configured secret. | Opaque URL token for the shared session. The same input and original input stream ID produce the same public session ID while the secret stays stable. |
|
||||
| `HlsPlaybackFamilyKey` | Playback family | Built from Tuliprox username and client fingerprint. | Groups related playback attempts by user and client. |
|
||||
| `hls_access_lease_id` | Per playback URL | Random server-side lease ID. | Lets exactly one playback path use the shared session after Tuliprox validates the user and lease. |
|
||||
| HLS cache user session token | Per playback admission | Generated for the Shared HLS entry request. | Connects the access lease to Tuliprox user session accounting and connection handling. |
|
||||
|
||||
The key design point is that `proxy_session_id` is shared, but `hls_access_lease_id` is not.
|
||||
The key design point is that `proxy_session_id` is shared, but `hls_access_lease_id` is not. The `stream_ref` field used
|
||||
inside `HlsSessionKey` is exactly the original `input_stream_id`: an Xtream provider/origin stream ID as a decimal string,
|
||||
or the parser-resolved M3U ID, including alphanumeric IDs and stable URL-derived hashes. `target_id` and `virtual_id` stay
|
||||
in the routing and access context and do not affect this shared identity.
|
||||
|
||||
## End-to-end request flow
|
||||
|
||||
@@ -50,8 +53,9 @@ sequenceDiagram
|
||||
|
||||
## Step 1: Entry request
|
||||
|
||||
The player first opens a generated Tuliprox live HLS URL. Tuliprox resolves the target, input, virtual ID, stream URL, and
|
||||
user connection permission.
|
||||
The player first opens a generated Tuliprox live HLS URL. Tuliprox resolves the target, input, virtual ID, original input
|
||||
stream ID, stream URL, and user connection permission. The original input stream ID was captured before target mapping
|
||||
and is not replaced by the target's virtual ID.
|
||||
|
||||
If Shared HLS is enabled for the target, Tuliprox creates a fresh `Pending` `HlsAccessLease` for this playback and returns
|
||||
an HTTP `307 Temporary Redirect` to the canonical shared manifest path:
|
||||
@@ -213,3 +217,8 @@ Typical outcomes are:
|
||||
- `503 Service Unavailable` with `Retry-After` when origin/cache work is temporarily not ready.
|
||||
|
||||
Players should reopen the original generated playlist entry URL when they recover from a long pause or stale HLS path.
|
||||
|
||||
An upgrade from `virtual_id`-based session identity also makes old canonical URLs stale when the virtual and input stream
|
||||
IDs differ. New entry requests receive the new `proxy_session_id`; old cache namespaces are left for normal garbage
|
||||
collection. Persisted M3U-to-Xtream data that lost a nonnumeric input ID as `provider_id = 0` must be refreshed before the
|
||||
entry can safely join a shared session.
|
||||
|
||||
@@ -43,7 +43,7 @@ separate `share_live_streams.mpeg_ts` option.
|
||||
|
||||
| Shared between viewers | Still separate per viewer |
|
||||
| :--- | :--- |
|
||||
| The stable `HlsSession` for the same input and live stream reference. | The `hls_access_lease_id` in the user-facing URL. |
|
||||
| The stable `HlsSession` for the same input and original input stream ID. | The `hls_access_lease_id` in the user-facing URL. |
|
||||
| Origin manifest refreshes and origin segment fetches when the cache can reuse them. | Tuliprox username, client fingerprint, and access checks. |
|
||||
| Cached HLS segments and HLS MAP objects. | User connection admission and stream reservation lifecycle. |
|
||||
| Transient HLS resource handling when the manifest requires it. | User-visible error handling and custom video redirects. |
|
||||
@@ -51,6 +51,26 @@ separate `share_live_streams.mpeg_ts` option.
|
||||
This means the feature is not a bypass for user limits. It is a way to avoid duplicated upstream HLS work after Tuliprox
|
||||
has accepted each viewer.
|
||||
|
||||
## How is a shared session identified?
|
||||
|
||||
Tuliprox builds the shared content identity as:
|
||||
|
||||
```text
|
||||
input:<input_id>|hls|<input_stream_id>
|
||||
```
|
||||
|
||||
`input_id` identifies the configured Tuliprox input. `input_stream_id` is the original stream ID captured from the input
|
||||
playlist item before target mapping. For Xtream inputs, it is the provider/origin stream ID rendered as a decimal string.
|
||||
For M3U inputs, it is the exact ID resolved by the parser, which can also be alphanumeric or a stable URL-derived hash.
|
||||
|
||||
The target ID and `virtual_id` are not part of this key. Consequently, two targets with different virtual IDs share the
|
||||
same content session when they refer to the same `input_id` and `input_stream_id`. Their access leases, routing context,
|
||||
and user admission remain separate. Origin credentials, direct origin URLs, and the currently selected provider mirror
|
||||
also do not change the shared session identity.
|
||||
|
||||
The pair `(input_id, input_stream_id)` must uniquely identify one stream within the processed inputs. Target mapping must
|
||||
not change the captured input stream ID.
|
||||
|
||||
## Requirements
|
||||
|
||||
Shared HLS starts only when both switches are enabled:
|
||||
@@ -98,7 +118,8 @@ targets:
|
||||
## Request flow in plain language
|
||||
|
||||
1. A player opens the normal generated Tuliprox HLS live URL.
|
||||
2. Tuliprox authenticates the user and checks whether this target allows Shared HLS.
|
||||
2. Tuliprox authenticates the user, resolves the unchanged input stream ID, and checks whether this target allows Shared
|
||||
HLS.
|
||||
3. Tuliprox creates a new access lease for that playback.
|
||||
4. Tuliprox returns an HTTP `307 Temporary Redirect` to the canonical shared HLS manifest URL.
|
||||
5. The player follows the redirect and requests `/hls/shared/live/.../manifest.m3u8`.
|
||||
|
||||
@@ -278,6 +278,8 @@ pub trait FieldSetAccessor {
|
||||
|
||||
pub trait PlaylistEntry: Send + Sync {
|
||||
fn get_virtual_id(&self) -> VirtualId;
|
||||
/// Returns the immutable stream identifier captured from the input playlist before target mappings.
|
||||
fn get_input_stream_id(&self) -> Option<Arc<str>>;
|
||||
fn get_provider_id(&self) -> Option<u32>;
|
||||
fn get_category_id(&self) -> Option<u32>;
|
||||
fn get_provider_url(&self) -> Arc<str>;
|
||||
@@ -335,6 +337,9 @@ pub struct PlaylistItemHeader {
|
||||
pub xtream_cluster: XtreamCluster,
|
||||
#[serde(default)]
|
||||
pub item_type: PlaylistItemType,
|
||||
/// Stable provider/origin ID captured before any target transformation.
|
||||
#[serde(default, with = "arc_str_serde")]
|
||||
pub input_stream_id: Arc<str>,
|
||||
}
|
||||
|
||||
impl Default for PlaylistItemHeader {
|
||||
@@ -361,11 +366,30 @@ impl Default for PlaylistItemHeader {
|
||||
category_id: 0,
|
||||
input_name: "".intern(),
|
||||
source_ordinal: 0,
|
||||
input_stream_id: "".intern(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaylistItemHeader {
|
||||
/// Captures the input playlist item ID at the input-processing boundary.
|
||||
///
|
||||
/// This must run before target transformations and must not be used to recover an identity
|
||||
/// from a target-mutated `id`.
|
||||
pub fn freeze_input_stream_id(&mut self) {
|
||||
if self.input_stream_id.is_empty() && !self.id.is_empty() {
|
||||
self.input_stream_id = Arc::clone(&self.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the input stream ID captured at the input-processing boundary.
|
||||
///
|
||||
/// Legacy fallback must be resolved before a target transformation starts. Once an item is
|
||||
/// represented by this header, the mutable target-facing `id` is never a valid fallback.
|
||||
pub fn get_input_stream_id(&self) -> Option<Arc<str>> {
|
||||
(!self.input_stream_id.is_empty()).then(|| Arc::clone(&self.input_stream_id))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn gen_uuid(&mut self) {
|
||||
self.uuid = generate_runtime_playlist_uuid(&self.input_name, &self.id, self.item_type, &self.url);
|
||||
@@ -544,6 +568,9 @@ pub struct M3uPlaylistItem {
|
||||
pub source_ordinal: u32,
|
||||
#[serde(default)]
|
||||
pub additional_properties: Option<StreamProperties>,
|
||||
/// Stable provider/origin ID captured before any target transformation.
|
||||
#[serde(default, with = "arc_str_serde")]
|
||||
pub input_stream_id: Arc<str>,
|
||||
}
|
||||
|
||||
fn write_m3u_attr(line: &mut String, name: &str, value: &str) { let _ = write!(line, " {name}=\"{value}\""); }
|
||||
@@ -660,6 +687,11 @@ impl PlaylistEntry for M3uPlaylistItem {
|
||||
#[inline]
|
||||
fn get_virtual_id(&self) -> VirtualId { self.virtual_id }
|
||||
|
||||
fn get_input_stream_id(&self) -> Option<Arc<str>> {
|
||||
let input_stream_id = if self.input_stream_id.is_empty() { &self.provider_id } else { &self.input_stream_id };
|
||||
(!input_stream_id.is_empty()).then(|| Arc::clone(input_stream_id))
|
||||
}
|
||||
|
||||
fn get_provider_id(&self) -> Option<u32> { get_provider_id(&self.provider_id, &self.url) }
|
||||
#[inline]
|
||||
fn get_category_id(&self) -> Option<u32> { None }
|
||||
@@ -884,6 +916,9 @@ pub struct XtreamPlaylistItem {
|
||||
pub channel_no: u32,
|
||||
#[serde(default)]
|
||||
pub source_ordinal: u32,
|
||||
/// Stable provider/origin ID captured before any target transformation.
|
||||
#[serde(default, with = "arc_str_serde")]
|
||||
pub input_stream_id: Arc<str>,
|
||||
}
|
||||
|
||||
impl XtreamPlaylistItem {
|
||||
@@ -940,6 +975,13 @@ impl XtreamPlaylistItem {
|
||||
impl PlaylistEntry for XtreamPlaylistItem {
|
||||
#[inline]
|
||||
fn get_virtual_id(&self) -> VirtualId { self.virtual_id }
|
||||
fn get_input_stream_id(&self) -> Option<Arc<str>> {
|
||||
if self.input_stream_id.is_empty() {
|
||||
(self.provider_id > 0).then(|| self.provider_id.to_string().intern())
|
||||
} else {
|
||||
Some(Arc::clone(&self.input_stream_id))
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn get_provider_id(&self) -> Option<u32> { Some(self.provider_id) }
|
||||
#[inline]
|
||||
@@ -1166,7 +1208,11 @@ impl PlaylistItem {
|
||||
impl From<&PlaylistItem> for XtreamPlaylistItem {
|
||||
fn from(item: &PlaylistItem) -> Self {
|
||||
let header = &item.header;
|
||||
let provider_id = get_provider_id(&header.id, &header.url).unwrap_or_default();
|
||||
let input_stream_id = Arc::clone(&header.input_stream_id);
|
||||
let missing_live_input_identity =
|
||||
input_stream_id.is_empty() && (header.item_type.is_live() || header.item_type == PlaylistItemType::Catchup);
|
||||
let provider_id =
|
||||
if missing_live_input_identity { 0 } else { get_provider_id(&header.id, &header.url).unwrap_or_default() };
|
||||
let additional_properties = PlaylistItem::get_additional_properties(header);
|
||||
|
||||
XtreamPlaylistItem {
|
||||
@@ -1192,6 +1238,7 @@ impl From<&PlaylistItem> for XtreamPlaylistItem {
|
||||
input_name: Arc::clone(&header.input_name),
|
||||
channel_no: header.chno,
|
||||
source_ordinal: header.source_ordinal,
|
||||
input_stream_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1199,9 +1246,12 @@ impl From<&PlaylistItem> for XtreamPlaylistItem {
|
||||
impl From<&PlaylistItem> for M3uPlaylistItem {
|
||||
fn from(item: &PlaylistItem) -> Self {
|
||||
let header = &item.header;
|
||||
let input_stream_id = Arc::clone(&header.input_stream_id);
|
||||
let missing_live_input_identity =
|
||||
input_stream_id.is_empty() && (header.item_type.is_live() || header.item_type == PlaylistItemType::Catchup);
|
||||
M3uPlaylistItem {
|
||||
virtual_id: header.virtual_id,
|
||||
provider_id: Arc::clone(&header.id),
|
||||
provider_id: if missing_live_input_identity { "".intern() } else { Arc::clone(&header.id) },
|
||||
name: if header.item_type == PlaylistItemType::Series {
|
||||
Arc::clone(&header.title)
|
||||
} else {
|
||||
@@ -1226,6 +1276,7 @@ impl From<&PlaylistItem> for M3uPlaylistItem {
|
||||
t_catchup_mode: None,
|
||||
source_ordinal: header.source_ordinal,
|
||||
additional_properties: header.additional_properties.clone(),
|
||||
input_stream_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1266,10 +1317,15 @@ impl From<&PlaylistItem> for CommonPlaylistItem {
|
||||
|
||||
impl From<&XtreamPlaylistItem> for PlaylistItem {
|
||||
fn from(item: &XtreamPlaylistItem) -> Self {
|
||||
let input_stream_id = item.get_input_stream_id();
|
||||
let header = PlaylistItemHeader {
|
||||
uuid: item.get_uuid(),
|
||||
virtual_id: item.virtual_id,
|
||||
id: item.provider_id.to_string().intern(),
|
||||
id: if item.provider_id == 0 && input_stream_id.is_none() {
|
||||
"".intern()
|
||||
} else {
|
||||
item.provider_id.to_string().intern()
|
||||
},
|
||||
name: item.name.clone(),
|
||||
title: item.title.clone(),
|
||||
logo: item.logo.clone(),
|
||||
@@ -1288,6 +1344,7 @@ impl From<&XtreamPlaylistItem> for PlaylistItem {
|
||||
time_shift: "".intern(),
|
||||
additional_properties: item.additional_properties.clone(),
|
||||
source_ordinal: item.source_ordinal,
|
||||
input_stream_id: input_stream_id.unwrap_or_else(|| "".intern()),
|
||||
};
|
||||
|
||||
PlaylistItem { header }
|
||||
@@ -1318,6 +1375,7 @@ impl From<&M3uPlaylistItem> for PlaylistItem {
|
||||
time_shift: item.time_shift.clone(),
|
||||
additional_properties: item.additional_properties.clone(),
|
||||
source_ordinal: item.source_ordinal,
|
||||
input_stream_id: item.get_input_stream_id().unwrap_or_else(|| "".intern()),
|
||||
};
|
||||
|
||||
PlaylistItem { header }
|
||||
@@ -1328,6 +1386,9 @@ impl PlaylistEntry for PlaylistItem {
|
||||
#[inline]
|
||||
fn get_virtual_id(&self) -> VirtualId { self.header.virtual_id }
|
||||
|
||||
#[inline]
|
||||
fn get_input_stream_id(&self) -> Option<Arc<str>> { self.header.get_input_stream_id() }
|
||||
|
||||
fn get_provider_id(&self) -> Option<u32> {
|
||||
let header = &self.header;
|
||||
get_provider_id(&header.id, &header.url)
|
||||
@@ -1389,6 +1450,7 @@ impl PlaylistGroup {
|
||||
#[inline]
|
||||
pub fn on_load(&mut self) {
|
||||
for pl in &mut self.channels {
|
||||
pl.header.freeze_input_stream_id();
|
||||
pl.header.gen_uuid();
|
||||
pl.header.category_id = self.id;
|
||||
}
|
||||
@@ -1554,7 +1616,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn xtream_playlist_item_conversion_falls_back_to_numeric_id_in_url() {
|
||||
let item = PlaylistItem {
|
||||
let mut item = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
id: "channel-alpha".intern(),
|
||||
url: "http://provider.example/live/user/pass/12345.ts".intern(),
|
||||
@@ -1564,16 +1626,142 @@ mod tests {
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
};
|
||||
item.header.freeze_input_stream_id();
|
||||
|
||||
let xtream_item = XtreamPlaylistItem::from(&item);
|
||||
assert_eq!(xtream_item.provider_id, 12345);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xtream_playlist_item_preserves_numeric_input_stream_id_as_string() {
|
||||
let mut item = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
id: "80510".intern(),
|
||||
virtual_id: 1001,
|
||||
url: "http://provider.example/live/user/pass/80510.ts".intern(),
|
||||
input_name: "input".intern(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
};
|
||||
item.header.freeze_input_stream_id();
|
||||
|
||||
let xtream_item = XtreamPlaylistItem::from(&item);
|
||||
|
||||
assert_eq!(xtream_item.provider_id, 80510);
|
||||
assert_eq!(xtream_item.input_stream_id.as_ref(), "80510");
|
||||
assert_eq!(xtream_item.get_input_stream_id().as_deref(), Some("80510"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alphanumeric_m3u_input_stream_id_survives_xtream_materialization() {
|
||||
let mut source = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
id: "channel-alpha".intern(),
|
||||
url: "http://provider.example/live/user/pass/12345.ts".intern(),
|
||||
input_name: "input".intern(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
};
|
||||
source.header.freeze_input_stream_id();
|
||||
|
||||
let m3u_item = M3uPlaylistItem::from(&source);
|
||||
let common_item = PlaylistItem::from(&m3u_item);
|
||||
let xtream_item = XtreamPlaylistItem::from(&common_item);
|
||||
|
||||
assert_eq!(m3u_item.input_stream_id.as_ref(), "channel-alpha");
|
||||
assert_eq!(xtream_item.provider_id, 12345);
|
||||
assert_eq!(xtream_item.input_stream_id.as_ref(), "channel-alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_hash_input_stream_id_is_preserved_verbatim() {
|
||||
let mut source = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
id: "d34db33f".intern(),
|
||||
url: "http://provider.example/live/channel.m3u8".intern(),
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
};
|
||||
source.header.freeze_input_stream_id();
|
||||
|
||||
let m3u_item = M3uPlaylistItem::from(&source);
|
||||
|
||||
assert_eq!(m3u_item.input_stream_id.as_ref(), "d34db33f");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_field_mapping_cannot_change_frozen_input_stream_id() {
|
||||
let mut header = PlaylistItemHeader { id: "origin-alpha".intern(), ..PlaylistItemHeader::default() };
|
||||
header.freeze_input_stream_id();
|
||||
|
||||
assert!(header.set_field("id", "target-id"));
|
||||
|
||||
assert_eq!(header.id.as_ref(), "target-id");
|
||||
assert_eq!(header.input_stream_id.as_ref(), "origin-alpha");
|
||||
assert!(!header.set_field("input_stream_id", "unexpected"));
|
||||
assert_eq!(header.input_stream_id.as_ref(), "origin-alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_playlist_items_fall_back_to_provider_id_without_virtual_id() {
|
||||
let mut source = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
id: "legacy-alpha".intern(),
|
||||
virtual_id: 7001,
|
||||
url: "http://provider.example/live/user/pass/80510.ts".intern(),
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
};
|
||||
source.header.freeze_input_stream_id();
|
||||
let mut m3u_item = M3uPlaylistItem::from(&source);
|
||||
m3u_item.input_stream_id = "".intern();
|
||||
let mut xtream_item = XtreamPlaylistItem::from(&source);
|
||||
xtream_item.input_stream_id = "".intern();
|
||||
|
||||
assert_eq!(m3u_item.get_input_stream_id().as_deref(), Some("legacy-alpha"));
|
||||
assert_eq!(xtream_item.get_input_stream_id().as_deref(), Some("80510"));
|
||||
xtream_item.provider_id = 0;
|
||||
xtream_item.url = "http://provider.example/live/channel.ts".intern();
|
||||
assert_eq!(xtream_item.get_input_stream_id(), None);
|
||||
|
||||
let mut legacy_common_item = PlaylistItem::from(&xtream_item);
|
||||
assert!(legacy_common_item.header.id.is_empty());
|
||||
assert_eq!(legacy_common_item.get_input_stream_id(), None);
|
||||
legacy_common_item.header.id = "target-mapped-id".intern();
|
||||
let rematerialized_m3u_item = M3uPlaylistItem::from(&legacy_common_item);
|
||||
let rematerialized_xtream_item = XtreamPlaylistItem::from(&legacy_common_item);
|
||||
assert_eq!(legacy_common_item.header.id.as_ref(), "target-mapped-id");
|
||||
assert_eq!(legacy_common_item.get_input_stream_id(), None);
|
||||
assert!(rematerialized_m3u_item.provider_id.is_empty());
|
||||
assert_eq!(rematerialized_m3u_item.get_input_stream_id(), None);
|
||||
assert_eq!(rematerialized_xtream_item.provider_id, 0);
|
||||
assert_eq!(rematerialized_xtream_item.get_input_stream_id(), None);
|
||||
|
||||
xtream_item.input_stream_id = "explicit-origin-alpha".intern();
|
||||
assert_eq!(xtream_item.get_input_stream_id().as_deref(), Some("explicit-origin-alpha"));
|
||||
let identified_common_item = PlaylistItem::from(&xtream_item);
|
||||
assert_eq!(identified_common_item.header.input_stream_id.as_ref(), "explicit-origin-alpha");
|
||||
assert_eq!(identified_common_item.get_input_stream_id().as_deref(), Some("explicit-origin-alpha"));
|
||||
|
||||
let identified_m3u_item = M3uPlaylistItem::from(&identified_common_item);
|
||||
let identified_xtream_item = XtreamPlaylistItem::from(&identified_common_item);
|
||||
assert_eq!(identified_m3u_item.input_stream_id.as_ref(), "explicit-origin-alpha");
|
||||
assert_eq!(identified_m3u_item.get_input_stream_id().as_deref(), Some("explicit-origin-alpha"));
|
||||
assert_eq!(identified_xtream_item.provider_id, 0);
|
||||
assert_eq!(identified_xtream_item.input_stream_id.as_ref(), "explicit-origin-alpha");
|
||||
assert_eq!(identified_xtream_item.get_input_stream_id().as_deref(), Some("explicit-origin-alpha"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m3u_to_m3u_emits_tvg_id_from_epg_channel_id() {
|
||||
let item = M3uPlaylistItem {
|
||||
virtual_id: 0,
|
||||
provider_id: "prov1".intern(),
|
||||
input_stream_id: "prov1".intern(),
|
||||
name: "Test Channel".intern(),
|
||||
chno: 0,
|
||||
logo: "".intern(),
|
||||
@@ -1607,6 +1795,7 @@ mod tests {
|
||||
let item = M3uPlaylistItem {
|
||||
virtual_id: 0,
|
||||
provider_id: "prov1".intern(),
|
||||
input_stream_id: "prov1".intern(),
|
||||
name: "Test Channel".intern(),
|
||||
chno: 0,
|
||||
logo: "".intern(),
|
||||
@@ -1638,6 +1827,7 @@ mod tests {
|
||||
let item = M3uPlaylistItem {
|
||||
virtual_id: 0,
|
||||
provider_id: "prov1".intern(),
|
||||
input_stream_id: "prov1".intern(),
|
||||
name: "Test Channel".intern(),
|
||||
chno: 42,
|
||||
logo: "".intern(),
|
||||
@@ -1669,6 +1859,7 @@ mod tests {
|
||||
let item = M3uPlaylistItem {
|
||||
virtual_id: 0,
|
||||
provider_id: "prov1".intern(),
|
||||
input_stream_id: "prov1".intern(),
|
||||
name: "Test Channel".intern(),
|
||||
chno: 0,
|
||||
logo: "".intern(),
|
||||
@@ -1700,6 +1891,7 @@ mod tests {
|
||||
let item = M3uPlaylistItem {
|
||||
virtual_id: 0,
|
||||
provider_id: "prov1".intern(),
|
||||
input_stream_id: "prov1".intern(),
|
||||
name: "Test Channel".intern(),
|
||||
chno: 0,
|
||||
logo: "".intern(),
|
||||
@@ -1747,6 +1939,7 @@ mod tests {
|
||||
let item = M3uPlaylistItem {
|
||||
virtual_id: 0,
|
||||
provider_id: "prov1".intern(),
|
||||
input_stream_id: "prov1".intern(),
|
||||
name: "Test Channel".intern(),
|
||||
chno: 0,
|
||||
logo: "".intern(),
|
||||
|
||||
@@ -383,6 +383,7 @@ mod tests {
|
||||
input_name: "demo".intern(),
|
||||
channel_no: 0,
|
||||
source_ordinal: 0,
|
||||
input_stream_id: "1".intern(),
|
||||
};
|
||||
|
||||
let stream_channel = create_stream_channel_with_type(1, &playlist_item, PlaylistItemType::Video);
|
||||
@@ -412,6 +413,7 @@ mod tests {
|
||||
input_name: "provider-input".intern(),
|
||||
channel_no: 0,
|
||||
source_ordinal: 0,
|
||||
input_stream_id: "1".intern(),
|
||||
};
|
||||
|
||||
let stream_channel = playlist_item.to_stream_channel(1);
|
||||
@@ -444,6 +446,7 @@ mod tests {
|
||||
t_catchup_mode: None,
|
||||
source_ordinal: 0,
|
||||
additional_properties: None,
|
||||
input_stream_id: "42".intern(),
|
||||
};
|
||||
|
||||
let channel = pli.to_stream_channel(7).with_epg_reference_ts(Some(1_700_000_000));
|
||||
@@ -479,6 +482,7 @@ mod tests {
|
||||
t_catchup_mode: None,
|
||||
source_ordinal: 0,
|
||||
additional_properties: None,
|
||||
input_stream_id: "42".intern(),
|
||||
};
|
||||
|
||||
// to_stream_channel() defaults to None, and with_epg_reference_ts(None)
|
||||
|
||||
@@ -1051,6 +1051,7 @@ mod tests {
|
||||
input_name: "input".into(),
|
||||
channel_no: 0,
|
||||
source_ordinal: 0,
|
||||
input_stream_id: "1001".into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,6 @@ pub const DEFAULT_HOME_ENV_VAR: &str = "TULIPROX_HOME";
|
||||
pub const DEFAULT_WEB_ROOT_ENV_VAR: &str = "TULIPROX_WEB_ROOT";
|
||||
pub const DNS_RESOLVED_FILE: &str = "provider_dns_resolved.json";
|
||||
|
||||
pub const ENCODING_GZIP: &str = "gzip";
|
||||
pub const ENCODING_DEFLATE: &str = "deflate";
|
||||
|
||||
pub const FILENAME_TRIM_PATTERNS: &[char] = &['.', '-', '_'];
|
||||
|
||||
const SUPPORTED_RESPONSE_HEADERS: &[&str] = &[
|
||||
@@ -27,7 +24,6 @@ const SUPPORTED_RESPONSE_HEADERS: &[&str] = &[
|
||||
"content-length",
|
||||
"content-range",
|
||||
"vary",
|
||||
"transfer-encoding",
|
||||
//"connection",
|
||||
"access-control-allow-origin",
|
||||
"access-control-allow-credentials",
|
||||
|
||||
Reference in New Issue
Block a user