1use std::ops::Range;
2
3use crate::{
4 bail,
5 error::{Context as _, Error, Result},
6};
7use logos::Logos;
8
9#[derive(Logos, Debug, PartialEq)]
10#[logos(skip r"[ \t\n\f]+")]
11#[logos(error = String)]
12pub enum Token<'a> {
13 #[token("#EXTM3U")]
14 ExtM3U,
15 #[token("#EXT-X-ENDLIST")]
16 EndList,
17
18 #[token("#EXT-X-TARGETDURATION")]
19 TargetDuration,
20 #[token("#EXT-X-VERSION")]
21 Version,
22 #[token("#EXT-X-MEDIA-SEQUENCE")]
23 MediaSequence,
24 #[token("#EXT-X-KEY")]
25 Key,
26 #[token("#EXT-X-ALLOW-CACHE")]
27 AllowCache,
28 #[token("#EXT-X-PLAYLIST-TYPE")]
29 PlaylistType,
30 #[token("#EXT-X-I-FRAMES-ONLY")]
31 IFramesOnly,
32
33 #[token("#EXTINF")]
34 Inf,
35 #[token("#EXT-X-BYTERANGE")]
36 ByteRange,
37
38 #[token("METHOD")]
39 Method,
40 #[token("URI")]
41 Uri,
42
43 #[token("=")]
44 Equal,
45 #[token(",")]
46 Comma,
47 #[token(":")]
48 Colon,
49
50 #[regex(r"[0-9]+\.[0-9]+", |lex| lexical::parse(lex.slice()).ok())]
51 Float(f64),
52 #[regex(r"[0-9]+", |lex| lexical::parse(lex.slice()).ok())]
53 Integer(usize),
54 #[regex(r#""([^"]*)""#, |lex| lex.slice()[1..lex.slice().len() - 1].as_ref())]
55 String(&'a str),
56
57 #[regex(r"AES-128|SAMPLE-AES|NONE", |lex| match lex.slice() {
58 "AES-128" => Method::Aes128,
59 "SAMPLE-AES" => Method::SampleAes,
60 "NONE" => Method::None,
61 _ => unreachable!(),
62 })]
63 MethodValue(Method),
64 #[regex(r"YES|NO", |lex| match lex.slice() {
65 "YES" => true,
66 "NO" => false,
67 _ => unreachable!(),
68 })]
69 AllowCacheValue(bool),
70 #[regex(r"[0-9]+@[0-9]+", |lex| {
71 let mut parts = lex.slice().split('@');
72 let length: usize = lexical::parse(parts.next().unwrap()).unwrap();
73 let offset: usize = lexical::parse(parts.next().unwrap()).unwrap();
74 length..offset
75 })]
76 ByteRangeValue(Range<usize>),
77 #[regex(r"VOD|EVENT", |lex| match lex.slice() {
78 "VOD" => PlaylistType::Vod,
79 "EVENT" => PlaylistType::Event,
80 _ => unreachable!(),
81 })]
82 PlaylistTypeValue(PlaylistType),
83 #[regex(r"https?://[^ \t\n\f]+", |lex| lex.slice())]
84 UriValue(&'a str),
85}
86
87#[derive(Debug, PartialEq)]
88pub struct MediaPlaylist {
89 pub version: u8,
90 pub media_sequence: u32,
91 pub key: Option<Key>,
92 pub allow_cache: bool,
93 pub target_duration: u32,
94 pub playlist_type: PlaylistType,
95 pub iframes_only: bool,
96 pub segments: Vec<MediaSegment>,
97}
98
99#[derive(Debug, PartialEq)]
100pub struct Key {
101 pub method: Method,
102 pub uri: String,
103}
104
105#[derive(Debug, PartialEq)]
106pub enum Method {
107 Aes128,
108 SampleAes,
109 None,
110}
111
112#[derive(Debug, PartialEq)]
113pub enum PlaylistType {
114 Vod,
115 Event,
116}
117
118#[derive(Debug, PartialEq)]
119pub struct MediaSegment {
120 pub duration: f32,
121 pub byte_range: Option<Range<usize>>,
122 pub url: String,
123}
124
125pub fn parse(input: &str) -> Result<MediaPlaylist> {
126 let mut lexer = Token::lexer(input);
127 let mut playlist = MediaPlaylist {
128 version: 0,
129 media_sequence: 0,
130 key: None,
131 allow_cache: false,
132 target_duration: 0,
133 playlist_type: PlaylistType::Vod,
134 iframes_only: false,
135 segments: Vec::new(),
136 };
137
138 while let Some(token) = lexer.next() {
139 match token? {
140 Token::ExtM3U => (),
141 Token::Version => {
142 playlist.version = match lexer.nth(1).context("Invalid version")?? {
143 Token::Integer(version) => version as u8,
144 _ => bail!("Invalid version"),
145 };
146 }
147 Token::MediaSequence => {
148 playlist.media_sequence = match lexer.nth(1).context("Invalid media sequence")?? {
149 Token::Integer(sequence) => sequence as u32,
150 _ => bail!("Invalid media sequence"),
151 };
152 }
153 Token::Key => {
154 let method = match lexer.nth(3).context("Invalid method")?? {
155 Token::MethodValue(method) => method,
156 _ => bail!("Invalid method"),
157 };
158 let uri = match lexer.nth(3).context("Invalid URI")?? {
159 Token::String(uri) => uri.to_string(),
160 _ => bail!("Invalid key URL"),
161 };
162 playlist.key = Some(Key { method, uri });
163 }
164 Token::AllowCache => {
165 playlist.allow_cache = match lexer.nth(1).context("Invalid allow cache")?? {
166 Token::AllowCacheValue(allow_cache) => allow_cache,
167 _ => bail!("Invalid allow cache"),
168 };
169 }
170 Token::TargetDuration => {
171 playlist.target_duration =
172 match lexer.nth(1).context("Invalid target duration")?? {
173 Token::Integer(duration) => duration as u32,
174 _ => bail!("Invalid target duration"),
175 };
176 }
177 Token::PlaylistType => {
178 playlist.playlist_type = match lexer.nth(1).context("Invalid playlist type")?? {
179 Token::PlaylistTypeValue(playlist_type) => playlist_type,
180 _ => bail!("Invalid playlist type"),
181 };
182 }
183 Token::IFramesOnly => {
184 playlist.iframes_only = true;
185 }
186 Token::Inf => {
187 let duration = match lexer.nth(1).context("Invalid duration")?? {
188 Token::Float(duration) => duration as f32,
189 _ => bail!("Invalid duration"),
190 };
191
192 let byte_range = playlist
204 .iframes_only
205 .then(|| match lexer.nth(3) {
206 Some(Ok(Token::ByteRangeValue(range))) => Some(range),
207 _ => None,
208 })
209 .flatten();
210
211 let url_advance = if byte_range.is_some() { 0 } else { 1 };
212 let url = match lexer.nth(url_advance).context("Invalid URL")?? {
213 Token::UriValue(uri) => uri.to_string(),
214 _ => bail!("Invalid URL"),
215 };
216 playlist.segments.push(MediaSegment {
217 duration,
218 byte_range,
219 url,
220 });
221 }
222 Token::EndList => break,
223 _ => (),
224 }
225 }
226
227 Ok(playlist)
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn test_media_playlist() {
236 let input = r#"
237 #EXTM3U
238 #EXT-X-TARGETDURATION:17
239 #EXT-X-ALLOW-CACHE:YES
240 #EXT-X-PLAYLIST-TYPE:VOD
241 #EXT-X-KEY:METHOD=AES-128,URI="https://example.com/mon.key"
242 #EXT-X-VERSION:3
243 #EXT-X-MEDIA-SEQUENCE:1
244 #EXTINF:6.006,
245 https://example.com/segment-1.ts
246 #EXTINF:4.588,
247 https://example.com/segment-2.ts
248 #EXT-X-ENDLIST
249 "#;
250
251 let media_playlist = parse(input).unwrap();
252 assert_eq!(
253 media_playlist,
254 MediaPlaylist {
255 version: 3,
256 media_sequence: 1,
257 key: Some(Key {
258 method: Method::Aes128,
259 uri: "https://example.com/mon.key".to_string(),
260 }),
261 allow_cache: true,
262 target_duration: 17,
263 playlist_type: PlaylistType::Vod,
264 iframes_only: false,
265 segments: vec![
266 MediaSegment {
267 duration: 6.006,
268 byte_range: None,
269 url: "https://example.com/segment-1.ts".to_string(),
270 },
271 MediaSegment {
272 duration: 4.588,
273 byte_range: None,
274 url: "https://example.com/segment-2.ts".to_string(),
275 },
276 ],
277 }
278 )
279 }
280
281 #[allow(clippy::reversed_empty_ranges)]
282 #[test]
283 fn test_media_playlist_iframes() {
284 let input = r#"
285 #EXTM3U
286 #EXT-X-TARGETDURATION:3
287 #EXT-X-VERSION:4
288 #EXT-X-MEDIA-SEQUENCE:1
289 #EXT-X-PLAYLIST-TYPE:VOD
290 #EXT-X-I-FRAMES-ONLY
291 #EXTINF:1.120,
292 #EXT-X-BYTERANGE:1316@376
293 https://example.com/segment-1.ts
294 #EXTINF:6.720,
295 #EXT-X-BYTERANGE:44744@7896
296 https://example.com/segment-2.ts
297 #EXT-X-ENDLIST
298 "#;
299
300 let media_playlist = parse(input).unwrap();
301 assert_eq!(
302 media_playlist,
303 MediaPlaylist {
304 version: 4,
305 media_sequence: 1,
306 key: None,
307 allow_cache: false,
308 target_duration: 3,
309 playlist_type: PlaylistType::Vod,
310 iframes_only: true,
311 segments: vec![
312 MediaSegment {
313 duration: 1.12,
314 byte_range: Some(1316..376),
315 url: "https://example.com/segment-1.ts".to_string(),
316 },
317 MediaSegment {
318 duration: 6.72,
319 byte_range: Some(44744..7896),
320 url: "https://example.com/segment-2.ts".to_string(),
321 },
322 ],
323 }
324 )
325 }
326}