aede_core/copy/
playlists.rs1use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6use super::{Item, ItemKind, Plan};
7use crate::model::{Catalog, Id};
8
9impl Plan {
10 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 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
160fn 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;