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;