aede_core/
stats.rs

1//! Statistics on the catalog.
2//!
3//! Everything is computed on demand from the in-memory catalog: no aggregate
4//! value is stored, so none can drift out of sync.
5
6use std::collections::BTreeMap;
7
8use crate::model::{self, Catalog, EntityKind, Id};
9
10/// Named breakdown entry: `(label, count, size in bytes)`.
11#[derive(Debug, Clone, PartialEq)]
12pub struct Bucket {
13    /// Name of the slice as it is shown, already formatted (`FLAC`, `44.1 kHz`, `1970`).
14    pub label: String,
15    /// How many items fall into this slice.
16    pub count: usize,
17    /// Disk space these items occupy, saturating at `u64::MAX` if their sum exceeds it.
18    /// A breakdown can be read by weight and not only by number.
19    pub bytes: u64,
20}
21
22/// Snapshot of what the catalog contains, recomputed on each call.
23#[derive(Debug, Clone, Default)]
24pub struct Stats {
25    /// Audio files seen on disk, whether or not they could be attached to anything.
26    pub files: usize,
27    /// Local track placements held by the catalog, one per file.
28    pub tracks: usize,
29    /// Albums, EPs and singles reconstructed from the tags.
30    pub releases: usize,
31    /// Releases gathering several artists rather than one, and so excluded from a discography.
32    pub compilations: usize,
33    /// Distinct artists, all roles taken together.
34    pub artists: usize,
35    /// Artists carrying at least one album.
36    pub album_artists: usize,
37    /// Distinct record labels credited across the releases.
38    pub labels: usize,
39    /// Distinct genres in use, after the tags have been normalized.
40    pub genres: usize,
41    /// Cumulative playing time of the library, in milliseconds, saturating at `u64::MAX`.
42    pub total_duration_ms: u64,
43    /// Space the audio files take up on disk, in bytes, saturating at `u64::MAX`.
44    /// Cover art is not counted.
45    pub total_bytes: u64,
46    /// Tracks with no identified album.
47    pub orphan_tracks: usize,
48
49    /// Files grouped by container format, the untagged ones landing under `unknown`.
50    pub by_codec: Vec<Bucket>,
51    /// Files grouped by perceived quality, following [`QualityTier`].
52    pub by_quality: Vec<Bucket>,
53    /// Files grouped by sampling frequency; files that declare none are left out.
54    pub by_sample_rate: Vec<Bucket>,
55    /// Releases grouped by decade of publication, undated ones gathered under a separate label.
56    pub by_decade: Vec<Bucket>,
57
58    /// Share of releases having cover art (embedded or in the folder).
59    ///
60    /// This field and the three that follow are proportions between 0 and 1, not percentages;
61    /// the display layer decides how to present them.
62    pub cover_ratio: f64,
63    /// Share of tracks carrying a year.
64    pub year_ratio: f64,
65    /// Share of tracks carrying at least one genre.
66    pub genre_ratio: f64,
67    /// Share of tracks already identified by a MusicBrainz MBID.
68    pub mbid_ratio: f64,
69}
70
71/// Quality class, as a listener perceives it.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum QualityTier {
74    /// Lossless beyond CD: more than 48 kHz or more than 16 bits.
75    HiRes,
76    /// Lossless, CD quality or below.
77    Lossless,
78    /// Lossy, 256 kbps or more.
79    LossyHigh,
80    /// Lossy, below 256 kbps.
81    LossyLow,
82    /// Nothing reliable to go on: unreadable file, or a bitrate the decoder never reported.
83    Unknown,
84}
85
86impl QualityTier {
87    /// Wording of the tier for a listing or a breakdown, in a form the reader can act on.
88    pub fn label(self) -> &'static str {
89        match self {
90            QualityTier::HiRes => "Hi-res",
91            QualityTier::Lossless => "Lossless (CD)",
92            QualityTier::LossyHigh => "Lossy (>= 256 kbps)",
93            QualityTier::LossyLow => "Lossy (< 256 kbps)",
94            QualityTier::Unknown => "Unknown",
95        }
96    }
97}
98
99/// Ranks a file from what its audio properties actually establish.
100///
101/// A file that names no codec, or a lossy one that declares no bitrate, stays `Unknown`: better
102/// an admitted gap than a tier guessed from the extension alone.
103pub fn quality_tier(props: &crate::tags::AudioProperties) -> QualityTier {
104    if props.codec.is_empty() {
105        return QualityTier::Unknown;
106    }
107    if props.lossless {
108        if props.is_hi_res() {
109            QualityTier::HiRes
110        } else {
111            QualityTier::Lossless
112        }
113    } else {
114        match props.bitrate_kbps {
115            Some(kbps) if kbps >= 256 => QualityTier::LossyHigh,
116            Some(_) => QualityTier::LossyLow,
117            None => QualityTier::Unknown,
118        }
119    }
120}
121
122/// Runs through the whole catalog and produces the counts, breakdowns and completeness ratios.
123///
124/// The cost grows with the size of the library; call it when the catalog has settled rather than
125/// after each change.
126pub fn compute(catalog: &Catalog) -> Stats {
127    let mut stats = Stats {
128        files: catalog.files.len(),
129        tracks: catalog.tracks.len(),
130        releases: catalog.releases.len(),
131        compilations: catalog.releases.iter().filter(|r| r.is_compilation).count(),
132        artists: catalog.artists.len(),
133        labels: catalog.labels.len(),
134        genres: catalog.genres.len(),
135        total_duration_ms: catalog.total_duration_ms(),
136        total_bytes: catalog.total_size(),
137        orphan_tracks: catalog
138            .tracks
139            .iter()
140            .filter(|t| t.release_id.is_none())
141            .count(),
142        ..Default::default()
143    };
144
145    stats.album_artists = catalog
146        .releases
147        .iter()
148        .filter_map(|r| r.album_artist_id)
149        .collect::<std::collections::BTreeSet<_>>()
150        .len();
151
152    // --- Per-file breakdowns ----------------------------------------------
153    let mut codecs: BTreeMap<String, (usize, u64)> = BTreeMap::new();
154    let mut qualities: BTreeMap<&'static str, (usize, u64)> = BTreeMap::new();
155    let mut rates: BTreeMap<u32, (usize, u64)> = BTreeMap::new();
156
157    for file in &catalog.files {
158        let codec = if file.properties.codec.is_empty() {
159            "unknown".to_string()
160        } else {
161            file.properties.codec.to_uppercase()
162        };
163        let entry = codecs.entry(codec).or_default();
164        entry.0 += 1;
165        entry.1 = entry.1.saturating_add(file.size);
166
167        let tier = quality_tier(&file.properties).label();
168        let entry = qualities.entry(tier).or_default();
169        entry.0 += 1;
170        entry.1 = entry.1.saturating_add(file.size);
171
172        if let Some(rate) = file.properties.sample_rate {
173            let entry = rates.entry(rate).or_default();
174            entry.0 += 1;
175            entry.1 = entry.1.saturating_add(file.size);
176        }
177    }
178
179    stats.by_codec = sorted_buckets(codecs.into_iter());
180    stats.by_quality = sorted_buckets(qualities.into_iter().map(|(k, v)| (k.to_string(), v)));
181    stats.by_sample_rate = rates
182        .into_iter()
183        .map(|(rate, (count, bytes))| Bucket {
184            label: format_rate(rate),
185            count,
186            bytes,
187        })
188        .collect();
189
190    // --- Breakdown by decade ----------------------------------------------
191    let mut decades: BTreeMap<String, (usize, u64)> = BTreeMap::new();
192    for release in &catalog.releases {
193        let label = match release.year {
194            Some(year) => format!("{}", (year / 10) * 10),
195            None => "No year".to_string(),
196        };
197        let bytes: u64 = release
198            .track_ids
199            .iter()
200            .filter_map(|&id| catalog.track(id))
201            .filter_map(|t| catalog.file(t.file_id))
202            .map(|f| f.size)
203            .fold(0, u64::saturating_add);
204        let entry = decades.entry(label).or_default();
205        entry.0 += 1;
206        entry.1 = entry.1.saturating_add(bytes);
207    }
208    stats.by_decade = decades
209        .into_iter()
210        .map(|(label, (count, bytes))| Bucket {
211            label,
212            count,
213            bytes,
214        })
215        .collect();
216
217    // --- Completeness rates -----------------------------------------------
218    let with_cover = catalog
219        .releases
220        .iter()
221        .filter(|r| {
222            r.cover_path.is_some()
223                || r.track_ids
224                    .iter()
225                    .filter_map(|&id| catalog.track(id))
226                    .filter_map(|t| catalog.file(t.file_id))
227                    .any(|f| f.has_embedded_art)
228        })
229        .count();
230    stats.cover_ratio = ratio(with_cover, catalog.releases.len());
231
232    let with_year = catalog
233        .tracks
234        .iter()
235        .filter(|t| {
236            t.release_id
237                .and_then(|id| catalog.release(id))
238                .map(|r| r.year.is_some())
239                .unwrap_or(false)
240        })
241        .count();
242    stats.year_ratio = ratio(with_year, catalog.tracks.len());
243
244    let with_genre = catalog
245        .genre_links
246        .iter()
247        .filter(|g| g.entity_kind == EntityKind::Track)
248        .map(|g| g.entity_id)
249        .collect::<std::collections::BTreeSet<Id>>()
250        .len();
251    stats.genre_ratio = ratio(with_genre, catalog.tracks.len());
252
253    let with_mbid = catalog.tracks.iter().filter(|t| t.mbid.is_some()).count();
254    stats.mbid_ratio = ratio(with_mbid, catalog.tracks.len());
255
256    stats
257}
258
259/// The most present artists, by number of distinct tracks they are audible on.
260///
261/// Writing and production credits are excluded on purpose: a songwriter who
262/// never performs would otherwise outrank the singers of their own catalog.
263/// Those credits remain visible on the artist page and in [`top_writers`].
264///
265/// We count tracks, not credits: an artist who is both performer and composer
266/// of a piece must not count it twice.
267pub fn top_artists(catalog: &Catalog, limit: usize) -> Vec<(Id, usize)> {
268    top_by_role(catalog, limit, true)
269}
270
271/// The most credited writers and producers, by number of distinct tracks.
272pub fn top_writers(catalog: &Catalog, limit: usize) -> Vec<(Id, usize)> {
273    top_by_role(catalog, limit, false)
274}
275
276fn top_by_role(catalog: &Catalog, limit: usize, performing: bool) -> Vec<(Id, usize)> {
277    let mut sets: BTreeMap<Id, std::collections::BTreeSet<Id>> = BTreeMap::new();
278    for credit in &catalog.credits {
279        if credit.entity_kind == EntityKind::Track
280            && model::is_performing_role(&credit.role) == performing
281        {
282            sets.entry(credit.artist_id)
283                .or_default()
284                .insert(credit.entity_id);
285        }
286    }
287    let counts = sets.into_iter().map(|(id, set)| (id, set.len())).collect();
288    top(counts, limit)
289}
290
291/// The most represented genres, by number of tracks.
292pub fn top_genres(catalog: &Catalog, limit: usize) -> Vec<(Id, usize)> {
293    let mut sets: BTreeMap<Id, std::collections::BTreeSet<Id>> = BTreeMap::new();
294    for link in &catalog.genre_links {
295        if link.entity_kind == EntityKind::Track {
296            sets.entry(link.genre_id)
297                .or_default()
298                .insert(link.entity_id);
299        }
300    }
301    top(
302        sets.into_iter().map(|(id, set)| (id, set.len())).collect(),
303        limit,
304    )
305}
306
307/// The most present labels, by number of releases.
308pub fn top_labels(catalog: &Catalog, limit: usize) -> Vec<(Id, usize)> {
309    let mut counts: BTreeMap<Id, usize> = BTreeMap::new();
310    for release in &catalog.releases {
311        for &label_id in &release.label_ids {
312            *counts.entry(label_id).or_insert(0) += 1;
313        }
314    }
315    top(counts, limit)
316}
317
318fn top(counts: BTreeMap<Id, usize>, limit: usize) -> Vec<(Id, usize)> {
319    let mut list: Vec<(Id, usize)> = counts.into_iter().collect();
320    // On a tie, the smallest identifier comes first: stable result.
321    list.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
322    list.truncate(limit);
323    list
324}
325
326fn sorted_buckets(items: impl Iterator<Item = (String, (usize, u64))>) -> Vec<Bucket> {
327    let mut list: Vec<Bucket> = items
328        .map(|(label, (count, bytes))| Bucket {
329            label,
330            count,
331            bytes,
332        })
333        .collect();
334    list.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.label.cmp(&b.label)));
335    list
336}
337
338fn ratio(part: usize, total: usize) -> f64 {
339    if total == 0 {
340        0.0
341    } else {
342        part as f64 / total as f64
343    }
344}
345
346fn format_rate(rate: u32) -> String {
347    let khz = rate as f64 / 1000.0;
348    if khz.fract() == 0.0 {
349        format!("{} kHz", khz as u32)
350    } else {
351        format!("{khz:.1} kHz")
352    }
353}
354
355#[cfg(test)]
356#[path = "stats_tests.rs"]
357mod tests;