1use std::collections::BTreeMap;
7
8use crate::model::{self, Catalog, EntityKind, Id};
9
10#[derive(Debug, Clone, PartialEq)]
12pub struct Bucket {
13 pub label: String,
15 pub count: usize,
17 pub bytes: u64,
20}
21
22#[derive(Debug, Clone, Default)]
24pub struct Stats {
25 pub files: usize,
27 pub tracks: usize,
29 pub releases: usize,
31 pub compilations: usize,
33 pub artists: usize,
35 pub album_artists: usize,
37 pub labels: usize,
39 pub genres: usize,
41 pub total_duration_ms: u64,
43 pub total_bytes: u64,
46 pub orphan_tracks: usize,
48
49 pub by_codec: Vec<Bucket>,
51 pub by_quality: Vec<Bucket>,
53 pub by_sample_rate: Vec<Bucket>,
55 pub by_decade: Vec<Bucket>,
57
58 pub cover_ratio: f64,
63 pub year_ratio: f64,
65 pub genre_ratio: f64,
67 pub mbid_ratio: f64,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum QualityTier {
74 HiRes,
76 Lossless,
78 LossyHigh,
80 LossyLow,
82 Unknown,
84}
85
86impl QualityTier {
87 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
99pub 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
122pub 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 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 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 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
259pub fn top_artists(catalog: &Catalog, limit: usize) -> Vec<(Id, usize)> {
268 top_by_role(catalog, limit, true)
269}
270
271pub 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
291pub 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
307pub 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 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;