Skip to main content

utils/
hls_source.rs

1//! Minimal HLS assembler for SoundCloud Go+ AAC streams.
2//!
3//! SoundCloud's high-quality (256 kbps AAC) transcoding is delivered as an HLS
4//! media playlist of fragmented-MP4 segments rather than one progressive file.
5//! Symphonia has no HLS demuxer, but a CMAF/fMP4 HLS stream is just an optional
6//! init segment (`#EXT-X-MAP`) followed by media fragments — concatenating them
7//! in order yields a single valid fMP4 byte stream the `isomp4` reader decodes.
8//!
9//! This downloads the playlist and all its segments synchronously and returns
10//! the assembled bytes. It's blocking (built for the player's `spawn_blocking`
11//! decode thread, beside `range_source`) and trades a short up-front download
12//! for dead-simple, fully-seekable in-memory playback.
13
14use std::io::{Error, ErrorKind, Result};
15use std::time::Duration;
16
17/// Upper bound on the assembled stream size. A single track's AAC stream is a
18/// few tens of MiB; this 1 GiB cap guards against a malformed/malicious
19/// playlist driving unbounded in-memory growth.
20const MAX_TOTAL_BYTES: usize = 1024 * 1024 * 1024;
21
22fn client() -> reqwest::blocking::Client {
23    reqwest::blocking::Client::builder()
24        .timeout(Duration::from_secs(30))
25        .build()
26        .unwrap_or_default()
27}
28
29/// Fetch an HLS media-playlist URL and concatenate its init + media segments
30/// into one contiguous (fMP4) byte buffer.
31pub fn assemble(playlist_url: &str, user_agent: Option<&str>) -> Result<Vec<u8>> {
32    let http = client();
33
34    let mut playlist_req = http.get(playlist_url);
35    if let Some(ua) = user_agent {
36        playlist_req = playlist_req.header("User-Agent", ua);
37    }
38    let playlist = playlist_req
39        .send()
40        .and_then(|r| r.error_for_status())
41        .and_then(|r| r.text())
42        .map_err(|e| Error::other(format!("HLS playlist fetch: {e}")))?;
43
44    let segments = parse_segment_urls(&playlist, playlist_url);
45    if segments.is_empty() {
46        return Err(Error::new(
47            ErrorKind::InvalidData,
48            "HLS playlist had no segments",
49        ));
50    }
51
52    let mut out = Vec::new();
53    for url in segments {
54        let mut seg_req = http.get(&url);
55        if let Some(ua) = user_agent {
56            seg_req = seg_req.header("User-Agent", ua);
57        }
58        let bytes = seg_req
59            .send()
60            .and_then(|r| r.error_for_status())
61            .and_then(|r| r.bytes())
62            .map_err(|e| Error::other(format!("HLS segment fetch: {e}")))?;
63        if out.len().saturating_add(bytes.len()) > MAX_TOTAL_BYTES {
64            return Err(Error::new(
65                ErrorKind::InvalidData,
66                "HLS stream exceeds maximum allowed size",
67            ));
68        }
69        out.extend_from_slice(&bytes);
70    }
71    Ok(out)
72}
73
74/// Extract the ordered list of absolute segment URLs from a media playlist: the
75/// optional `#EXT-X-MAP` init segment first, then each media segment line.
76fn parse_segment_urls(playlist: &str, playlist_url: &str) -> Vec<String> {
77    let mut urls = Vec::new();
78    for raw in playlist.lines() {
79        let line = raw.trim();
80        if line.is_empty() {
81            continue;
82        }
83        if let Some(rest) = line.strip_prefix("#EXT-X-MAP:") {
84            if let Some(uri) = extract_attr_uri(rest) {
85                urls.push(resolve_url(playlist_url, &uri));
86            }
87            continue;
88        }
89        if line.starts_with('#') {
90            continue;
91        }
92        urls.push(resolve_url(playlist_url, line));
93    }
94    urls
95}
96
97/// Pull `URI="…"` out of an `#EXT-X-MAP` attribute list.
98fn extract_attr_uri(attrs: &str) -> Option<String> {
99    let start = attrs.find("URI=\"")? + 5;
100    let rest = &attrs[start..];
101    let end = rest.find('"')?;
102    Some(rest[..end].to_string())
103}
104
105/// Resolve a possibly-relative segment reference against the playlist URL.
106fn resolve_url(base: &str, reference: &str) -> String {
107    if reference.starts_with("http://") || reference.starts_with("https://") {
108        return reference.to_string();
109    }
110    if let Some(scheme_end) = base.find("://") {
111        let after_scheme = &base[scheme_end + 3..];
112        if let Some(rel) = reference.strip_prefix('/') {
113            if let Some(host_len) = after_scheme.find('/') {
114                let host = &after_scheme[..host_len];
115                return format!("{}://{host}/{rel}", &base[..scheme_end]);
116            }
117            return format!("{base}/{rel}");
118        }
119        let path_part = base.split('?').next().unwrap_or(base);
120        // Only treat a slash that lies after "scheme://host" as a path
121        // separator — rfind on the whole URL would otherwise match the slash
122        // in "https://" when the base has no path, mangling the result.
123        let after_scheme_start = scheme_end + 3;
124        if let Some(rel) = path_part
125            .get(after_scheme_start..)
126            .and_then(|host_and_path| host_and_path.rfind('/'))
127        {
128            let slash = after_scheme_start + rel;
129            return format!("{}/{reference}", &path_part[..slash]);
130        }
131        // Host only, no path component: append directly.
132        return format!("{path_part}/{reference}");
133    }
134    reference.to_string()
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn parses_map_and_segments_in_order() {
143        let playlist = "#EXTM3U\n\
144            #EXT-X-MAP:URI=\"https://cdn.sndcdn.com/init.mp4\"\n\
145            #EXTINF:6.0,\n\
146            https://cdn.sndcdn.com/seg0.mp4\n\
147            #EXTINF:6.0,\n\
148            https://cdn.sndcdn.com/seg1.mp4\n\
149            #EXT-X-ENDLIST\n";
150        let urls = parse_segment_urls(playlist, "https://api.soundcloud.com/media/x/playlist.m3u8");
151        assert_eq!(
152            urls,
153            vec![
154                "https://cdn.sndcdn.com/init.mp4",
155                "https://cdn.sndcdn.com/seg0.mp4",
156                "https://cdn.sndcdn.com/seg1.mp4",
157            ]
158        );
159    }
160
161    #[test]
162    fn resolves_relative_and_absolute_paths() {
163        let base = "https://cf.sndcdn.com/media/abc/def/playlist.m3u8?token=xyz";
164        assert_eq!(
165            resolve_url(base, "seg1.mp4"),
166            "https://cf.sndcdn.com/media/abc/def/seg1.mp4"
167        );
168        assert_eq!(
169            resolve_url(base, "/other/seg2.mp4"),
170            "https://cf.sndcdn.com/other/seg2.mp4"
171        );
172        assert_eq!(
173            resolve_url(base, "https://x.com/s.mp4"),
174            "https://x.com/s.mp4"
175        );
176        // Base with host only (no path) must not match the scheme's slash.
177        assert_eq!(
178            resolve_url("https://host.com", "seg.mp4"),
179            "https://host.com/seg.mp4"
180        );
181        assert_eq!(
182            resolve_url("https://host.com?token=z", "seg.mp4"),
183            "https://host.com/seg.mp4"
184        );
185    }
186}