1use std::io::{Error, ErrorKind, Result};
15use std::time::Duration;
16
17const 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
29pub 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
74fn 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
97fn 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
105fn 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 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 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 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}