aede_core/model/
query.rs

1//! Reading the graph.
2//!
3//! Every question a command asks of the catalog lands here, and nothing in this
4//! file changes anything: the whole module takes `&self`. That is the point of
5//! keeping it apart from `build` — a query that had to mutate would be a design
6//! mistake, and here it would not even compile.
7//!
8//! Two rules run through all of it. A lookup that can match several things
9//! returns all of them together with how it matched ([`TitleMatch`]), because a
10//! command must never pick one answer out of several in silence. And a figure
11//! about an artist says which class of role it counts: being audible on a
12//! record and having written it are not the same fact.
13
14use std::collections::{BTreeMap, BTreeSet};
15
16use crate::text;
17
18use super::{
19    Artist, AudioFile, Catalog, EntityKind, Genre, Id, Label, Recording, Relation, Release,
20    ReleaseGroup, Track, Work, is_performing_role,
21};
22
23impl Catalog {
24    /// Earliest known file entry date; any unknown placement keeps an existing edition undated.
25    pub fn release_added_at(&self, id: Id) -> Option<u64> {
26        let release = self.release(id)?;
27        let mut earliest = None;
28        for id in &release.track_ids {
29            let file = self.file(self.track(*id)?.file_id)?;
30            let added = *self.file_added_at.get(&file.path)?;
31            earliest = Some(earliest.map_or(added, |old: u64| old.min(added)));
32        }
33        earliest
34    }
35
36    /// Order selected releases for playback: oldest first, unknown dates last.
37    /// Equal years retain the CLI folder/title tie breakers; track order is untouched.
38    pub fn playback_release_order(&self, mut ids: Vec<Id>) -> Vec<Id> {
39        ids.sort_by(|a, b| {
40            self.release(*a)
41                .map(|r| (r.year.unwrap_or(u32::MAX), &r.folder, &r.title))
42                .cmp(
43                    &self
44                        .release(*b)
45                        .map(|r| (r.year.unwrap_or(u32::MAX), &r.folder, &r.title)),
46                )
47        });
48        ids
49    }
50
51    /// The artist an id designates, or `None` when the id is out of range.
52    pub fn artist(&self, id: Id) -> Option<&Artist> {
53        self.artists.get(id as usize)
54    }
55
56    /// The release an id designates, or `None` when the id is out of range.
57    pub fn release(&self, id: Id) -> Option<&Release> {
58        self.releases.get(id as usize)
59    }
60
61    /// The canonical group an id designates, if this catalog identified it.
62    pub fn release_group(&self, id: Id) -> Option<&ReleaseGroup> {
63        self.release_groups.get(id as usize)
64    }
65
66    /// The track an id designates, or `None` when the id is out of range.
67    pub fn track(&self, id: Id) -> Option<&Track> {
68        self.tracks.get(id as usize)
69    }
70
71    /// The recorded performance an id designates, if it is in this catalog.
72    pub fn recording(&self, id: Id) -> Option<&Recording> {
73        self.recordings.get(id as usize)
74    }
75
76    /// Every local placement of one recording, in catalog order.
77    pub fn tracks_of_recording(&self, recording_id: Id) -> Vec<Id> {
78        self.recording(recording_id)
79            .map(|recording| recording.track_ids.clone())
80            .unwrap_or_default()
81    }
82
83    /// The composition an id designates, if this catalog explicitly knows it.
84    pub fn work(&self, id: Id) -> Option<&Work> {
85        self.works.get(id as usize)
86    }
87
88    /// Every recording explicitly known to realize this composition.
89    pub fn recordings_of_work(&self, work_id: Id) -> Vec<Id> {
90        self.work(work_id)
91            .map(|work| work.recording_ids.clone())
92            .unwrap_or_default()
93    }
94
95    /// Recordings whose title or MusicBrainz identifier matches the query.
96    pub fn find_recordings(&self, query: &str) -> Vec<&Recording> {
97        // A local recording can share its title with many other takes. Its
98        // first placement's path is the stable fallback identity printed by
99        // navigation when no MusicBrainz recording ID exists.
100        if let Some(path) = query.strip_prefix("local:") {
101            return self
102                .recordings
103                .iter()
104                .filter(|recording| {
105                    recording.track_ids.iter().any(|&track_id| {
106                        self.track(track_id)
107                            .and_then(|track| self.file(track.file_id))
108                            .is_some_and(|file| file.path == path)
109                    })
110                })
111                .collect();
112        }
113        let key = text::normalize(query);
114        self.recordings
115            .iter()
116            .filter(|recording| {
117                recording.key == key
118                    || recording.key.contains(&key)
119                    || recording.mbid.as_deref() == Some(query)
120            })
121            .collect()
122    }
123
124    /// Works whose title or MusicBrainz identifier matches the query.
125    pub fn find_works(&self, query: &str) -> Vec<&Work> {
126        let key = text::normalize(query);
127        self.works
128            .iter()
129            .filter(|work| work.key == key || work.key.contains(&key) || work.mbid == query)
130            .collect()
131    }
132
133    /// Release groups whose title or MusicBrainz identifier matches.
134    pub fn find_release_groups(&self, query: &str) -> Vec<&ReleaseGroup> {
135        let key = text::normalize(query);
136        self.release_groups
137            .iter()
138            .filter(|group| group.key == key || group.key.contains(&key) || group.mbid == query)
139            .collect()
140    }
141
142    /// The file an id designates, or `None` when the id is out of range.
143    pub fn file(&self, id: Id) -> Option<&AudioFile> {
144        self.files.get(id as usize)
145    }
146
147    /// Every outgoing graph relationship from one entity.
148    pub fn relations_from(&self, kind: EntityKind, id: Id) -> Vec<&Relation> {
149        self.relations
150            .iter()
151            .filter(|relation| relation.source_kind == kind && relation.source_id == id)
152            .collect()
153    }
154
155    /// Targets reached through one typed relationship.
156    pub fn related_entities(
157        &self,
158        kind: EntityKind,
159        id: Id,
160        relation_kind: &str,
161        target_kind: EntityKind,
162    ) -> Vec<Id> {
163        self.relations
164            .iter()
165            .filter(|relation| {
166                relation.source_kind == kind
167                    && relation.source_id == id
168                    && relation.target_kind == target_kind
169                    && relation.kind == relation_kind
170            })
171            .map(|relation| relation.target_id)
172            .collect()
173    }
174
175    /// Every imported analysis that describes this file, whatever the source.
176    pub fn analyses_of(
177        &self,
178        file: &AudioFile,
179    ) -> impl Iterator<Item = &crate::analysis::FileAnalysis> {
180        self.analyses.iter().filter(|a| a.path == file.path)
181    }
182
183    /// Imported analyses describing files the catalog does not hold, in full.
184    ///
185    /// Same population [`Catalog::pending_analyses`] counts. Kept apart
186    /// because most callers only want the number — `doctor` says "N waiting"
187    /// on every run — and building the list, with its own reference into
188    /// every path, is a heavier answer nobody should pay for just to learn
189    /// how many there are. `aede import --pending` is the caller that does
190    /// want the list, to say *which* ones.
191    pub fn pending_analyses_list(&self) -> Vec<&crate::analysis::FileAnalysis> {
192        let known: BTreeSet<&str> = self.files.iter().map(|f| f.path.as_str()).collect();
193        self.analyses
194            .iter()
195            .filter(|a| !known.contains(a.path.as_str()))
196            .collect()
197    }
198
199    /// Imported analyses describing files the catalog does not hold.
200    ///
201    /// Not an error and not garbage: the usual reason is that the folder they
202    /// speak of has not been scanned yet. Counting them is how the interface
203    /// can say "twelve analyses are waiting for a scan" instead of losing them
204    /// without a word.
205    pub fn pending_analyses(&self) -> usize {
206        self.pending_analyses_list().len()
207    }
208
209    /// The label an id designates, or `None` when the id is out of range.
210    pub fn label(&self, id: Id) -> Option<&Label> {
211        self.labels.get(id as usize)
212    }
213
214    /// The genre an id designates, or `None` when the id is out of range.
215    pub fn genre(&self, id: Id) -> Option<&Genre> {
216        self.genres.get(id as usize)
217    }
218
219    /// Total duration of the library, saturating at `u64::MAX` for oversized measures.
220    pub fn total_duration_ms(&self) -> u64 {
221        self.tracks
222            .iter()
223            .filter_map(|t| t.duration_ms)
224            .fold(0, u64::saturating_add)
225    }
226
227    /// Total size on disk, saturating at `u64::MAX` for oversized measures.
228    pub fn total_size(&self) -> u64 {
229        self.files
230            .iter()
231            .map(|f| f.size)
232            .fold(0, u64::saturating_add)
233    }
234
235    /// The artist's own discography: releases they are the album artist of.
236    ///
237    /// This is what a listener means by "their albums". A guest appearance on
238    /// somebody else's record belongs in [`Catalog::guest_appearances`].
239    pub fn releases_as_album_artist(&self, artist_id: Id) -> Vec<Id> {
240        self.releases
241            .iter()
242            .filter(|r| r.album_artist_id == Some(artist_id))
243            .map(|r| r.id)
244            .collect()
245    }
246
247    /// Releases the artist is audible on without being the album artist:
248    /// featured vocals, a guest solo, one track on a compilation.
249    pub fn guest_appearances(&self, artist_id: Id) -> Vec<Id> {
250        let own: BTreeSet<Id> = self
251            .releases_as_album_artist(artist_id)
252            .into_iter()
253            .collect();
254        self.releases_for(artist_id, true)
255            .into_iter()
256            .filter(|id| !own.contains(id))
257            .collect()
258    }
259
260    /// Releases where the artist holds a credit that is not a performance.
261    ///
262    /// **All of them**, including the ones they also play on. Ozzy Osbourne
263    /// composes most of what he sings: a figure that subtracted those would
264    /// report one album where the credits count sixty-nine, and the number
265    /// would be right about a set nobody asked for.
266    pub fn releases_with_writing_credit(&self, artist_id: Id) -> Vec<Id> {
267        self.releases_for(artist_id, false)
268    }
269
270    /// Tracks where the artist holds a credit that is not a performance.
271    pub fn writing_tracks_of_artist(&self, artist_id: Id) -> Vec<Id> {
272        let mut set = BTreeSet::new();
273        for credit in self.credits.iter() {
274            if credit.artist_id == artist_id
275                && credit.entity_kind == EntityKind::Track
276                && !is_performing_role(&credit.role)
277            {
278                set.insert(credit.entity_id);
279            }
280        }
281        set.into_iter().collect()
282    }
283
284    /// Releases the artist wrote or produced **and is not audible on**.
285    ///
286    /// Not a measure of anything — a display set. It exists so the artist page
287    /// can show the records that would otherwise never appear on it, without
288    /// repeating the discography above. Never label it "writing": that is
289    /// [`Catalog::releases_with_writing_credit`].
290    pub fn releases_written_without_performing(&self, artist_id: Id) -> Vec<Id> {
291        let heard: BTreeSet<Id> = self
292            .releases_as_album_artist(artist_id)
293            .into_iter()
294            .chain(self.guest_appearances(artist_id))
295            .collect();
296        self.releases_for(artist_id, false)
297            .into_iter()
298            .filter(|id| !heard.contains(id))
299            .collect()
300    }
301
302    /// Releases reached through the artist's credits, keeping either the
303    /// performing roles or the writing ones.
304    fn releases_for(&self, artist_id: Id, performing: bool) -> Vec<Id> {
305        let mut out = BTreeSet::new();
306        for credit in self.credits.iter().filter(|c| c.artist_id == artist_id) {
307            if is_performing_role(&credit.role) != performing {
308                continue;
309            }
310            let release_id = match credit.entity_kind {
311                EntityKind::Release => Some(credit.entity_id),
312                EntityKind::Track => self.track(credit.entity_id).and_then(|t| t.release_id),
313                _ => None,
314            };
315            if let Some(id) = release_id {
316                out.insert(id);
317            }
318        }
319        out.into_iter().collect()
320    }
321
322    /// Tracks the artist is audible on, without duplicates.
323    pub fn performed_tracks_of_artist(&self, artist_id: Id) -> Vec<Id> {
324        let mut set = BTreeSet::new();
325        for credit in self.credits.iter() {
326            if credit.artist_id == artist_id
327                && credit.entity_kind == EntityKind::Track
328                && is_performing_role(&credit.role)
329            {
330                set.insert(credit.entity_id);
331            }
332        }
333        set.into_iter().collect()
334    }
335
336    /// Tracks the artist wrote or produced **and is not audible on**.
337    ///
338    /// The display counterpart of [`Catalog::performed_tracks_of_artist`]:
339    /// together they cover every track credit without counting one twice, a
340    /// track where someone both plays and composes going to the first. For
341    /// "how much did this person write", use
342    /// [`Catalog::writing_tracks_of_artist`].
343    pub fn written_tracks_without_performing(&self, artist_id: Id) -> Vec<Id> {
344        let performed: BTreeSet<Id> = self
345            .performed_tracks_of_artist(artist_id)
346            .into_iter()
347            .collect();
348        let mut set = BTreeSet::new();
349        for credit in self.credits.iter() {
350            if credit.artist_id == artist_id
351                && credit.entity_kind == EntityKind::Track
352                && !is_performing_role(&credit.role)
353                && !performed.contains(&credit.entity_id)
354            {
355                set.insert(credit.entity_id);
356            }
357        }
358        set.into_iter().collect()
359    }
360
361    /// Every release the artist appears on, whatever their role.
362    pub fn releases_of_artist(&self, artist_id: Id) -> Vec<Id> {
363        let mut set = BTreeSet::new();
364        for credit in self.credits.iter().filter(|c| c.artist_id == artist_id) {
365            match credit.entity_kind {
366                EntityKind::Release => {
367                    set.insert(credit.entity_id);
368                }
369                EntityKind::Track => {
370                    if let Some(release_id) =
371                        self.track(credit.entity_id).and_then(|t| t.release_id)
372                    {
373                        set.insert(release_id);
374                    }
375                }
376                _ => {}
377            }
378        }
379        set.into_iter().collect()
380    }
381
382    /// Tracks credited to an artist, without duplicates.
383    ///
384    /// A single artist may hold several roles on one track (performer **and**
385    /// composer): without deduplication, every count would be inflated.
386    pub fn tracks_of_artist(&self, artist_id: Id) -> Vec<Id> {
387        let mut set = BTreeSet::new();
388        for credit in self.credits.iter() {
389            if credit.artist_id == artist_id && credit.entity_kind == EntityKind::Track {
390                set.insert(credit.entity_id);
391            }
392        }
393        set.into_iter().collect()
394    }
395
396    /// Artists credited on an entity, along with their role.
397    pub fn credits_on(&self, kind: EntityKind, id: Id) -> Vec<(&Artist, &str)> {
398        self.credits
399            .iter()
400            .filter(|c| c.entity_kind == kind && c.entity_id == id)
401            .filter_map(|c| self.artist(c.artist_id).map(|a| (a, c.role.as_str())))
402            .collect()
403    }
404
405    /// An artist's neighbours in the graph, from strongest link to weakest.
406    pub fn neighbours_of_artist(&self, artist_id: Id) -> Vec<(&Artist, u32, &str)> {
407        let mut out: Vec<(&Artist, u32, &str)> = self
408            .relations
409            .iter()
410            .filter(|r| {
411                r.source_kind == EntityKind::Artist
412                    && r.source_id == artist_id
413                    && r.target_kind == EntityKind::Artist
414                    && r.kind == "collaborated"
415            })
416            .filter_map(|r| {
417                self.artist(r.target_id)
418                    .map(|a| (a, r.weight, r.kind.as_str()))
419            })
420            .collect();
421        out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.name.cmp(&b.0.name)));
422        out
423    }
424
425    /// Tracks on which two artists are both credited in a performing role.
426    ///
427    /// This is what the weight of a `collaborated` relation counts, recomputed
428    /// on demand rather than stored: the `credit` table already holds the
429    /// answer, and a second copy is a second thing that can fall out of step.
430    pub fn tracks_in_common(&self, a: Id, b: Id) -> Vec<Id> {
431        let performing_on = |artist: Id| -> BTreeSet<Id> {
432            self.credits
433                .iter()
434                .filter(|c| {
435                    c.entity_kind == EntityKind::Track
436                        && c.artist_id == artist
437                        && is_performing_role(&c.role)
438                })
439                .map(|c| c.entity_id)
440                .collect()
441        };
442        let (left, right) = (performing_on(a), performing_on(b));
443        left.intersection(&right).copied().collect()
444    }
445
446    /// Releases tied to this one by a relation of the given kind.
447    ///
448    /// Used with [`crate::model::DUPLICATE`] and [`crate::model::OTHER_EDITION`]
449    /// to answer "is this album here twice, and on purpose?".
450    pub fn related_releases(&self, release_id: Id, kind: &str) -> Vec<Id> {
451        self.relations
452            .iter()
453            .filter(|r| {
454                r.source_kind == EntityKind::Release
455                    && r.source_id == release_id
456                    && r.target_kind == EntityKind::Release
457                    && r.kind == kind
458            })
459            .map(|r| r.target_id)
460            .collect()
461    }
462
463    /// Genres attached to an entity.
464    pub fn genres_of(&self, kind: EntityKind, id: Id) -> Vec<&Genre> {
465        self.genre_links
466            .iter()
467            .filter(|g| g.entity_kind == kind && g.entity_id == id)
468            .filter_map(|g| self.genre(g.genre_id))
469            .collect()
470    }
471
472    /// Case- and accent-insensitive search, across every entity.
473    pub fn search(&self, query: &str, limit: usize) -> Vec<SearchHit> {
474        let needle = text::normalize(query);
475        if needle.is_empty() {
476            return Vec::new();
477        }
478        let mut hits: Vec<SearchHit> = Vec::new();
479
480        let mut push = |kind: EntityKind, id: Id, name: &str, key: &str, detail: String| {
481            let score = match () {
482                _ if key == needle => 0u8,
483                _ if key.starts_with(&needle) => 1,
484                _ if key.contains(&needle) => 2,
485                _ => return,
486            };
487            hits.push(SearchHit {
488                kind,
489                id,
490                name: name.to_string(),
491                detail,
492                score,
493            });
494        };
495
496        for a in &self.artists {
497            push(EntityKind::Artist, a.id, &a.name, &a.key, String::new());
498        }
499        for r in &self.releases {
500            let detail = r
501                .album_artist_id
502                .and_then(|id| self.artist(id))
503                .map(|a| a.name.clone())
504                .unwrap_or_else(|| "Various Artists".to_string());
505            push(EntityKind::Release, r.id, &r.title, &r.key, detail);
506        }
507        for t in &self.tracks {
508            let key = text::normalize(&t.title);
509            let detail = t
510                .release_id
511                .and_then(|id| self.release(id))
512                .map(|r| r.title.clone())
513                .unwrap_or_default();
514            push(EntityKind::Track, t.id, &t.title, &key, detail);
515        }
516        for recording in &self.recordings {
517            push(
518                EntityKind::Recording,
519                recording.id,
520                &recording.title,
521                &recording.key,
522                ui_count(recording.track_ids.len(), "local placement"),
523            );
524        }
525        for work in &self.works {
526            push(
527                EntityKind::Work,
528                work.id,
529                &work.title,
530                &work.key,
531                ui_count(work.recording_ids.len(), "recording"),
532            );
533        }
534        for group in &self.release_groups {
535            push(
536                EntityKind::ReleaseGroup,
537                group.id,
538                &group.title,
539                &group.key,
540                ui_count(group.release_ids.len(), "local edition"),
541            );
542        }
543        for l in &self.labels {
544            push(EntityKind::Label, l.id, &l.name, &l.key, String::new());
545        }
546
547        hits.sort_by(|a, b| {
548            a.score
549                .cmp(&b.score)
550                .then_with(|| a.kind.cmp(&b.kind))
551                .then_with(|| a.name.len().cmp(&b.name.len()))
552                .then_with(|| a.name.cmp(&b.name))
553        });
554        hits.truncate(limit);
555        hits
556    }
557
558    /// Finds an artist by name, up to normalization.
559    pub fn find_artist(&self, name: &str) -> Option<&Artist> {
560        let key = text::normalize(name);
561        self.artists.iter().find(|a| a.key == key)
562    }
563
564    /// Every artist whose name matches, exactly or failing that partially.
565    ///
566    /// **The rule [`Catalog::find_releases`] and [`Catalog::find_tracks`]
567    /// already followed, and that this one did not.** `find_artist` matched
568    /// exactly and nothing else, so every caller that wanted `osbourne` to
569    /// reach Ozzy bolted a fuzzy search underneath and took its **first hit**
570    /// — an arbitrary answer among several, given without a word. On a shelf
571    /// holding both `Ozzy Osbourne` and `O. Osbourne` that is not a nicety:
572    /// `aede artist osbourne` and `aede artist ozzy` answered about two
573    /// different people, one of them with a biography and thirteen albums and
574    /// the other with seven.
575    ///
576    /// So: exact first, and only widen when nothing was exact — the same shape,
577    /// returning the same [`TitleMatch`], so a caller can say which of the two
578    /// happened and refuse to arbitrate between several.
579    pub fn find_artists(&self, name: &str) -> (Vec<&Artist>, TitleMatch) {
580        let key = text::normalize(name);
581        if key.is_empty() {
582            return (Vec::new(), TitleMatch::Exact);
583        }
584        let exact: Vec<&Artist> = self.artists.iter().filter(|a| a.key == key).collect();
585        if !exact.is_empty() {
586            return (exact, TitleMatch::Exact);
587        }
588        let aliases: Vec<&Artist> = self
589            .artists
590            .iter()
591            .filter(|artist| {
592                artist
593                    .aliases
594                    .iter()
595                    .any(|alias| text::normalize(alias) == key)
596            })
597            .collect();
598        if !aliases.is_empty() {
599            return (aliases, TitleMatch::Exact);
600        }
601        let partial = self
602            .artists
603            .iter()
604            .filter(|artist| {
605                artist.key.contains(&key)
606                    || artist
607                        .aliases
608                        .iter()
609                        .any(|alias| text::normalize(alias).contains(&key))
610            })
611            .collect();
612        (partial, TitleMatch::Partial)
613    }
614
615    /// One release by MusicBrainz identity, or every title match.
616    ///
617    /// Same rule as [`Catalog::find_tracks`], and for the same reason: a
618    /// command must not pick one answer out of several without saying so.
619    /// An identifier wins before names. Unlike tracks, two matching albums are
620    /// two *different* editions — a shared prefix is not an ambiguity — which
621    /// is why an exact title usually ends the name search without choosing one.
622    pub fn find_releases(&self, title: &str) -> (Vec<&Release>, TitleMatch) {
623        let identified: Vec<&Release> = self
624            .releases
625            .iter()
626            .filter(|release| release.mbid.as_deref() == Some(title))
627            .collect();
628        if !identified.is_empty() {
629            return (identified, TitleMatch::Exact);
630        }
631        let key = text::normalize(title);
632        if key.is_empty() {
633            return (Vec::new(), TitleMatch::Exact);
634        }
635        let exact: Vec<&Release> = self.releases.iter().filter(|r| r.key == key).collect();
636        if !exact.is_empty() {
637            return (exact, TitleMatch::Exact);
638        }
639        let partial = self
640            .releases
641            .iter()
642            .filter(|r| r.key.contains(&key))
643            .collect();
644        (partial, TitleMatch::Partial)
645    }
646
647    /// Every track carrying this title, up to normalization.
648    ///
649    /// A title is not an identifier: the same one legitimately comes back on
650    /// the album, on a single and on a live record, and those are different
651    /// recordings. All of them are returned, in catalog order.
652    ///
653    /// Exact matches win. Only when there is none does the search widen to the
654    /// titles containing the text, so that a half-remembered title still leads
655    /// somewhere; [`TitleMatch`] says which of the two happened.
656    pub fn find_tracks(&self, title: &str) -> (Vec<&Track>, TitleMatch) {
657        let key = text::normalize(title);
658        if key.is_empty() {
659            return (Vec::new(), TitleMatch::Exact);
660        }
661        let exact: Vec<&Track> = self
662            .tracks
663            .iter()
664            .filter(|t| text::normalize(&t.title) == key)
665            .collect();
666        if !exact.is_empty() {
667            return (exact, TitleMatch::Exact);
668        }
669        let partial = self
670            .tracks
671            .iter()
672            .filter(|t| text::normalize(&t.title).contains(&key))
673            .collect();
674        (partial, TitleMatch::Partial)
675    }
676
677    /// Every genre whose name matches, exactly or failing that partially.
678    ///
679    /// Same rule as [`Catalog::find_releases`]: "metal" must reach the genre
680    /// spelled exactly that way without hiding "Black Metal" and "Doom Metal"
681    /// when there is no exact one.
682    pub fn find_genres(&self, name: &str) -> (Vec<&Genre>, TitleMatch) {
683        let key = text::normalize(name);
684        if key.is_empty() {
685            return (Vec::new(), TitleMatch::Exact);
686        }
687        let exact: Vec<&Genre> = self.genres.iter().filter(|g| g.key == key).collect();
688        if !exact.is_empty() {
689            return (exact, TitleMatch::Exact);
690        }
691        (
692            self.genres
693                .iter()
694                .filter(|g| g.key.contains(&key))
695                .collect(),
696            TitleMatch::Partial,
697        )
698    }
699
700    /// Every label whose name matches, exactly or failing that partially.
701    pub fn find_labels(&self, name: &str) -> (Vec<&Label>, TitleMatch) {
702        let key = text::normalize(name);
703        if key.is_empty() {
704            return (Vec::new(), TitleMatch::Exact);
705        }
706        let exact: Vec<&Label> = self.labels.iter().filter(|l| l.key == key).collect();
707        if !exact.is_empty() {
708            return (exact, TitleMatch::Exact);
709        }
710        (
711            self.labels
712                .iter()
713                .filter(|l| l.key.contains(&key))
714                .collect(),
715            TitleMatch::Partial,
716        )
717    }
718
719    /// Tracks carrying a genre, directly or through their release.
720    ///
721    /// A genre attached to an album is a genre of every track on it: tags put
722    /// it in either place, and a listener does not think of the difference.
723    pub fn tracks_of_genre(&self, genre_id: Id) -> Vec<Id> {
724        let mut tracks: BTreeSet<Id> = BTreeSet::new();
725        for link in &self.genre_links {
726            if link.genre_id != genre_id {
727                continue;
728            }
729            match link.entity_kind {
730                EntityKind::Track => {
731                    tracks.insert(link.entity_id);
732                }
733                EntityKind::Release => {
734                    if let Some(release) = self.release(link.entity_id) {
735                        tracks.extend(release.track_ids.iter().copied());
736                    }
737                }
738                EntityKind::Artist
739                | EntityKind::Recording
740                | EntityKind::Work
741                | EntityKind::ReleaseGroup
742                | EntityKind::Label
743                | EntityKind::Genre => {}
744            }
745        }
746        tracks.into_iter().collect()
747    }
748
749    /// Releases holding at least one track of a genre, or carrying it whole.
750    pub fn releases_of_genre(&self, genre_id: Id) -> Vec<Id> {
751        let tracks: BTreeSet<Id> = self.tracks_of_genre(genre_id).into_iter().collect();
752        let mut releases: BTreeSet<Id> = BTreeSet::new();
753        for track in self.tracks.iter().filter(|t| tracks.contains(&t.id)) {
754            if let Some(id) = track.release_id {
755                releases.insert(id);
756            }
757        }
758        releases.into_iter().collect()
759    }
760
761    /// Tracks published under a label, through the releases carrying it.
762    pub fn tracks_of_label(&self, label_id: Id) -> Vec<Id> {
763        self.releases
764            .iter()
765            .filter(|r| r.label_ids.contains(&label_id))
766            .flat_map(|r| r.track_ids.iter().copied())
767            .collect()
768    }
769
770    /// Releases published under a label.
771    pub fn releases_of_label(&self, label_id: Id) -> Vec<Id> {
772        self.releases
773            .iter()
774            .filter(|r| r.label_ids.contains(&label_id))
775            .map(|r| r.id)
776            .collect()
777    }
778
779    /// Everyone credited in a role, with how many credits each holds.
780    ///
781    /// This is the inverse of the artist page: that one answers "what did this
782    /// person do", this one answers "who does this in my library". The whole
783    /// point of storing roles rather than a bare artist column is that the
784    /// question can be asked in both directions.
785    ///
786    /// Sorted by number of credits, then by sort name, so the answer is stable
787    /// and the most present come first.
788    pub fn artists_in_role(&self, role: &str) -> Vec<(Id, usize)> {
789        let mut counts: BTreeMap<Id, usize> = BTreeMap::new();
790        for credit in self.credits.iter().filter(|c| c.role == role) {
791            *counts.entry(credit.artist_id).or_insert(0) += 1;
792        }
793        let mut rows: Vec<(Id, usize)> = counts.into_iter().collect();
794        rows.sort_by(|a, b| {
795            b.1.cmp(&a.1).then_with(|| {
796                self.artist(a.0)
797                    .map(|x| x.sort_name.as_str())
798                    .cmp(&self.artist(b.0).map(|x| x.sort_name.as_str()))
799            })
800        });
801        rows
802    }
803
804    /// Tracks on which an artist holds one particular role.
805    ///
806    /// The other direction of [`Catalog::artists_in_role`], one person at a
807    /// time: not "who produces here" but "what did this person produce". The
808    /// artist page separates performing from writing; this goes one step
809    /// finer, down to the single role.
810    ///
811    /// `album` is credited on the **release**, not on its tracks, so asking
812    /// for it yields every track of the releases they sign — otherwise the
813    /// answer would be empty for the one role every album artist holds.
814    pub fn tracks_of_artist_in_role(&self, artist_id: Id, role: &str) -> Vec<Id> {
815        let mut tracks: BTreeSet<Id> = BTreeSet::new();
816        for credit in self
817            .credits
818            .iter()
819            .filter(|c| c.artist_id == artist_id && c.role == role)
820        {
821            match credit.entity_kind {
822                EntityKind::Track => {
823                    tracks.insert(credit.entity_id);
824                }
825                EntityKind::Release => {
826                    if let Some(release) = self.release(credit.entity_id) {
827                        tracks.extend(release.track_ids.iter().copied());
828                    }
829                }
830                EntityKind::Artist
831                | EntityKind::Recording
832                | EntityKind::Work
833                | EntityKind::ReleaseGroup
834                | EntityKind::Label
835                | EntityKind::Genre => {}
836            }
837        }
838        tracks.into_iter().collect()
839    }
840
841    /// The roles one artist holds, with how many credits each.
842    ///
843    /// What makes an empty answer readable: told that nobody is credited a
844    /// given way, the user has to be able to see what this person *is*
845    /// credited as.
846    pub fn roles_of_artist(&self, artist_id: Id) -> Vec<(&str, usize)> {
847        let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
848        for credit in self.credits.iter().filter(|c| c.artist_id == artist_id) {
849            *counts.entry(credit.role.as_str()).or_insert(0) += 1;
850        }
851        let mut rows: Vec<(&str, usize)> = counts.into_iter().collect();
852        rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
853        rows
854    }
855
856    /// Every role actually used in this catalog, in the order they are shown.
857    ///
858    /// Read from the credits rather than from a fixed list: a role that came
859    /// from a tag nobody anticipated is still a role, and it must be offerable.
860    pub fn roles_in_use(&self) -> Vec<&str> {
861        let mut roles: BTreeSet<&str> = BTreeSet::new();
862        for credit in &self.credits {
863            roles.insert(credit.role.as_str());
864        }
865        roles.into_iter().collect()
866    }
867
868    /// Tracks whose file carries a comment containing this text.
869    ///
870    /// The comment belongs to the **file**, not to the track: it is free text a
871    /// user wrote, and it survives in the raw tags exactly as it was typed.
872    /// Matching is done on the normalized form, so accents and case are no
873    /// obstacle — a comment is prose, and prose is typed carelessly.
874    pub fn tracks_with_comment(&self, text: &str) -> Vec<Id> {
875        let needle = text::normalize(text);
876        if needle.is_empty() {
877            return Vec::new();
878        }
879        self.tracks
880            .iter()
881            .filter(|t| {
882                self.file(t.file_id)
883                    .and_then(|f| f.first_tag("comment"))
884                    .map(|c| text::normalize(c).contains(&needle))
885                    .unwrap_or(false)
886            })
887            .map(|t| t.id)
888            .collect()
889    }
890
891    /// The words of a track: the tag that carries them, or the `.lrc` beside
892    /// the file.
893    ///
894    /// **The one place that decides which of the two wins.** The tag is
895    /// preferred, and costs nothing since raw tags are in the catalog; the
896    /// sidecar is opened only when the tag holds none, and only for the tracks
897    /// that have one. Three callers ask this question — the track page, the
898    /// `lyrics:` field of the grammar, `search --lyrics` — and three answers
899    /// that could disagree about precedence is two too many.
900    pub fn lyrics_of_track(&self, track_id: Id) -> Option<crate::lyrics::Lyrics> {
901        let file = self.file(self.track(track_id)?.file_id)?;
902        file.first_tag("lyrics")
903            .and_then(|text| crate::lyrics::from_tag(&file.path, text))
904            .or_else(|| crate::lyrics::read(std::path::Path::new(file.lyrics_path.as_ref()?)))
905    }
906
907    /// The tracks whose words carry this text, with the line that carries it.
908    ///
909    /// The **line**, not the song: a table cell holding four hundred lines is a
910    /// table nobody can read, and what somebody searching for "all aboard"
911    /// wants to see is the line they half-remembered, in the shape they
912    /// half-remembered it.
913    pub fn tracks_with_lyric(&self, text: &str) -> Vec<(Id, String)> {
914        let needle = text::normalize(text);
915        if needle.is_empty() {
916            return Vec::new();
917        }
918        self.tracks
919            .iter()
920            .filter_map(|track| {
921                let lyrics = self.lyrics_of_track(track.id)?;
922                let line = lyrics
923                    .lines
924                    .iter()
925                    .find(|line| text::normalize(&line.text).contains(&needle))?;
926                Some((track.id, line.text.clone()))
927            })
928            .collect()
929    }
930
931    /// The comment carried by the file behind a track, when there is one.
932    pub fn comment_of_track(&self, track_id: Id) -> Option<&str> {
933        let track = self.track(track_id)?;
934        self.file(track.file_id)?.first_tag("comment")
935    }
936}
937
938fn ui_count(count: usize, noun: &str) -> String {
939    format!("{count} {noun}{}", if count == 1 { "" } else { "s" })
940}
941
942/// How [`Catalog::find_tracks`] reached its results.
943#[derive(Debug, Clone, Copy, PartialEq, Eq)]
944pub enum TitleMatch {
945    /// The titles are the given one, up to normalization.
946    Exact,
947    /// No title matched, so the ones containing the text were returned.
948    Partial,
949}
950
951/// One answer returned by [`Catalog::search`], ready to be shown and followed.
952#[derive(Debug, Clone)]
953pub struct SearchHit {
954    /// Table the hit lives in, which tells the caller where to navigate.
955    pub kind: EntityKind,
956    /// Identifier within that table.
957    pub id: Id,
958    /// Display form, unnormalized, as it should appear on screen.
959    pub name: String,
960    /// Context shown next to the name (album artist, track's album…).
961    pub detail: String,
962    score: u8,
963}
964
965#[cfg(test)]
966#[path = "query_tests.rs"]
967mod tests;