319 lines
11 KiB
Rust
319 lines
11 KiB
Rust
//! An HLS [`SourceStream`]: fetches an `.m3u8` media playlist and streams its
|
|
//! mp3 segments in order as one continuous byte stream.
|
|
//!
|
|
//! SoundCloud (and other HLS sources) serve audio as a playlist of short mp3
|
|
//! segments rather than one file. mp3 frames byte-concatenate into a valid
|
|
//! stream (the fact `ffmpeg -c copy` relies on), so streaming the segments in
|
|
//! order yields bytes rodio's symphonia mp3 decoder handles unchanged
|
|
//! (architecture/soundcloud-provider.md D4).
|
|
//!
|
|
//! Sibling to [`crate::windowed_http::WindowedHttpStream`]. This is **forward
|
|
//! only**: it reports the source as non-seekable and length-less, so symphonia
|
|
//! does not attempt an end-seek that would need a known total length (the
|
|
//! rodio-0.22 panic the opus work documented).
|
|
//!
|
|
//! Never logs the playlist or segment URLs — they are signed and ephemeral.
|
|
|
|
use std::io;
|
|
use std::pin::Pin;
|
|
use std::task::{Context, Poll};
|
|
|
|
use bytes::Bytes;
|
|
use futures::{Future, Stream};
|
|
use stream_download::source::{DecodeError, SourceStream};
|
|
use tracing::{debug, trace, warn};
|
|
use url::Url;
|
|
|
|
/// Parameters for [`HlsStream::create`].
|
|
#[derive(Clone, Debug)]
|
|
pub struct HlsParams {
|
|
/// The `.m3u8` media-playlist URL.
|
|
pub url: Url,
|
|
pub client: reqwest::Client,
|
|
}
|
|
|
|
impl HlsParams {
|
|
pub fn new(url: Url, client: reqwest::Client) -> Self {
|
|
Self { url, client }
|
|
}
|
|
}
|
|
|
|
/// Error creating the stream (playlist fetch/parse failed).
|
|
#[derive(Debug, thiserror::Error)]
|
|
#[error("{0}")]
|
|
pub struct HlsError(String);
|
|
|
|
impl DecodeError for HlsError {}
|
|
|
|
type BytesStream = Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send + Sync>>;
|
|
type SegmentFuture = Pin<Box<dyn Future<Output = io::Result<BytesStream>> + Send + Sync>>;
|
|
|
|
/// A parsed playlist: either the media (segment) playlist we want, or a master
|
|
/// playlist pointing at variant playlists (we follow the first).
|
|
enum Playlist {
|
|
Media(Vec<Url>),
|
|
Master(Url),
|
|
}
|
|
|
|
enum State {
|
|
/// Ready to fetch `segments[cursor]`.
|
|
Idle,
|
|
/// Waiting for a segment response.
|
|
Requesting(SegmentFuture),
|
|
/// Draining a segment body.
|
|
Streaming(BytesStream),
|
|
Finished,
|
|
}
|
|
|
|
/// See the module docs. Streams `segments[cursor]` bytes, advancing at segment
|
|
/// boundaries; finishes after the last.
|
|
pub struct HlsStream {
|
|
client: reqwest::Client,
|
|
/// Ordered mp3 segment URLs parsed from the media playlist.
|
|
segments: Vec<Url>,
|
|
/// Index of the next segment to fetch.
|
|
cursor: usize,
|
|
state: State,
|
|
}
|
|
|
|
impl std::fmt::Debug for HlsStream {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("HlsStream")
|
|
.field("segments", &self.segments.len())
|
|
.field("cursor", &self.cursor)
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
impl HlsStream {
|
|
/// Schedules the fetch of the segment at `cursor`, or finishes when none
|
|
/// remain.
|
|
fn schedule_next(&mut self) {
|
|
let Some(url) = self.segments.get(self.cursor).cloned() else {
|
|
self.state = State::Finished;
|
|
return;
|
|
};
|
|
self.cursor += 1;
|
|
self.state = State::Requesting(Box::pin(fetch_segment(self.client.clone(), url)));
|
|
}
|
|
}
|
|
|
|
/// GETs one segment, returning its body byte-stream. A non-success status is an
|
|
/// error carrying the status only (never the signed URL).
|
|
async fn fetch_segment(client: reqwest::Client, url: Url) -> io::Result<BytesStream> {
|
|
trace!("fetching hls segment");
|
|
let resp = client.get(url).send().await.map_err(|err| {
|
|
io::Error::other(format!("segment request failed: {}", err.without_url()))
|
|
})?;
|
|
if !resp.status().is_success() {
|
|
return Err(io::Error::other(format!(
|
|
"segment request rejected: {}",
|
|
resp.status()
|
|
)));
|
|
}
|
|
Ok(Box::pin(resp.bytes_stream()))
|
|
}
|
|
|
|
impl Stream for HlsStream {
|
|
type Item = io::Result<Bytes>;
|
|
|
|
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
|
let this = &mut *self;
|
|
loop {
|
|
match &mut this.state {
|
|
State::Finished => return Poll::Ready(None),
|
|
State::Idle => this.schedule_next(),
|
|
State::Requesting(future) => match future.as_mut().poll(cx) {
|
|
Poll::Pending => return Poll::Pending,
|
|
Poll::Ready(Ok(stream)) => this.state = State::Streaming(stream),
|
|
Poll::Ready(Err(err)) => {
|
|
this.state = State::Finished;
|
|
return Poll::Ready(Some(Err(err)));
|
|
}
|
|
},
|
|
State::Streaming(stream) => match stream.as_mut().poll_next(cx) {
|
|
Poll::Pending => return Poll::Pending,
|
|
Poll::Ready(Some(Ok(bytes))) => return Poll::Ready(Some(Ok(bytes))),
|
|
Poll::Ready(Some(Err(err))) => {
|
|
return Poll::Ready(Some(Err(io::Error::other(format!(
|
|
"segment body failed: {}",
|
|
err.without_url()
|
|
)))));
|
|
}
|
|
// Segment drained: move to the next one.
|
|
Poll::Ready(None) => this.schedule_next(),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SourceStream for HlsStream {
|
|
type Params = HlsParams;
|
|
type StreamCreationError = HlsError;
|
|
|
|
async fn create(params: Self::Params) -> Result<Self, Self::StreamCreationError> {
|
|
let segments = load_segments(¶ms.client, params.url).await?;
|
|
debug!(segments = segments.len(), "hls stream open");
|
|
if segments.is_empty() {
|
|
return Err(HlsError("hls playlist has no segments".to_string()));
|
|
}
|
|
Ok(Self {
|
|
client: params.client,
|
|
segments,
|
|
cursor: 0,
|
|
state: State::Idle,
|
|
})
|
|
}
|
|
|
|
/// Unknown up front (segment sizes are not in the playlist), so `None` —
|
|
/// which, with `supports_seek() == false`, keeps symphonia off the
|
|
/// end-seek path.
|
|
fn content_length(&self) -> Option<u64> {
|
|
None
|
|
}
|
|
|
|
/// Forward-only. In-track seeking (open at a segment offset) is a later,
|
|
/// additive change (architecture/soundcloud-provider.md D7).
|
|
async fn seek_range(&mut self, _start: u64, _end: Option<u64>) -> io::Result<()> {
|
|
Err(io::Error::new(
|
|
io::ErrorKind::Unsupported,
|
|
"HLS stream is forward-only",
|
|
))
|
|
}
|
|
|
|
/// Best-effort resume after a dropped connection: re-open the current
|
|
/// segment. `stream-download`'s temp storage already holds everything up to
|
|
/// `current_position`, and its `Read` side serves that; this only needs to
|
|
/// resume producing fresh bytes, so re-fetching the in-flight segment is
|
|
/// acceptable (a short overlap at worst).
|
|
async fn reconnect(&mut self, _current_position: u64) -> io::Result<()> {
|
|
let restart = self.cursor.saturating_sub(1);
|
|
warn!(
|
|
segment = restart,
|
|
"reconnecting hls stream at current segment"
|
|
);
|
|
self.cursor = restart;
|
|
self.state = State::Idle;
|
|
Ok(())
|
|
}
|
|
|
|
fn supports_seek(&self) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Fetches the playlist at `url`, following one level of master playlist, and
|
|
/// returns the ordered media-segment URLs.
|
|
async fn load_segments(client: &reqwest::Client, url: Url) -> Result<Vec<Url>, HlsError> {
|
|
let body = fetch_text(client, url.clone()).await?;
|
|
match parse_playlist(&body, &url)? {
|
|
Playlist::Media(segments) => Ok(segments),
|
|
Playlist::Master(variant) => {
|
|
let body = fetch_text(client, variant.clone()).await?;
|
|
match parse_playlist(&body, &variant)? {
|
|
Playlist::Media(segments) => Ok(segments),
|
|
// A master pointing at another master is not something
|
|
// SoundCloud produces; refuse rather than recurse.
|
|
Playlist::Master(_) => Err(HlsError("nested master playlist".to_string())),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn fetch_text(client: &reqwest::Client, url: Url) -> Result<String, HlsError> {
|
|
let resp = client
|
|
.get(url)
|
|
.send()
|
|
.await
|
|
.map_err(|e| HlsError(format!("playlist request failed: {}", e.without_url())))?;
|
|
if !resp.status().is_success() {
|
|
return Err(HlsError(format!("playlist rejected: {}", resp.status())));
|
|
}
|
|
resp.text()
|
|
.await
|
|
.map_err(|e| HlsError(format!("playlist read failed: {e}")))
|
|
}
|
|
|
|
/// Parses an m3u8 body. A master playlist (`#EXT-X-STREAM-INF`) yields the
|
|
/// first variant URI; otherwise every non-comment line is a media segment.
|
|
/// Relative URIs resolve against `base`.
|
|
fn parse_playlist(body: &str, base: &Url) -> Result<Playlist, HlsError> {
|
|
let is_master = body.contains("#EXT-X-STREAM-INF");
|
|
let mut uris = Vec::new();
|
|
for line in body.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
let resolved = base
|
|
.join(line)
|
|
.map_err(|e| HlsError(format!("bad segment URI: {e}")))?;
|
|
uris.push(resolved);
|
|
if is_master {
|
|
// Only the first variant is needed.
|
|
break;
|
|
}
|
|
}
|
|
if is_master {
|
|
uris.into_iter()
|
|
.next()
|
|
.map(Playlist::Master)
|
|
.ok_or_else(|| HlsError("master playlist has no variant".to_string()))
|
|
} else {
|
|
Ok(Playlist::Media(uris))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn base() -> Url {
|
|
Url::parse("https://cf-hls.sndcdn.com/media/0/playlist.m3u8?token=x").unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn parses_media_playlist_absolute_and_relative() {
|
|
let body = "#EXTM3U\n\
|
|
#EXT-X-VERSION:6\n\
|
|
#EXTINF:10.0,\n\
|
|
https://cdn.sndcdn.com/media/0/1.ts\n\
|
|
#EXTINF:9.9,\n\
|
|
2.ts\n\
|
|
#EXT-X-ENDLIST\n";
|
|
let Playlist::Media(segs) = parse_playlist(body, &base()).unwrap() else {
|
|
panic!("expected media playlist");
|
|
};
|
|
assert_eq!(segs.len(), 2);
|
|
assert_eq!(segs[0].as_str(), "https://cdn.sndcdn.com/media/0/1.ts");
|
|
// Relative URI resolved against the playlist URL.
|
|
assert_eq!(segs[1].as_str(), "https://cf-hls.sndcdn.com/media/0/2.ts");
|
|
}
|
|
|
|
#[test]
|
|
fn follows_first_master_variant() {
|
|
let body = "#EXTM3U\n\
|
|
#EXT-X-STREAM-INF:BANDWIDTH=128000\n\
|
|
variant-128.m3u8\n\
|
|
#EXT-X-STREAM-INF:BANDWIDTH=64000\n\
|
|
variant-64.m3u8\n";
|
|
let Playlist::Master(url) = parse_playlist(body, &base()).unwrap() else {
|
|
panic!("expected master playlist");
|
|
};
|
|
assert_eq!(
|
|
url.as_str(),
|
|
"https://cf-hls.sndcdn.com/media/0/variant-128.m3u8"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_comments_and_blank_lines() {
|
|
let body = "#EXTM3U\n\n#EXTINF:1,\nonly.ts\n\n";
|
|
let Playlist::Media(segs) = parse_playlist(body, &base()).unwrap() else {
|
|
panic!("expected media playlist");
|
|
};
|
|
assert_eq!(segs.len(), 1);
|
|
}
|
|
}
|