use std::fmt;
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct HlsSegment {
pub url: String,
pub duration: f64,
pub sequence: u64,
}
impl fmt::Display for HlsSegment {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "HlsSegment(seq={}, duration={:.2}s)", self.sequence, self.duration)
}
}
#[derive(Debug, Clone)]
pub struct HlsPlaylist {
pub target_duration: f64,
pub media_sequence: u64,
pub segments: Vec<HlsSegment>,
pub is_endlist: bool,
}
impl fmt::Display for HlsPlaylist {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"HlsPlaylist(segments={}, target_duration={:.1}s, media_sequence={}, endlist={})",
self.segments.len(),
self.target_duration,
self.media_sequence,
self.is_endlist
)
}
}
#[derive(Debug, Clone)]
pub struct HlsVariant {
pub url: String,
pub bandwidth: u64,
pub resolution: Option<String>,
pub codecs: Option<String>,
}
impl fmt::Display for HlsVariant {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"HlsVariant(bandwidth={}, resolution={})",
self.bandwidth,
self.resolution.as_deref().unwrap_or("unknown")
)
}
}
fn resolve_url(base: &str, uri: &str) -> String {
if uri.starts_with("http://") || uri.starts_with("https://") {
return uri.to_string();
}
url::Url::parse(base)
.and_then(|base_url| base_url.join(uri))
.map(|resolved| resolved.to_string())
.unwrap_or_else(|_| {
if let Some(pos) = base.rfind('/') {
format!("{}/{}", &base[..pos], uri)
} else {
uri.to_string()
}
})
}
pub async fn parse_master(client: &reqwest::Client, url: &str) -> Result<Vec<HlsVariant>> {
tracing::debug!(url = url, "📡 Fetching HLS master playlist");
let body = client
.get(url)
.send()
.await
.map_err(|e| Error::http(url, "fetching master playlist", e))?
.bytes()
.await
.map_err(|e| Error::http(url, "reading master playlist body", e))?;
let parsed = m3u8_rs::parse_playlist_res(&body).map_err(|e| Error::hls_parsing(url, format!("{e:?}")))?;
match parsed {
m3u8_rs::Playlist::MasterPlaylist(master) => {
let variants: Vec<HlsVariant> = master
.variants
.into_iter()
.map(|v| {
let resolution = v.resolution.map(|r| format!("{}x{}", r.width, r.height));
HlsVariant {
url: resolve_url(url, &v.uri),
bandwidth: v.bandwidth,
resolution,
codecs: v.codecs,
}
})
.collect();
tracing::debug!(url = url, variant_count = variants.len(), "✅ Parsed master playlist");
Ok(variants)
}
m3u8_rs::Playlist::MediaPlaylist(_) => {
Err(Error::hls_parsing(url, "expected master playlist, got media playlist"))
}
}
}
pub async fn parse_media(client: &reqwest::Client, url: &str) -> Result<HlsPlaylist> {
tracing::debug!(url = url, "📡 Fetching HLS media playlist");
let body = client
.get(url)
.send()
.await
.map_err(|e| Error::http(url, "fetching media playlist", e))?
.bytes()
.await
.map_err(|e| Error::http(url, "reading media playlist body", e))?;
let parsed = m3u8_rs::parse_playlist_res(&body).map_err(|e| Error::hls_parsing(url, format!("{e:?}")))?;
match parsed {
m3u8_rs::Playlist::MediaPlaylist(media) => {
let media_sequence = media.media_sequence;
let target_duration = media.target_duration as f64;
let is_endlist = media.end_list;
let segments: Vec<HlsSegment> = media
.segments
.into_iter()
.enumerate()
.map(|(i, seg)| HlsSegment {
url: resolve_url(url, &seg.uri),
duration: seg.duration as f64,
sequence: media_sequence + i as u64,
})
.collect();
let playlist = HlsPlaylist {
target_duration,
media_sequence,
segments,
is_endlist,
};
tracing::debug!(
url = url,
segment_count = playlist.segments.len(),
media_sequence = playlist.media_sequence,
is_endlist = playlist.is_endlist,
"✅ Parsed media playlist"
);
Ok(playlist)
}
m3u8_rs::Playlist::MasterPlaylist(_) => {
Err(Error::hls_parsing(url, "expected media playlist, got master playlist"))
}
}
}
pub fn select_variant(variants: &[HlsVariant], target_bandwidth: Option<u64>) -> Option<&HlsVariant> {
if variants.is_empty() {
return None;
}
match target_bandwidth {
Some(max_bw) => {
let matching = variants
.iter()
.filter(|v| v.bandwidth <= max_bw)
.max_by_key(|v| v.bandwidth);
matching.or_else(|| variants.iter().min_by_key(|v| v.bandwidth))
}
None => variants.iter().max_by_key(|v| v.bandwidth),
}
}