aede_core/playlist/
folders.rs

1//! Shared album/discography destinations for the CLI and explicit server publication.
2use super::Style;
3use crate::model::{Catalog, Id};
4use std::collections::BTreeMap;
5use std::path::PathBuf;
6fn in_scope(path: &str, scope: &[String]) -> bool {
7    scope.is_empty() || scope.iter().any(|root| crate::text::is_under(path, root))
8}
9fn lines(text: &str) -> usize {
10    text.lines()
11        .filter(|line| !line.is_empty() && !line.starts_with('#'))
12        .count()
13}
14/// One playlist per album folder, holding that album in its own order.
15pub fn albums(catalog: &Catalog, scope: &[String]) -> BTreeMap<PathBuf, Vec<Id>> {
16    let mut by_folder: BTreeMap<PathBuf, Vec<Id>> = BTreeMap::new();
17    for release in &catalog.releases {
18        if !in_scope(&release.folder, scope) {
19            continue;
20        }
21        // A box set laid out as `Album/Disc 1`, `Album/Disc 2` is *one*
22        // release living in the parent, so its playlist goes there and spans
23        // the discs — which is exactly the file somebody wants when the tracks
24        // are numbered 1..17 twice over.
25        let folder = PathBuf::from(&release.folder);
26        by_folder
27            .entry(folder)
28            .or_default()
29            .extend(&release.track_ids);
30    }
31    by_folder
32}
33
34/// Render one deduplicated relative-path playlist per physical folder.
35pub fn render_folders(
36    catalog: &Catalog,
37    folders: BTreeMap<PathBuf, Vec<Id>>,
38    style: Style,
39) -> Result<Vec<(PathBuf, String)>, String> {
40    let mut out = Vec::new();
41    for (folder, mut tracks) in folders {
42        let mut seen = std::collections::BTreeSet::new();
43        tracks.retain(|id| seen.insert(*id));
44        let text = super::try_render(catalog, &tracks, Some(&folder), style)?;
45        if lines(&text) == 0 {
46            continue;
47        }
48        out.push((folder.join(super::file_name(&folder)), text));
49    }
50    Ok(out)
51}
52
53/// One playlist per artist folder, holding every album of that artist in order.
54///
55/// The artist folder is not a thing the catalog holds — it is inferred as the
56/// folder every one of that artist's albums sits in. Where they do not share
57/// one, or share only a watched root, nothing is written: a library laid out
58/// flat would otherwise get one playlist per artist dumped in its root, which
59/// is not tidying but littering.
60pub fn discographies(catalog: &Catalog, scope: &[String]) -> BTreeMap<PathBuf, Vec<Id>> {
61    let mut by_artist: BTreeMap<Id, Vec<&crate::model::Release>> = BTreeMap::new();
62    for release in &catalog.releases {
63        if let Some(artist) = release.album_artist_id
64            && in_scope(&release.folder, scope)
65        {
66            by_artist.entry(artist).or_default().push(release);
67        }
68    }
69
70    let roots: Vec<&str> = catalog.roots.iter().map(String::as_str).collect();
71    let mut by_folder: BTreeMap<PathBuf, Vec<&crate::model::Release>> = BTreeMap::new();
72    for (_, releases) in by_artist {
73        let Some(folder) = crate::coverart::shared_artist_folder(&releases) else {
74            continue;
75        };
76        let name = folder.to_string_lossy().to_string();
77        // The root of a library is not an artist folder, and neither is a
78        // folder that *is* one of the album folders — that one already has its
79        // own playlist under the very same name.
80        if roots.contains(&name.as_str()) || releases.iter().any(|r| r.folder == name) {
81            continue;
82        }
83        by_folder.entry(folder).or_default().extend(releases);
84    }
85    by_folder
86        .into_iter()
87        .map(|(folder, mut releases)| {
88            // Chronological, which is the order a discography is read in; the
89            // title breaks a tie so that two records of the same year come out the
90            // same way on every machine.
91            releases.sort_by(|a, b| {
92                a.year
93                    .unwrap_or(0)
94                    .cmp(&b.year.unwrap_or(0))
95                    .then_with(|| a.title.cmp(&b.title))
96            });
97            let tracks: Vec<Id> = releases.iter().flat_map(|r| r.track_ids.clone()).collect();
98            (folder, tracks)
99        })
100        .collect()
101}
102
103#[cfg(test)]
104#[path = "folders_tests.rs"]
105mod tests;