Files
tuliprox/backend/src/api/model/hls_cache/map.rs
T
knylbyteandGitHub c70fbc671e 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.
2026-07-15 10:40:30 +02:00

194 lines
6.9 KiB
Rust

use super::{cache::MapCacheKey, ids::ProxySessionId, session::HlsSession, timeline::CacheAccessState};
use crate::processing::parser::hls::origin_manifest::ParsedByteRange;
use axum::http::StatusCode;
use std::{
fmt,
hash::{Hash, Hasher},
sync::Arc,
};
/// Proxy-visible identifier for one EXT-X-MAP cache resource.
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct ProxyMapId(pub u64);
impl From<u64> for ProxyMapId {
fn from(value: u64) -> Self { Self(value) }
}
#[derive(Clone, Eq, PartialEq)]
pub struct OriginMapKey {
pub origin_epoch: u64,
/// Concrete absolute MAP fetch URI resolved against the final manifest URL.
///
/// This may intentionally contain a provider mirror, redirect target, or CDN
/// host. Do not normalize it back to `provider://` or the original manifest
/// host unless a separate semantic identity is introduced and refetch safety
/// for relative EXT-X-MAP URIs is proven.
pub resolved_origin_uri: String,
pub byte_range: Option<ParsedByteRange>,
}
impl fmt::Debug for OriginMapKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OriginMapKey")
.field("origin_epoch", &self.origin_epoch)
.field("resolved_origin_uri", &"<redacted>")
.field("byte_range", &self.byte_range)
.finish()
}
}
impl Hash for OriginMapKey {
fn hash<H: Hasher>(&self, state: &mut H) {
self.origin_epoch.hash(state);
self.resolved_origin_uri.hash(state);
match self.byte_range {
Some(byte_range) => {
1_u8.hash(state);
byte_range.length.hash(state);
byte_range.offset.hash(state);
}
None => 0_u8.hash(state),
}
}
}
/// Volatile concrete origin URL for one EXT-X-MAP download.
///
/// The URL is resolved against the final fetched manifest URL and may include a redirect/CDN host. Use it only as a
/// fetch target or sanitized diagnostics; stable proxy cache identity is `MapCacheKey`.
#[derive(Clone, Eq, PartialEq)]
pub struct OriginMapFetchRef {
/// Concrete URL used to refetch the MAP object.
///
/// Keep this aligned with `OriginMapKey::resolved_origin_uri`; relative MAP
/// URIs must remain resolved against the final manifest URL after redirects.
pub resolved_origin_url: String,
pub byte_range: Option<ParsedByteRange>,
pub valid_until_ms: Option<u64>,
}
impl OriginMapFetchRef {
pub fn is_valid_at(&self, now_ms: u64) -> bool {
self.valid_until_ms.is_none_or(|valid_until_ms| now_ms <= valid_until_ms)
}
}
impl fmt::Debug for OriginMapFetchRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OriginMapFetchRef")
.field("resolved_origin_url", &"<redacted>")
.field("byte_range", &self.byte_range)
.field("valid_until_ms", &self.valid_until_ms)
.finish()
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum MapCacheStatus {
Discovered,
Queued { queued_at_ms: u64 },
Fetching { started_at_ms: u64 },
Ready { content_length: u64, ready_at_ms: u64 },
FailedRetryable { failed_at_ms: u64, retry_after_ms: u64 },
FailedPermanent { failed_at_ms: u64, status: Option<StatusCode> },
Expired,
}
#[derive(Clone, Eq, PartialEq)]
pub struct MapEntry {
pub proxy_map_id: ProxyMapId,
pub origin_key: OriginMapKey,
pub proxy_file_ext: String,
pub content_type: String,
pub cache_key: MapCacheKey,
pub origin_fetch_ref: Option<OriginMapFetchRef>,
pub byte_range: Option<ParsedByteRange>,
pub status: MapCacheStatus,
pub access: Arc<CacheAccessState>,
}
impl fmt::Debug for MapEntry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MapEntry")
.field("proxy_map_id", &self.proxy_map_id)
.field("origin_key", &self.origin_key)
.field("proxy_file_ext", &self.proxy_file_ext)
.field("content_type", &self.content_type)
.field("cache_key", &self.cache_key)
.field("origin_fetch_ref", &self.origin_fetch_ref)
.field("byte_range", &self.byte_range)
.field("status", &self.status)
.field("active_readers", &self.access.active_readers())
.field("last_accessed_at_ms", &self.access.last_accessed_at_ms())
.finish()
}
}
impl MapEntry {
pub fn default_content_type() -> &'static str { "video/mp4" }
/// Creates a proxy MAP entry from a concrete origin MAP key.
///
/// `origin_fetch_ref` intentionally starts from `origin_key.resolved_origin_uri`.
/// Until a separate semantic MAP identity exists, this preserves the final
/// redirect/CDN fetch URL required to reload relative EXT-X-MAP resources.
pub fn new(
proxy_session_id: &ProxySessionId,
proxy_map_id: ProxyMapId,
origin_key: OriginMapKey,
proxy_file_ext: String,
) -> Self {
Self {
proxy_map_id,
origin_fetch_ref: Some(OriginMapFetchRef {
resolved_origin_url: origin_key.resolved_origin_uri.clone(),
byte_range: origin_key.byte_range,
valid_until_ms: None,
}),
byte_range: origin_key.byte_range,
cache_key: MapCacheKey::new(proxy_session_id.clone(), proxy_map_id, &proxy_file_ext),
content_type: Self::default_content_type().to_string(),
proxy_file_ext,
origin_key,
status: MapCacheStatus::Discovered,
access: Arc::new(CacheAccessState::new()),
}
}
}
impl HlsSession {
pub fn queue_map_fetch_candidates(&mut self, now_ms: u64) {
if self.is_gc_marked_for_removal() {
return;
}
for map in self.maps.values_mut() {
if matches!(map.status, MapCacheStatus::Discovered) && map.origin_fetch_ref.is_some() {
map.status = MapCacheStatus::Queued { queued_at_ms: now_ms };
}
}
}
}
#[cfg(test)]
mod tests {
use super::{MapEntry, OriginMapKey, ParsedByteRange, ProxyMapId};
use crate::api::model::ProxySessionId;
#[test]
fn map_entry_preserves_concrete_fetch_url_and_byte_range() {
let key = OriginMapKey {
origin_epoch: 0,
resolved_origin_uri: "https://cdn.example.net/live/redirected/init.mp4".to_string(),
byte_range: Some(ParsedByteRange { length: 100, offset: 50 }),
};
let entry = MapEntry::new(&ProxySessionId("proxy".to_string()), ProxyMapId(7), key.clone(), "mp4".to_string());
assert_eq!(entry.origin_key, key);
let fetch_ref = entry.origin_fetch_ref.as_ref().expect("fetch ref");
assert_eq!(fetch_ref.resolved_origin_url, "https://cdn.example.net/live/redirected/init.mp4");
assert_eq!(fetch_ref.byte_range, Some(ParsedByteRange { length: 100, offset: 50 }));
}
}