termusiclib/playlist/
mod.rs

1//! This is a very simple url extractor for different kinds of playlist formats: M3U, PLS, ASX, XSPF
2//!
3//! It is not optimized yet and does create a lot of strings on the way.
4
5mod asx;
6mod m3u;
7mod pls;
8mod xspf;
9
10use std::{
11    borrow::Cow,
12    fmt::Display,
13    path::{Path, PathBuf},
14    str::FromStr,
15};
16
17use anyhow::{anyhow, Context, Result};
18use reqwest::Url;
19
20use crate::utils;
21
22#[derive(Debug, PartialEq, Eq, Clone)]
23#[allow(clippy::module_name_repetitions)]
24pub enum PlaylistValue {
25    /// A Local path, specific to the current running system (unix / dos)
26    Path(PathBuf),
27    /// A URI / URL starting with a protocol
28    Url(Url),
29}
30
31impl From<PathBuf> for PlaylistValue {
32    fn from(value: PathBuf) -> Self {
33        Self::Path(value)
34    }
35}
36
37impl Display for PlaylistValue {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            PlaylistValue::Path(v) => v.display().fmt(f),
41            PlaylistValue::Url(v) => v.fmt(f),
42        }
43    }
44}
45
46impl PlaylistValue {
47    /// If the current value is a [`PlaylistValue::Url`] and has the `file://` protocol, convert it to a path
48    ///
49    /// # Errors
50    ///
51    /// If the url's scheme is `file://` but converting to a pathbuf fails, see [`reqwest::Url::to_file_path`]
52    pub fn file_url_to_path(&mut self) -> Result<()> {
53        let Self::Url(url) = self else {
54            // dont do anything if not a url
55            return Ok(());
56        };
57
58        if url.scheme() == "file" {
59            let as_path = url
60                .to_file_path()
61                .map_err(|()| anyhow!("Failed to convert URL to Path!"))
62                .with_context(|| url.to_string())?;
63            *self = Self::Path(as_path);
64        }
65
66        Ok(())
67    }
68
69    /// If the current value is a [`PlaylistValue::Path`] and not absolute, make it absolute via the provided `base`
70    ///
71    /// `base` is expected to be absolute!
72    pub fn absoluteize(&mut self, base: &Path) {
73        let Self::Path(path) = self else {
74            return;
75        };
76
77        // do nothing if path is already absolute
78        if path.is_absolute() {
79            return;
80        }
81
82        // only need to change the path if the return is owned
83        if let Cow::Owned(new_path) = utils::absolute_path_base(path, base) {
84            *path = new_path;
85        }
86    }
87
88    /// Try to parse the given string
89    pub fn try_from_str(line: &str) -> Result<Self> {
90        // maybe not the best check, but better than nothing
91        if line.contains("://") {
92            return Ok(Self::Url(Url::parse(line)?));
93        }
94
95        Ok(Self::Path(PathBuf::from_str(line)?))
96    }
97}
98
99/// Decode playlist content string. It checks for M3U, PLS, XSPF and ASX content in the string.
100///
101/// Returns the parsed entries from the playlist, in playlist order.
102///
103/// NOTE: currently there is a mix of url and other things in this list
104///
105/// # Example
106///
107/// ```rust
108/// let list = playlist_decoder::decode(r##"<?xml version="1.0" encoding="UTF-8"?>
109///    <playlist version="1" xmlns="http://xspf.org/ns/0/">
110///      <trackList>
111///        <track>
112///          <title>Nobody Move, Nobody Get Hurt</title>
113///          <creator>We Are Scientists</creator>
114///          <location>file:///mp3s/titel_1.mp3</location>
115///        </track>
116///        <track>
117///          <title>See The World</title>
118///          <creator>The Kooks</creator>
119///          <location>http://www.example.org/musik/world.ogg</location>
120///        </track>
121///      </trackList>
122///    </playlist>"##).unwrap();
123/// assert!(list.len() == 2, "Did not find 2 urls in example");
124/// for item in list {
125///     println!("{:?}", item);
126/// }
127/// ```
128pub fn decode(content: &str) -> Result<Vec<PlaylistValue>> {
129    let mut set: Vec<PlaylistValue> = vec![];
130    let content_small = content.to_lowercase();
131
132    if content_small.contains("<playlist") {
133        let items = xspf::decode(content)?;
134        set.reserve(items.len());
135        for item in items {
136            set.push(item.location);
137        }
138    } else if content_small.contains("<asx") {
139        let items = asx::decode(content)?;
140        set.reserve(items.len());
141        for item in items {
142            set.push(item.location);
143        }
144    } else if content_small.contains("[playlist]") {
145        let items = pls::decode(content);
146        set.reserve(items.len());
147        for item in items {
148            set.push(item.url);
149        }
150    } else {
151        let items = m3u::decode(content);
152        set.reserve(items.len());
153        for item in items {
154            set.push(item.url);
155        }
156    }
157
158    Ok(set)
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use pretty_assertions::assert_eq;
165
166    #[test]
167    fn should_parse_xspf() {
168        let s = r#"<?xml version="1.0" encoding="UTF-8"?>
169        <playlist version="1" xmlns="http://xspf.org/ns/0/">
170            <trackList>
171            <track>
172                <title>Title</title>
173                <identifier>Identifier</identifier>
174                <location>http://this.is.an.example</location>
175            </track>
176            </trackList>
177        </playlist>"#;
178        let items = decode(s).unwrap();
179        assert_eq!(items.len(), 1);
180        assert_eq!(
181            items[0],
182            PlaylistValue::Url(Url::parse("http://this.is.an.example").unwrap())
183        );
184    }
185
186    #[test]
187    fn should_parse_asx() {
188        let s = r#"<asx version="3.0">
189  <title>Test-Liste</title>
190  <entry>
191    <title>title1</title>
192    <ref href="ref1"/>
193  </entry>
194</asx>"#;
195        let items = decode(s).unwrap();
196        assert_eq!(items.len(), 1);
197        assert_eq!(items[0], PlaylistValue::Path("ref1".into()));
198    }
199
200    #[test]
201    fn should_parse_pls() {
202        let items = decode(
203            "[playlist]
204File1=http://this.is.an.example
205Title1=mytitle
206        ",
207        )
208        .unwrap();
209        assert_eq!(items.len(), 1);
210        assert_eq!(
211            items[0],
212            PlaylistValue::Url(Url::parse("http://this.is.an.example").unwrap())
213        );
214    }
215
216    #[test]
217    fn should_parse_m3u() {
218        let playlist = "/some/absolute/unix/path.mp3";
219
220        let results = decode(playlist).unwrap();
221        assert_eq!(results.len(), 1);
222        assert_eq!(
223            results[0],
224            PlaylistValue::Path("/some/absolute/unix/path.mp3".into())
225        );
226    }
227
228    mod playlist_value {
229        use std::path::Path;
230
231        use reqwest::Url;
232
233        use super::super::PlaylistValue;
234
235        // different test for unix and windows as paths dont match
236        #[test]
237        #[cfg(target_family = "unix")]
238        fn file_url_to_path() {
239            let mut value = PlaylistValue::Url(Url::parse("file:///mnt/somewhere").unwrap());
240            value.file_url_to_path().unwrap();
241            assert_eq!(value, PlaylistValue::Path("/mnt/somewhere".into()));
242        }
243
244        #[test]
245        #[cfg(target_family = "windows")]
246        fn file_url_to_path() {
247            let mut value = PlaylistValue::Url(Url::parse("file://C:\\somewhere").unwrap());
248            value.file_url_to_path().unwrap();
249            assert_eq!(value, PlaylistValue::Path("C:\\somewhere".into()));
250        }
251
252        #[test]
253        fn file_url_to_path_path_noop() {
254            let mut value = PlaylistValue::Path("/mnt/somewhere".into());
255            value.file_url_to_path().unwrap();
256            assert_eq!(value, PlaylistValue::Path("/mnt/somewhere".into()));
257        }
258
259        #[test]
260        fn file_url_to_path_not_file() {
261            let url = Url::parse("http://google.com").unwrap();
262            let mut value = PlaylistValue::Url(url.clone());
263            value.file_url_to_path().unwrap();
264            assert_eq!(value, PlaylistValue::Url(url));
265        }
266
267        #[test]
268        fn absoluteize() {
269            let mut value = PlaylistValue::Path("somewhere".into());
270            value.absoluteize(Path::new("/tmp"));
271            assert_eq!(value, PlaylistValue::Path("/tmp/somewhere".into()));
272        }
273
274        #[test]
275        fn absoluteize_absolute() {
276            let mut value = PlaylistValue::Path("/mnt/somewhere".into());
277            value.absoluteize(Path::new("/tmp"));
278            assert_eq!(value, PlaylistValue::Path("/mnt/somewhere".into()));
279        }
280
281        #[test]
282        fn absoluteize_not_path() {
283            let url = Url::parse("file:///mnt/somewhere").unwrap();
284            let mut value = PlaylistValue::Url(url.clone());
285            value.absoluteize(Path::new("/tmp"));
286            assert_eq!(value, PlaylistValue::Url(url));
287        }
288
289        #[test]
290        fn from_line() {
291            let line = "somewhere";
292            assert_eq!(
293                PlaylistValue::try_from_str(line).unwrap(),
294                PlaylistValue::Path("somewhere".into())
295            );
296
297            let line = "/mnt/somewhere";
298            assert_eq!(
299                PlaylistValue::try_from_str(line).unwrap(),
300                PlaylistValue::Path("/mnt/somewhere".into())
301            );
302
303            let line = "";
304            assert_eq!(
305                PlaylistValue::try_from_str(line).unwrap(),
306                PlaylistValue::Path("".into())
307            );
308
309            let line = "file:///mnt/somewhere";
310            assert_eq!(
311                PlaylistValue::try_from_str(line).unwrap(),
312                PlaylistValue::Url(Url::parse("file:///mnt/somewhere").unwrap())
313            );
314
315            let line = "https://google.com";
316            assert_eq!(
317                PlaylistValue::try_from_str(line).unwrap(),
318                PlaylistValue::Url(Url::parse("https://google.com").unwrap())
319            );
320        }
321    }
322}