aede_core/playlist/
selection.rs

1//! Resolve inert playlist entries against catalog identities, without entry I/O.
2use crate::model::{Catalog, Id};
3use std::collections::BTreeMap;
4use std::path::{Component, Path, PathBuf};
5
6fn normalized(path: &Path) -> Option<PathBuf> {
7    if !path.is_absolute() {
8        return None;
9    }
10    let mut result = PathBuf::new();
11    for part in path.components() {
12        match part {
13            Component::CurDir => {}
14            Component::ParentDir => {
15                if !result.pop() {
16                    return None;
17                }
18            }
19            other => result.push(other.as_os_str()),
20        }
21    }
22    Some(result)
23}
24
25/// Preserve order and repetitions, refusing the entire selection on any unknown,
26/// ambiguous or non-file entry. Relative paths use the discovered playlist parent.
27/// No path is opened, canonicalized or fetched; only current catalog files match.
28pub fn resolve(catalog: &Catalog, content: &str, parent: &Path) -> Result<Vec<Id>, String> {
29    if content.len() > 8 * 1024 * 1024 || content.contains('\0') || !parent.is_absolute() {
30        return Err("invalid or oversized M3U selection".into());
31    }
32    let mut paths: BTreeMap<PathBuf, Vec<Id>> = BTreeMap::new();
33    for track in &catalog.tracks {
34        if let Some(file) = catalog.file(track.file_id)
35            && let Some(path) = normalized(Path::new(&file.path))
36        {
37            paths.entry(path).or_default().push(track.id);
38        }
39    }
40    let mut tracks = Vec::new();
41    for (index, entry) in super::entry_lines(content) {
42        if tracks.len() >= 4096 {
43            return Err("M3U selection exceeds 4096 tracks".into());
44        }
45        // Native paths may contain colons, but URI-shaped entries never become files.
46        if entry.contains("://")
47            || entry.starts_with("file:")
48            || entry.chars().any(char::is_control)
49        {
50            return Err(format!("M3U line {} is not a catalogued file", index + 1));
51        }
52        let path = normalized(&parent.join(entry))
53            .ok_or_else(|| format!("invalid M3U path on line {}", index + 1))?;
54        match paths.get(&path).map(Vec::as_slice) {
55            Some([track]) => tracks.push(*track),
56            _ => {
57                return Err(format!(
58                    "M3U line {} has no unique catalogued track; synchronize the library",
59                    index + 1
60                ));
61            }
62        }
63    }
64    if tracks.is_empty() {
65        return Err("M3U selection is empty".into());
66    }
67    Ok(tracks)
68}
69
70#[cfg(test)]
71#[path = "selection_tests.rs"]
72mod tests;