aede_core/playlist.rs
1//! M3U playlists: the one place that knows how to write one.
2//!
3//! Two callers with two purposes. `--m3u` hands whatever is on screen to a
4//! player, so it writes **absolute** paths to a file that may end up anywhere.
5//! `aede playlist` writes into the album folder itself, so it writes
6//! **relative** ones: a playlist beside its music travels with it, survives the
7//! folder being moved or copied to a card, and is the same file on another
8//! machine. One renderer for both, because two would agree about `#EXTINF`
9//! today and disagree about it in six months.
10
11/// Bounded, read-only discovery of playlist files under watched roots.
12pub mod files;
13/// Shared catalog-derived folder destinations and rendering.
14pub mod folders;
15/// Explicit catalog-only resolution of existing M3U entries.
16pub mod selection;
17
18use std::path::Path;
19
20use crate::model::{Catalog, EntityKind, Id};
21
22/// Read local playlist text, refusing input larger than 16 MiB.
23///
24/// Shared by direct CLI playback and companion remapping during copying.
25/// Static special files are refused before opening; the same regular descriptor
26/// is size-checked and capped while reading in case the file grows. UTF-8 bytes,
27/// the initial BOM and line endings are preserved. Entries are never opened here.
28pub fn read_text(path: &Path) -> Result<String, String> {
29 use std::io::Read;
30 const MAX_BYTES: u64 = 16 * 1024 * 1024;
31 let file = crate::tags::bytes::open_regular(path)
32 .map_err(|error| format!("{}: {error}", path.display()))?;
33 if file.metadata().map_err(|error| error.to_string())?.len() > MAX_BYTES {
34 return Err(format!(
35 "{}: playlist exceeds the 16 MiB input limit",
36 path.display()
37 ));
38 }
39 let mut contents = String::new();
40 file.take(MAX_BYTES + 1)
41 .read_to_string(&mut contents)
42 .map_err(|error| format!("{}: cannot read playlist as UTF-8: {error}", path.display()))?;
43 if contents.len() as u64 > MAX_BYTES {
44 return Err(format!(
45 "{}: playlist exceeds the 16 MiB input limit",
46 path.display()
47 ));
48 }
49 Ok(contents)
50}
51
52/// M3U entries with their zero-based source line numbers, without trimming paths.
53///
54/// Skip one initial encoding BOM, blank lines and first-column comments.
55/// Any following BOM character belongs to the first entry's filename.
56/// Leading whitespace can name a different file, including one beginning with
57/// whitespace followed by `#`; readers must not reinterpret it as formatting.
58/// This only separates lines: callers retain their path and URL admission rules.
59pub fn entry_lines(content: &str) -> impl Iterator<Item = (usize, &str)> {
60 content
61 .strip_prefix('\u{feff}')
62 .unwrap_or(content)
63 .lines()
64 .enumerate()
65 .filter(|(_, line)| !line.trim().is_empty() && !line.starts_with('#'))
66}
67
68/// Which of the two M3U dialects to write.
69#[derive(Clone, Copy, PartialEq)]
70pub enum Style {
71 /// `#EXTM3U` with an `#EXTINF` line per track: duration and title, which
72 /// is what lets a player show a name rather than a file name.
73 Extended,
74 /// Paths and nothing else. Older hardware players — car head units, some
75 /// DAPs — stop at the first `#` they do not understand.
76 Simple,
77}
78
79/// Renders a playlist.
80///
81/// `base` is the folder the file will be written into: paths under it are
82/// written relative to it, anything else absolute. A playlist that silently
83/// dropped the tracks it could not make relative would be worse than one that
84/// names them the long way.
85///
86/// Paths containing CR or LF cannot occupy one M3U line and are omitted. Use
87/// [`try_render`] to refuse such a selection instead of producing a partial list.
88pub fn render(catalog: &Catalog, tracks: &[Id], base: Option<&Path>, style: Style) -> String {
89 render_with_paths(catalog, tracks, style, |id| {
90 catalog
91 .track(id)
92 .and_then(|track| catalog.file(track.file_id))
93 .map(|file| relative_to(&file.path, base))
94 })
95}
96
97/// Renders a complete selection, refusing paths M3U cannot represent.
98///
99/// A carriage return or newline in a file name could otherwise inject another
100/// entry or a directive. The CLI uses this before publishing any playlist.
101pub fn try_render(
102 catalog: &Catalog,
103 tracks: &[Id],
104 base: Option<&Path>,
105 style: Style,
106) -> Result<String, String> {
107 for &id in tracks {
108 if let Some(file) = catalog
109 .track(id)
110 .and_then(|track| catalog.file(track.file_id))
111 && relative_to(&file.path, base).contains(['\r', '\n'])
112 {
113 return Err(format!(
114 "{:?}: M3U cannot represent a path containing a line break",
115 file.path
116 ));
117 }
118 }
119 Ok(render(catalog, tracks, base, style))
120}
121
122/// Renders the same metadata with caller-supplied paths. Missing mappings are
123/// omitted before their EXTINF is written, as are paths containing line breaks;
124/// metadata line breaks become spaces.
125pub(crate) fn render_with_paths(
126 catalog: &Catalog,
127 tracks: &[Id],
128 style: Style,
129 path: impl Fn(Id) -> Option<String>,
130) -> String {
131 let mut out = String::new();
132 if style == Style::Extended {
133 out.push_str("#EXTM3U\n");
134 }
135 for &id in tracks {
136 let Some(track) = catalog.track(id) else {
137 continue;
138 };
139 let Some(path) = path(id) else {
140 continue;
141 };
142 if path.contains(['\r', '\n']) {
143 continue;
144 }
145 if style == Style::Extended {
146 let artist = catalog
147 .credits_on(EntityKind::Track, id)
148 .into_iter()
149 .find(|(_, role)| *role == "main")
150 .map(|(a, _)| a.name.clone())
151 .unwrap_or_default();
152 // Seconds, rounded, and -1 when unknown: that is what the format
153 // says, and a player reading 0 would show a track of no length.
154 let seconds = match track.duration_ms {
155 Some(ms) => (ms / 1000 + u64::from(ms % 1000 >= 500)) as i64,
156 None => -1,
157 };
158 let title = track.title.replace(['\r', '\n'], " ");
159 let artist = artist.replace(['\r', '\n'], " ");
160 match artist.is_empty() {
161 true => out.push_str(&format!("#EXTINF:{seconds},{title}\n")),
162 false => out.push_str(&format!("#EXTINF:{seconds},{artist} - {title}\n")),
163 }
164 }
165 push_path(&mut out, &path);
166 }
167 out
168}
169
170/// Writes a path without exposing its first character to M3U line formatting.
171pub(crate) fn push_path(out: &mut String, path: &str) {
172 // An explicit relative prefix preserves literal initial characters that
173 // readers otherwise trim or interpret as a BOM, comment or file URI.
174 if path.starts_with('#')
175 || path.starts_with('\u{feff}')
176 || path.starts_with(char::is_whitespace)
177 || path.starts_with("file:")
178 {
179 out.push_str("./");
180 }
181 out.push_str(path);
182 out.push('\n');
183}
184
185/// The path as the playlist should carry it.
186fn relative_to(path: &str, base: Option<&Path>) -> String {
187 let Some(base) = base.and_then(|b| b.to_str()) else {
188 return path.to_string();
189 };
190 crate::text::relative_under(path, base)
191 .filter(|relative| !relative.is_empty())
192 .unwrap_or_else(|| path.to_string())
193}
194
195/// The name of the playlist a folder should hold: the folder's own name.
196///
197/// Not the album title, which is tempting and wrong twice over: two folders can
198/// hold the same title (a rip and a remaster), and a title carries `/` and `:`
199/// on records that were named by people rather than by filesystems. The folder
200/// name is unique where the file goes, is already legal there, and is what the
201/// user recognises in a player's list.
202pub fn file_name(folder: &Path) -> String {
203 let stem = folder
204 .file_name()
205 .and_then(|n| n.to_str())
206 .unwrap_or("playlist");
207 format!("{stem}.m3u")
208}
209
210/// `true` when the file already holds exactly this, byte for byte.
211///
212/// Content rather than a modification date, because a playlist is derived from
213/// the *set* of tracks and not from their bytes: adding a track to an album
214/// changes what the playlist should say without touching any file the playlist
215/// already names. Comparing the text answers both questions at once, and
216/// leaves the file's date alone when nothing changed — which matters to
217/// whatever syncs the folder afterwards.
218///
219/// Only regular destination files are compared, using a fixed-size buffer.
220/// Existing links/special files are not accepted as current output. As with
221/// the replacement writer, this is not a sandbox against concurrent path changes.
222pub fn already_says(path: &Path, content: &str) -> bool {
223 use std::io::Read;
224 let Ok(metadata) = std::fs::symlink_metadata(path) else {
225 return false;
226 };
227 if !metadata.is_file()
228 || metadata.file_type().is_symlink()
229 || metadata.len() != content.len() as u64
230 {
231 return false;
232 }
233 let Ok(mut file) = crate::tags::bytes::open_regular(path) else {
234 return false;
235 };
236 let mut buffer = [0; 8192];
237 for expected in content.as_bytes().chunks(buffer.len()) {
238 let held = &mut buffer[..expected.len()];
239 if file.read_exact(held).is_err() || held[..] != *expected {
240 return false;
241 }
242 }
243 file.read(&mut buffer[..1]).is_ok_and(|read| read == 0)
244}
245
246/// Replaces a playlist only after its complete text has been written.
247///
248/// Shares the exclusive temporary-output policy used by copying. A symbolic
249/// link or non-regular final path is refused; failed publication leaves the
250/// previous playlist intact and removes unfinished temporary output.
251pub fn write_atomic(path: &Path, content: &str) -> Result<(), String> {
252 let output = crate::copy::TemporaryOutput::new(path)?;
253 std::fs::write(output.path(), content)
254 .map_err(|error| format!("{}: {error}", path.display()))?;
255 output.publish()
256}
257
258#[cfg(test)]
259#[path = "playlist_tests.rs"]
260mod tests;