aede_core/copy/
playlists.rs

1//! Destination playlists retain only selected audio and use its planned paths.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6use super::{Item, ItemKind, Plan};
7use crate::model::{Catalog, Id};
8
9impl Plan {
10    /// Rewrites selected M3U/M3U8 companions and adds a selection M3U8.
11    ///
12    /// Source playlists are read as UTF-8 and left untouched. Entries not in
13    /// this plan (including remote URLs) are omitted; the returned count makes
14    /// that narrowing visible. Paths refer to adapted names and converted
15    /// extensions, relative to each destination playlist. The generated
16    /// selection follows the caller's track order.
17    pub fn prepare_playlists(&mut self, catalog: &Catalog, tracks: &[Id]) -> Result<usize, String> {
18        let mapping: BTreeMap<String, PathBuf> = self
19            .items
20            .iter()
21            .filter(|item| item.kind == ItemKind::Audio)
22            .map(|item| {
23                (
24                    path_key(&item.source.to_string_lossy()),
25                    item.relative.clone(),
26                )
27            })
28            .collect();
29        let mut omitted = 0;
30        for item in &mut self.items {
31            if item.kind == ItemKind::Audio || !is_playlist(&item.source) {
32                continue;
33            }
34            let text = crate::playlist::read_text(&item.source)?;
35            let (rendered, removed) = remap(&text, Some(&item.source), &item.relative, &mapping)?;
36            omitted += removed;
37            item.size = rendered.len() as u64;
38            item.contents = Some(rendered);
39        }
40        let mut root_names: BTreeSet<String> = self
41            .items
42            .iter()
43            .filter_map(|item| item.relative.components().next())
44            .map(|component| super::names::portable_key(&component.as_os_str().to_string_lossy()))
45            .collect();
46        let relative = PathBuf::from(super::names::make_unique_portable(
47            "aede-selection.m3u8",
48            &mut root_names,
49        ));
50        let mut paths = BTreeMap::new();
51        for &id in tracks {
52            let Some(file) = catalog
53                .track(id)
54                .and_then(|track| catalog.file(track.file_id))
55            else {
56                continue;
57            };
58            let Some(path) = mapping.get(&path_key(&file.path)) else {
59                continue;
60            };
61            let path = relative_path(Path::new(""), path);
62            if path.contains(['\n', '\r']) {
63                return Err("selected output path cannot be represented in M3U".into());
64            }
65            paths.insert(id, path);
66        }
67        let contents = crate::playlist::render_with_paths(
68            catalog,
69            tracks,
70            crate::playlist::Style::Extended,
71            |id| paths.get(&id).cloned(),
72        );
73        self.items.push(Item {
74            source: PathBuf::from("selected tracks"),
75            relative,
76            size: contents.len() as u64,
77            kind: ItemKind::Other,
78            convert: None,
79            contents: Some(contents),
80        });
81        Ok(omitted)
82    }
83}
84
85fn is_playlist(path: &Path) -> bool {
86    path.extension().is_some_and(|extension| {
87        let extension = extension.to_string_lossy();
88        extension.eq_ignore_ascii_case("m3u") || extension.eq_ignore_ascii_case("m3u8")
89    })
90}
91
92fn remap(
93    text: &str,
94    source: Option<&Path>,
95    output: &Path,
96    mapping: &BTreeMap<String, PathBuf>,
97) -> Result<(String, usize), String> {
98    let mut rendered = String::new();
99    let mut pending = Vec::new();
100    let mut omitted = 0;
101    for line in text.strip_prefix('\u{feff}').unwrap_or(text).lines() {
102        let line = line.trim_end_matches('\r');
103        if line.is_empty() {
104            continue;
105        }
106        if line == "#EXTM3U" {
107            rendered.push_str("#EXTM3U\n");
108            continue;
109        }
110        if line.starts_with('#') {
111            pending.push(line);
112            continue;
113        }
114        // Match both M3U readers: a URI separator cannot become a native
115        // relative path, while a colon alone may belong to a name or drive.
116        if line.contains("://") {
117            pending.clear();
118            omitted += 1;
119            continue;
120        }
121        let key = match source {
122            Some(source) if !is_absolute(line) => path_key(&format!(
123                "{}/{}",
124                crate::text::folder(&source.to_string_lossy()),
125                line
126            )),
127            _ => path_key(line),
128        };
129        let Some(target) = mapping.get(&key) else {
130            pending.clear();
131            omitted += 1;
132            continue;
133        };
134        for comment in pending.drain(..) {
135            rendered.push_str(comment);
136            rendered.push('\n');
137        }
138        let path = relative_path(output.parent().unwrap_or_else(|| Path::new("")), target);
139        if path.contains(['\n', '\r']) {
140            return Err(format!(
141                "{}: output path cannot be represented in M3U",
142                target.display()
143            ));
144        }
145        crate::playlist::push_path(&mut rendered, &path);
146    }
147    Ok((rendered, omitted))
148}
149
150fn is_absolute(path: &str) -> bool {
151    path.starts_with('/')
152        || path.starts_with("\\\\")
153        || (path.as_bytes().get(1) == Some(&b':')
154            && path
155                .as_bytes()
156                .get(2)
157                .is_some_and(|separator| matches!(separator, b'/' | b'\\')))
158}
159
160/// Lexical normalization also supports Windows source playlists on Unix test
161/// hosts. Backslashes are separators only for a Windows absolute spelling.
162fn path_key(path: &str) -> String {
163    let windows = path.starts_with("\\\\")
164        || (path.as_bytes().get(1) == Some(&b':')
165            && path
166                .as_bytes()
167                .get(2)
168                .is_some_and(|separator| matches!(separator, b'/' | b'\\')));
169    let normalized = if windows {
170        path.replace('\\', "/")
171    } else {
172        path.to_string()
173    };
174    let mut parts = Vec::new();
175    for part in normalized.split('/') {
176        match part {
177            "" | "." => {}
178            ".." => {
179                parts.pop();
180            }
181            _ => parts.push(part),
182        }
183    }
184    let key = format!(
185        "{}{}",
186        if normalized.starts_with('/') { "/" } else { "" },
187        parts.join("/")
188    );
189    if windows { key.to_lowercase() } else { key }
190}
191
192fn relative_path(base: &Path, target: &Path) -> String {
193    let base: Vec<_> = base.components().collect();
194    let target: Vec<_> = target.components().collect();
195    let common = base
196        .iter()
197        .zip(&target)
198        .take_while(|(left, right)| left == right)
199        .count();
200    let mut out = vec!["..".to_string(); base.len() - common];
201    out.extend(
202        target[common..]
203            .iter()
204            .map(|component| component.as_os_str().to_string_lossy().into_owned()),
205    );
206    out.join("/")
207}
208
209#[cfg(test)]
210#[path = "playlists_tests.rs"]
211mod tests;