aede_core/
query.rs

1//! A query language over the catalog and over what the user wrote about it.
2//!
3//! Options compose by AND and by nothing else. That is the ceiling no number of
4//! new flags ever raises: there is no `--genre metal OR --genre jazz`, no
5//! "everything except this label", no "between 1990 and 1999". A grammar has
6//! all three for free, and having one is what turns a saved query into a smart
7//! collection — which, since a selection is already what `--csv`, `--m3u` and
8//! M3's queue consume, is playable the day it is written.
9//!
10//! ```text
11//! genre:metal year:1990..1999 rating:>=4 -label:earache
12//! (artist:ozzy OR artist:dio) loved
13//! album.rating:5 played:0
14//! work:"War Pigs" instrument:guitar
15//! guest:"Zakk Wylde" with:"Ozzy Osbourne"
16//! ```
17//!
18//! **It is an interface, not a storage engine.** Defined on its own it works
19//! today over the vectors in memory and tomorrow over SQL. Defined as "whatever
20//! the database makes easy" it would arrive late and shaped by the wrong
21//! concerns.
22//!
23//! Everything evaluates against a **track**, because a track is the finest
24//! grain and every coarser answer is a fold of it: the albums matching a query
25//! are the albums of the tracks matching it. One evaluator, not five.
26
27use std::collections::{BTreeMap, BTreeSet};
28
29use crate::model::{Catalog, EntityKind, Id};
30use crate::text;
31use crate::user::{EntityRef, UserData};
32
33/// A parsed query, ready to be run against any catalog.
34#[derive(Debug, Clone, PartialEq)]
35pub enum Query {
36    /// Versioned catalog browsing filters saved as a dynamic collection.
37    Browse(Box<browse::Filter>),
38    /// Matches everything, which is what an empty expression means.
39    All,
40    /// Every part must match.
41    And(Vec<Query>),
42    /// At least one part must match.
43    Or(Vec<Query>),
44    /// The part must not match.
45    Not(Box<Query>),
46    /// One condition on one field.
47    Term(Term),
48}
49
50/// One condition: a field, and what it is being asked.
51#[derive(Debug, Clone, PartialEq)]
52pub struct Term {
53    /// Which field, already resolved to something known.
54    pub field: Field,
55    /// What is being asked of it.
56    pub test: Test,
57}
58
59/// What a term asks of a value.
60#[derive(Debug, Clone, PartialEq)]
61pub enum Test {
62    /// The text contains this, compared normalized.
63    Contains(String),
64    /// The text is exactly this, compared normalized.
65    Is(String),
66    /// The number satisfies the comparison.
67    Compare(Compare, f64),
68    /// The number falls in the range, either end open.
69    Between(Option<f64>, Option<f64>),
70    /// The flag is set.
71    Set,
72    /// The flag is not set, which `lossless:false` and `loved:no` ask for.
73    Unset,
74}
75
76/// How two numbers are compared.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum Compare {
79    /// `=`
80    Equal,
81    /// `>`
82    Greater,
83    /// `>=`
84    AtLeast,
85    /// `<`
86    Less,
87    /// `<=`
88    AtMost,
89}
90
91/// Which value of a track a term reads.
92///
93/// The names are the ones typed, and the dotted ones say **where** an opinion
94/// was written: `rating` is the track's own, `album.rating` the album's,
95/// `artist.rating` the artist's. Without that distinction "rated five stars"
96/// would be a different claim depending on where the user happened to put it,
97/// and no message could say which was meant.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum Field {
100    /// Track title.
101    Title,
102    /// Any artist credited on the track, in any role.
103    Artist,
104    /// Album title.
105    Album,
106    /// The canonical recorded performance behind the local track.
107    Recording,
108    /// An identified composition, its explicit parent, or this file's WORK tag.
109    Work,
110    /// A movement title or number written in the local file's tags.
111    Movement,
112    /// The release group shared by this edition and its remasters.
113    ReleaseGroup,
114    /// The album's own artist.
115    AlbumArtist,
116    /// A genre carried by the track or by its album.
117    Genre,
118    /// A label the album came out on.
119    Label,
120    /// The comment tag, as the tagger wrote it.
121    Comment,
122    /// The words: from the tag that carries them, or from the `.lrc` beside
123    /// the file.
124    Lyrics,
125    /// The file's path.
126    Path,
127    /// The codec: flac, mp3, opus…
128    Codec,
129    /// File container, independently of the contained codec.
130    Container,
131    /// Year of the album.
132    Year,
133    /// Playing time, in milliseconds.
134    Duration,
135    /// Size on disk, in bytes.
136    Size,
137    /// Bitrate in kbps.
138    Bitrate,
139    /// Sample rate in Hz.
140    SampleRate,
141    /// Declared bits per sample, when the format provides it.
142    BitDepth,
143    /// Declared source channel count.
144    Channels,
145    /// Stable catalog track reference, independent of display titles.
146    Reference,
147    /// `true` for a lossless codec.
148    Lossless,
149    /// `true` when the album is one several artists share.
150    Compilation,
151    /// Stars given, on the entity named by the scope.
152    Rating(Scope),
153    /// A favourite, on the entity named by the scope.
154    Loved(Scope),
155    /// A favourite, wherever it was actually written: the track itself, or
156    /// failing that its album, or failing that its artist.
157    ///
158    /// What a bare `loved` asks. Unlike [`Field::Rating`], a favourite is a
159    /// blunter signal — closer to "this matters to me" than to a precise
160    /// score — so loving a whole album should not have to be repeated one
161    /// track at a time. `track.loved` still reaches the precise, track-only
162    /// question, exactly as `album.loved` and `artist.loved` already do for
163    /// theirs.
164    LovedAnywhere,
165    /// A free label, on the entity named by the scope.
166    Tag(Scope),
167    /// A note was written, and contains this text.
168    Note(Scope),
169    /// How many times it was played.
170    Played,
171    /// An artist audible on the track, in any performing role.
172    ///
173    /// The class `model::is_performing_role` draws: singing one guest verse
174    /// counts, having written the words does not. `artist --with` asks exactly
175    /// this question, and no pile of role fields ORed together would say it as
176    /// plainly.
177    Performing,
178    /// An artist credited in one named role.
179    ///
180    /// `artist:` matches any credit, in any role, which is what makes
181    /// `artist:ozzy artist:"zakk wylde"` already mean "both are on it". This
182    /// asks the finer question the graph was built for: *who did what*.
183    Credit(&'static str),
184    /// An instrument or relationship attribute on a credit.
185    Instrument,
186    /// A performing artist appearing on another artist's non-compilation release.
187    Guest,
188    /// A performing artist appearing on a compilation.
189    CompilationArtist,
190    /// An artist with a non-performing contribution such as composer or producer.
191    Contributor,
192    /// A performing artist sharing the track with at least one other performer.
193    Collaborator,
194    /// The whole track, for a bare word with no field.
195    Anything,
196}
197
198/// Which entity an opinion was written on.
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum Scope {
201    /// The track itself.
202    Track,
203    /// The album it belongs to.
204    Album,
205    /// Its main artist.
206    Artist,
207}
208
209/// Why a query could not be read.
210#[derive(Debug, Clone, PartialEq)]
211pub struct QueryError {
212    /// What went wrong, worded for the person who typed it.
213    pub message: String,
214}
215
216impl std::fmt::Display for QueryError {
217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        write!(f, "{}", self.message)
219    }
220}
221
222impl std::error::Error for QueryError {}
223
224fn error(message: impl Into<String>) -> QueryError {
225    QueryError {
226        message: message.into(),
227    }
228}
229
230mod parsing;
231pub use parsing::parse;
232
233/// Every field, by the name it is typed under.
234const FIELD_NAMES: &[(&str, Field)] = &[
235    ("title", Field::Title),
236    ("artist", Field::Artist),
237    ("album", Field::Album),
238    ("albumartist", Field::AlbumArtist),
239    ("recording", Field::Recording),
240    ("work", Field::Work),
241    ("movement", Field::Movement),
242    ("releasegroup", Field::ReleaseGroup),
243    ("release-group", Field::ReleaseGroup),
244    ("release_group", Field::ReleaseGroup),
245    ("genre", Field::Genre),
246    ("label", Field::Label),
247    ("comment", Field::Comment),
248    ("lyrics", Field::Lyrics),
249    ("path", Field::Path),
250    ("codec", Field::Codec),
251    ("format", Field::Codec),
252    ("container", Field::Container),
253    ("year", Field::Year),
254    ("duration", Field::Duration),
255    ("length", Field::Duration),
256    ("size", Field::Size),
257    ("bitrate", Field::Bitrate),
258    ("samplerate", Field::SampleRate),
259    ("bitdepth", Field::BitDepth),
260    ("channels", Field::Channels),
261    ("ref", Field::Reference),
262    ("lossless", Field::Lossless),
263    ("compilation", Field::Compilation),
264    ("played", Field::Played),
265    ("rating", Field::Rating(Scope::Track)),
266    ("album.rating", Field::Rating(Scope::Album)),
267    ("artist.rating", Field::Rating(Scope::Artist)),
268    ("loved", Field::LovedAnywhere),
269    ("track.loved", Field::Loved(Scope::Track)),
270    ("album.loved", Field::Loved(Scope::Album)),
271    ("artist.loved", Field::Loved(Scope::Artist)),
272    ("tag", Field::Tag(Scope::Track)),
273    ("album.tag", Field::Tag(Scope::Album)),
274    ("artist.tag", Field::Tag(Scope::Artist)),
275    // One field per role, so that the credit table can be asked its own
276    // question: `composer:bach performer:gould` is what a graph is for, and
277    // what no pile of options was ever going to express. A role arriving from
278    // MusicBrainz at M1 needs one row here.
279    ("composer", Field::Credit("composer")),
280    ("lyricist", Field::Credit("lyricist")),
281    ("producer", Field::Credit("producer")),
282    ("engineer", Field::Credit("engineer")),
283    ("performer", Field::Credit("performer")),
284    ("conductor", Field::Credit("conductor")),
285    ("orchestra", Field::Credit("orchestra")),
286    ("choir", Field::Credit("choir")),
287    ("ensemble", Field::Credit("ensemble")),
288    ("soloist", Field::Credit("soloist")),
289    ("remixer", Field::Credit("remixer")),
290    ("featured", Field::Credit("featured")),
291    ("mainartist", Field::Credit("main")),
292    ("performing", Field::Performing),
293    ("instrument", Field::Instrument),
294    ("guest", Field::Guest),
295    ("compilationartist", Field::CompilationArtist),
296    ("compilation-artist", Field::CompilationArtist),
297    ("contributor", Field::Contributor),
298    ("collaborator", Field::Collaborator),
299    ("with", Field::Collaborator),
300    ("note", Field::Note(Scope::Track)),
301    ("album.note", Field::Note(Scope::Album)),
302    ("artist.note", Field::Note(Scope::Artist)),
303];
304
305fn field_named(name: &str) -> Option<Field> {
306    let wanted = name.trim().to_lowercase();
307    FIELD_NAMES
308        .iter()
309        .find(|(n, _)| *n == wanted)
310        .map(|(_, f)| f.clone())
311}
312
313fn is_numeric(field: &Field) -> bool {
314    matches!(
315        field,
316        Field::Year
317            | Field::Duration
318            | Field::Size
319            | Field::Bitrate
320            | Field::SampleRate
321            | Field::BitDepth
322            | Field::Channels
323            | Field::Played
324            | Field::Rating(_)
325    )
326}
327
328fn is_flag(field: &Field) -> bool {
329    matches!(
330        field,
331        Field::Lossless | Field::Compilation | Field::Loved(_) | Field::LovedAnywhere
332    )
333}
334
335/// The same question, asked of the album or the artist instead of the track.
336///
337/// Every field the *user* writes carries a scope, and a bare `rating`, `tag`
338/// or `note` means the track's own. That is deliberate — five stars on an
339/// artist is not five stars on a track — but it makes one answer badly
340/// misleading: somebody who rated an **album** types `rating`, is told
341/// nothing matches, and concludes the feature is broken. It is not; they
342/// asked a different question from the one they meant. (`loved` is the one
343/// exception: see [`Field::LovedAnywhere`], which already looks past the
344/// track, so there is nothing here for it to rescope.)
345///
346/// So a caller that gets an empty result can ask the same question again at
347/// another scope, and — if *that* answers — say which scope holds what was
348/// written. The suggestion is only ever a suggestion: the query itself keeps
349/// meaning exactly what it says.
350///
351/// Fields that carry no scope are left alone, so a mixed expression such as
352/// `genre:metal rating` is rescoped only where rescoping means something.
353pub fn rescoped(query: &Query, scope: Scope) -> Query {
354    match query {
355        Query::Browse(filter) => Query::Browse(filter.clone()),
356        Query::All => Query::All,
357        Query::And(parts) => Query::And(parts.iter().map(|p| rescoped(p, scope)).collect()),
358        Query::Or(parts) => Query::Or(parts.iter().map(|p| rescoped(p, scope)).collect()),
359        Query::Not(inner) => Query::Not(Box::new(rescoped(inner, scope))),
360        Query::Term(term) => Query::Term(Term {
361            field: match &term.field {
362                Field::Rating(_) => Field::Rating(scope),
363                Field::Loved(_) => Field::Loved(scope),
364                Field::Tag(_) => Field::Tag(scope),
365                Field::Note(_) => Field::Note(scope),
366                other => other.clone(),
367            },
368            test: term.test.clone(),
369        }),
370    }
371}
372
373/// `true` when the expression asks about something the user wrote, at the
374/// track's own scope — the case where [`rescoped`] has anything to offer.
375pub fn asks_about_the_track_itself(query: &Query) -> bool {
376    match query {
377        Query::Browse(_) => false,
378        Query::All => false,
379        Query::And(parts) | Query::Or(parts) => parts.iter().any(asks_about_the_track_itself),
380        Query::Not(inner) => asks_about_the_track_itself(inner),
381        Query::Term(term) => matches!(
382            term.field,
383            Field::Rating(Scope::Track)
384                | Field::Loved(Scope::Track)
385                | Field::Tag(Scope::Track)
386                | Field::Note(Scope::Track)
387        ),
388    }
389}
390
391/// Fields a bare mention can ask about: "is there one at all?"
392///
393/// Wider than [`is_flag`], and the two were one predicate until that turned out
394/// to answer two different questions with one answer. `is_flag` says whether
395/// `field:true` means a yes or a no; this says whether the field's *name*,
396/// written alone, is a question. They coincide for `lossless` and `loved`, and
397/// come apart exactly where it matters:
398///
399/// - `note:vinyle` searches inside the note, so `note` is not a yes/no field;
400/// - `note` alone can only mean "the ones I have written a note on", because
401///   nobody searches a music library for the word "note".
402///
403/// Before they were separated there was **no way at all** to ask which things
404/// carried a note, a tag or a rating: a bare `note` fell through to a text
405/// search for the word, `note:true` searched for the word "true", and the
406/// fallback in [`flag_of`] that exists precisely to answer this — "any other
407/// field used as a bare flag asks whether it holds anything" — was unreachable.
408///
409/// The cost is that a bare `note`, `tag` or `rating` can no longer be a text
410/// search for those three words. Written with a field they still are:
411/// `title:note` finds the word.
412fn asks_whether_it_holds_anything(field: &Field) -> bool {
413    is_flag(field) || matches!(field, Field::Rating(_) | Field::Note(_) | Field::Tag(_))
414}
415
416/// Fields whose values come from a closed list the library holds.
417///
418/// Asking for a genre that exists and holds nothing, and asking for a genre
419/// nobody ever heard of, are two different questions and deserve two different
420/// answers — the same distinction `artists --role` already draws. A grammar
421/// that answered "nothing matches" to both would be a step backwards from the
422/// options it is meant to replace.
423fn closed_vocabulary(field: &Field) -> Option<&'static str> {
424    Some(match field {
425        Field::Genre => "genre",
426        Field::Label => "label",
427        Field::Credit(_)
428        | Field::Performing
429        | Field::Guest
430        | Field::CompilationArtist
431        | Field::Contributor
432        | Field::Collaborator => "artist",
433        _ => return None,
434    })
435}
436
437/// Values a query names that the library has never heard of.
438///
439/// Returned rather than raised, so the caller decides: a command refuses, and
440/// a saved collection listing shows the row anyway.
441pub fn unknown_values(query: &Query, context: &Context) -> Vec<(String, String)> {
442    let mut found = Vec::new();
443    collect_unknown(query, context, &mut found);
444    found
445}
446
447fn collect_unknown(query: &Query, context: &Context, out: &mut Vec<(String, String)>) {
448    match query {
449        Query::Browse(_) | Query::All => {}
450        Query::And(parts) | Query::Or(parts) => {
451            for part in parts {
452                collect_unknown(part, context, out);
453            }
454        }
455        Query::Not(inner) => collect_unknown(inner, context, out),
456        Query::Term(term) => {
457            let Some(what) = closed_vocabulary(&term.field) else {
458                return;
459            };
460            let wanted = match &term.test {
461                Test::Contains(value) | Test::Is(value) => text::normalize(value),
462                _ => return,
463            };
464            if wanted.is_empty() {
465                return;
466            }
467            let known = match &term.field {
468                Field::Genre => context
469                    .catalog
470                    .genres
471                    .iter()
472                    .any(|g| g.key.contains(&wanted)),
473                Field::Label => context.catalog.labels.iter().any(|l| {
474                    l.key.contains(&wanted)
475                        || l.mbid
476                            .as_deref()
477                            .is_some_and(|mbid| text::normalize(mbid).contains(&wanted))
478                        || context
479                            .sourced
480                            .label_mbids
481                            .get(&l.id)
482                            .is_some_and(|mbid| text::normalize(mbid).contains(&wanted))
483                }),
484                // A role field names a person, and a person who is in the
485                // library but never credited that way is an empty result, not
486                // a misunderstanding.
487                Field::Credit(_)
488                | Field::Performing
489                | Field::Guest
490                | Field::CompilationArtist
491                | Field::Contributor
492                | Field::Collaborator => {
493                    context.catalog.artists.iter().any(|a| {
494                        a.key.contains(&wanted)
495                            || a.aliases
496                                .iter()
497                                .any(|alias| text::normalize(alias).contains(&wanted))
498                            || a.mbid
499                                .as_deref()
500                                .is_some_and(|mbid| text::normalize(mbid) == wanted)
501                    }) || context
502                        .sourced
503                        .credits
504                        .values()
505                        .chain(context.sourced.edition_credits.values())
506                        .flatten()
507                        .any(|credit| {
508                            text::normalize(&credit.artist_name).contains(&wanted)
509                                || text::normalize(&credit.artist_mbid) == wanted
510                                || credit
511                                    .credited_as
512                                    .as_deref()
513                                    .is_some_and(|name| text::normalize(name).contains(&wanted))
514                        })
515                }
516                _ => true,
517            };
518            if !known {
519                out.push((what.to_string(), wanted));
520            }
521        }
522    }
523}
524
525/// The field names, for a help message or a completion.
526pub fn field_names() -> Vec<&'static str> {
527    FIELD_NAMES.iter().map(|(name, _)| *name).collect()
528}
529
530// --------------------------------------------------------------------------
531// Running a query
532// --------------------------------------------------------------------------
533
534pub mod browse;
535mod indexes;
536mod sorting;
537pub use sorting::{Sort, SortKey, sort, sort_key_names};
538
539/// Everything needed to answer, gathered once rather than per track.
540pub struct Context<'a> {
541    /// The library.
542    pub catalog: &'a Catalog,
543    /// What the user wrote about it.
544    pub data: &'a UserData,
545    /// Whose opinions count.
546    pub owner: &'a str,
547    /// Certain external relationships, indexed once for this evaluation.
548    sourced: SourcedRelations,
549    browse_claims: browse::Claims,
550    local: indexes::LocalIndexes<'a>,
551}
552
553impl<'a> Context<'a> {
554    /// Creates a query context over local tags and user data.
555    pub fn new(catalog: &'a Catalog, data: &'a UserData, owner: &'a str) -> Context<'a> {
556        Context {
557            catalog,
558            data,
559            owner,
560            sourced: SourcedRelations::default(),
561            browse_claims: browse::Claims::default(),
562            local: indexes::LocalIndexes::new(catalog, data, owner),
563        }
564    }
565
566    /// Adds externally sourced relationships that are certain enough to
567    /// traverse. Approximate matches remain evidence and never silently become
568    /// query results.
569    pub fn with_sources(mut self, sources: &crate::sources::Sources) -> Context<'a> {
570        self.sourced = SourcedRelations::new(self.catalog, sources);
571        self.browse_claims = browse::Claims::new(self.catalog, sources);
572        self
573    }
574}
575
576#[derive(Default)]
577struct SourcedRelations {
578    works: BTreeMap<Id, Vec<crate::sources::WorkLink>>,
579    work_parents: BTreeMap<Id, Vec<crate::sources::WorkParentLink>>,
580    credits: BTreeMap<Id, Vec<crate::sources::CreditLink>>,
581    edition_credits: BTreeMap<Id, Vec<crate::sources::CreditLink>>,
582    label_mbids: BTreeMap<Id, String>,
583}
584
585impl SourcedRelations {
586    fn new(catalog: &Catalog, sources: &crate::sources::Sources) -> SourcedRelations {
587        let mut indexed = SourcedRelations::default();
588        for link in sources
589            .work_links(catalog)
590            .into_iter()
591            .filter(|link| link.trusted)
592        {
593            let works = indexed.works.entry(link.recording_id).or_default();
594            if !works.contains(&link.work) {
595                works.push(link.work);
596            }
597        }
598        for link in sources
599            .work_parent_links(catalog)
600            .into_iter()
601            .filter(|link| link.trusted)
602        {
603            for &recording_id in link.applicable_recording_ids.iter() {
604                let parents = indexed.work_parents.entry(recording_id).or_default();
605                if !parents.contains(&link.parent) {
606                    parents.push(link.parent.clone());
607                }
608            }
609        }
610        for link in sources
611            .credit_links(catalog)
612            .into_iter()
613            .filter(|link| link.trusted)
614        {
615            for &recording_id in link.applicable_recording_ids.iter() {
616                let credits = indexed.credits.entry(recording_id).or_default();
617                if !credits.contains(&link.credit) {
618                    credits.push(link.credit.clone());
619                }
620            }
621        }
622        for link in sources
623            .edition_credit_links(catalog)
624            .into_iter()
625            .filter(|link| link.trusted)
626        {
627            let credits = indexed.edition_credits.entry(link.release_id).or_default();
628            if !credits.contains(&link.credit) {
629                credits.push(link.credit);
630            }
631        }
632        for label in &catalog.labels {
633            let Some(identity) = sources.label_identity(catalog, label.id) else {
634                continue;
635            };
636            let mbid = match identity {
637                crate::sources::LabelIdentityResolution::Confirmed { mbid, .. }
638                | crate::sources::LabelIdentityResolution::Agrees { mbid, .. }
639                | crate::sources::LabelIdentityResolution::Accepted { mbid, .. } => mbid,
640                _ => continue,
641            };
642            indexed.label_mbids.insert(label.id, mbid);
643        }
644        indexed
645    }
646}
647
648/// The tracks a query matches, in catalog order.
649///
650/// Order matters because everything downstream pages through it, and paging is
651/// only meaningful while the order is the same on every run.
652pub fn run(query: &Query, context: &Context) -> Vec<Id> {
653    if let Query::Browse(filter) = query {
654        return browse::tracks(filter, context);
655    }
656    context
657        .catalog
658        .tracks
659        .iter()
660        .filter(|track| matches(query, context, track.id))
661        .map(|track| track.id)
662        .collect()
663}
664
665/// Whether one track satisfies a query.
666pub fn matches(query: &Query, context: &Context, track: Id) -> bool {
667    match query {
668        Query::Browse(filter) => browse::tracks(filter, context).contains(&track),
669        Query::All => true,
670        Query::And(parts) => parts.iter().all(|p| matches(p, context, track)),
671        Query::Or(parts) => parts.iter().any(|p| matches(p, context, track)),
672        Query::Not(inner) => !matches(inner, context, track),
673        Query::Term(term) => term_matches(term, context, track),
674    }
675}
676
677fn term_matches(term: &Term, context: &Context, track: Id) -> bool {
678    match &term.test {
679        Test::Set => flag_of(&term.field, context, track),
680        Test::Unset => !flag_of(&term.field, context, track),
681        Test::Compare(compare, wanted) => match number_of(&term.field, context, track) {
682            // A track with no year cannot satisfy a question about years. It is
683            // absent from the answer rather than counted as zero, which would
684            // put every untagged file in "before 1970".
685            None => false,
686            Some(value) => match compare {
687                Compare::Equal => (value - wanted).abs() < f64::EPSILON,
688                Compare::Greater => value > *wanted,
689                Compare::AtLeast => value >= *wanted,
690                Compare::Less => value < *wanted,
691                Compare::AtMost => value <= *wanted,
692            },
693        },
694        Test::Between(low, high) => match number_of(&term.field, context, track) {
695            None => false,
696            Some(value) => {
697                low.map(|l| value >= l).unwrap_or(true) && high.map(|h| value <= h).unwrap_or(true)
698            }
699        },
700        Test::Contains(wanted) => {
701            let wanted = text::normalize(wanted);
702            texts_of(&term.field, context, track)
703                .iter()
704                .any(|value| text::normalize(value).contains(&wanted))
705        }
706        Test::Is(wanted) => {
707            let wanted = text::normalize(wanted);
708            texts_of(&term.field, context, track)
709                .iter()
710                .any(|value| text::normalize(value) == wanted)
711        }
712    }
713}
714
715/// The reference for the entity a scope names, starting from a track.
716fn scoped(scope: Scope, context: &Context, track: Id) -> Option<EntityRef> {
717    let catalog = context.catalog;
718    match scope {
719        Scope::Track => EntityRef::of(catalog, EntityKind::Track, track),
720        Scope::Album => catalog
721            .track(track)
722            .and_then(|t| t.release_id)
723            .and_then(|r| EntityRef::of(catalog, EntityKind::Release, r)),
724        Scope::Artist => main_artist(context, track)
725            .and_then(|artist| EntityRef::of(catalog, EntityKind::Artist, artist.id)),
726    }
727}
728
729fn annotation<'a>(
730    scope: Scope,
731    context: &'a Context,
732    track: Id,
733) -> Option<&'a crate::user::Annotation> {
734    let reference = scoped(scope, context, track)?;
735    context
736        .local
737        .annotation(context.data, context.owner, &reference)
738}
739
740fn main_artist<'a>(context: &Context<'a>, track: Id) -> Option<&'a crate::model::Artist> {
741    context
742        .local
743        .credits(context.catalog, EntityKind::Track, track)
744        .iter()
745        .filter(|credit| credit.role == "main")
746        .find_map(|credit| context.catalog.artist(credit.artist_id))
747}
748
749fn local_credits<'a>(context: &Context<'a>, track: Id) -> Vec<&'a crate::model::Credit> {
750    let mut credits = context
751        .local
752        .credits(context.catalog, EntityKind::Track, track)
753        .to_vec();
754    if let Some(release) = context.catalog.track(track).and_then(|row| row.release_id) {
755        credits.extend_from_slice(&context.local.credits(
756            context.catalog,
757            EntityKind::Release,
758            release,
759        ));
760    }
761    credits
762}
763
764fn flag_of(field: &Field, context: &Context, track: Id) -> bool {
765    match field {
766        Field::Lossless => context
767            .catalog
768            .track(track)
769            .and_then(|t| context.catalog.file(t.file_id))
770            .map(|f| f.properties.lossless)
771            .unwrap_or(false),
772        Field::Compilation => context
773            .catalog
774            .track(track)
775            .and_then(|t| t.release_id)
776            .and_then(|r| context.catalog.release(r))
777            .map(|r| r.is_compilation)
778            .unwrap_or(false),
779        Field::Loved(scope) => annotation(*scope, context, track)
780            .map(|a| a.loved)
781            .unwrap_or(false),
782        Field::LovedAnywhere => [Scope::Track, Scope::Album, Scope::Artist]
783            .into_iter()
784            .any(|scope| annotation(scope, context, track).is_some_and(|a| a.loved)),
785        // Any other field used as a bare flag asks whether it holds anything.
786        _ => {
787            !texts_of(field, context, track).is_empty()
788                || number_of(field, context, track).is_some()
789        }
790    }
791}
792
793fn number_of(field: &Field, context: &Context, track: Id) -> Option<f64> {
794    let catalog = context.catalog;
795    let track_row = catalog.track(track)?;
796    let file = catalog.file(track_row.file_id);
797    match field {
798        Field::Year => track_row
799            .release_id
800            .and_then(|r| catalog.release(r))
801            .and_then(|r| r.year)
802            .map(f64::from),
803        Field::Duration => track_row.duration_ms.map(|d| d as f64),
804        Field::Size => file.map(|f| f.size as f64),
805        Field::Bitrate => file.and_then(|f| f.properties.bitrate_kbps).map(f64::from),
806        Field::SampleRate => file.and_then(|f| f.properties.sample_rate).map(f64::from),
807        Field::BitDepth => file.and_then(|f| f.properties.bit_depth).map(f64::from),
808        Field::Channels => file.and_then(|f| f.properties.channels).map(f64::from),
809        Field::Played => {
810            let reference = scoped(Scope::Track, context, track)?;
811            Some(f64::from(context.local.play_count(
812                context.data,
813                context.owner,
814                &reference,
815            )))
816        }
817        Field::Rating(scope) => annotation(*scope, context, track)
818            .and_then(|a| a.rating)
819            .map(f64::from),
820        _ => None,
821    }
822}
823
824fn texts_of(field: &Field, context: &Context, track: Id) -> Vec<String> {
825    let catalog = context.catalog;
826    let Some(track_row) = catalog.track(track) else {
827        return Vec::new();
828    };
829    let file = catalog.file(track_row.file_id);
830    let release = track_row.release_id.and_then(|r| catalog.release(r));
831    match field {
832        Field::Title => vec![track_row.title.clone()],
833        Field::Artist => {
834            let mut values = Vec::new();
835            for credit in context
836                .local
837                .credits(context.catalog, EntityKind::Track, track)
838                .iter()
839            {
840                extend_artist_values(catalog, credit.artist_id, &mut values);
841                if let Some(credited_as) = &credit.credited_as {
842                    push_unique(&mut values, credited_as.clone());
843                }
844            }
845            if let Some(release_id) = track_row.release_id {
846                for credit in context
847                    .local
848                    .credits(context.catalog, EntityKind::Release, release_id)
849                    .iter()
850                {
851                    extend_artist_values(catalog, credit.artist_id, &mut values);
852                }
853            }
854            for credit in sourced_credits(context, track) {
855                extend_sourced_credit_values(credit, &mut values);
856            }
857            values
858        }
859        Field::Album => release
860            .map(|r| {
861                let mut values = vec![r.title.clone()];
862                if let Some(mbid) = &r.mbid {
863                    values.push(mbid.clone());
864                }
865                values
866            })
867            .unwrap_or_default(),
868        Field::Recording => catalog
869            .recording(track_row.recording_id)
870            .map(|recording| {
871                let mut values = vec![recording.title.clone()];
872                if let Some(mbid) = &recording.mbid {
873                    values.push(mbid.clone());
874                }
875                values
876            })
877            .unwrap_or_default(),
878        Field::Work => {
879            let mut values = catalog
880                .recording(track_row.recording_id)
881                .map(|recording| {
882                    recording
883                        .work_ids
884                        .iter()
885                        .filter_map(|&id| catalog.work(id))
886                        .flat_map(|work| vec![work.title.clone(), work.mbid.clone()])
887                        .collect()
888                })
889                .unwrap_or_default();
890            // A local WORK/grouping tag is searchable even without an MBID.
891            // This is text on one file, not a new canonical work identity.
892            if let Some(work_tag) = catalog
893                .file(track_row.file_id)
894                .and_then(|file| file.first_tag("grouping"))
895            {
896                push_unique(&mut values, work_tag.to_string());
897            }
898            if let Some(works) = context.sourced.works.get(&track_row.recording_id) {
899                for work in works {
900                    push_unique(&mut values, work.title.clone());
901                    push_unique(&mut values, work.mbid.clone());
902                }
903            }
904            if let Some(parents) = context.sourced.work_parents.get(&track_row.recording_id) {
905                for parent in parents {
906                    push_unique(&mut values, parent.title.clone());
907                    push_unique(&mut values, parent.mbid.clone());
908                }
909            }
910            values
911        }
912        Field::Movement => catalog
913            .file(track_row.file_id)
914            .map(|file| {
915                ["movement", "movementnumber"]
916                    .into_iter()
917                    .filter_map(|tag| file.first_tag(tag).map(str::to_string))
918                    .collect()
919            })
920            .unwrap_or_default(),
921        Field::ReleaseGroup => release
922            .and_then(|r| r.release_group_id)
923            .and_then(|id| catalog.release_group(id))
924            .map(|group| vec![group.title.clone(), group.mbid.clone()])
925            .unwrap_or_default(),
926        Field::AlbumArtist => release
927            .and_then(|r| r.album_artist_id)
928            .map(|a| artist_values(catalog, a))
929            .unwrap_or_default(),
930        Field::Genre => {
931            let mut names: Vec<String> = context
932                .local
933                .genres(context.catalog, EntityKind::Track, track)
934                .iter()
935                .map(|g| g.name.clone())
936                .collect();
937            if let Some(release) = release {
938                names.extend(
939                    context
940                        .local
941                        .genres(context.catalog, EntityKind::Release, release.id)
942                        .iter()
943                        .map(|g| g.name.clone()),
944                );
945            }
946            names
947        }
948        Field::Label => release
949            .map(|r| {
950                r.label_ids.iter().fold(Vec::new(), |mut values, &id| {
951                    if let Some(label) = catalog.label(id) {
952                        values.push(label.name.clone());
953                        if let Some(mbid) = &label.mbid {
954                            values.push(mbid.clone());
955                        }
956                        if let Some(mbid) = context.sourced.label_mbids.get(&id) {
957                            push_unique(&mut values, mbid.clone());
958                        }
959                    }
960                    values
961                })
962            })
963            .unwrap_or_default(),
964        Field::Comment => file
965            .and_then(|f| f.tags.get("comment"))
966            .cloned()
967            .unwrap_or_default(),
968        // The tag first, because it costs nothing — raw tags are in the
969        // catalog. The sidecar is only opened when the tag holds nothing, and
970        // only for the tracks that have one, which is what keeps a search
971        // across a library from being ten thousand file reads.
972        Field::Lyrics => catalog
973            .lyrics_of_track(track)
974            .map(|lyrics| vec![lyrics.text()])
975            .unwrap_or_default(),
976        Field::Reference => EntityRef::of(catalog, EntityKind::Track, track)
977            .map(|r| vec![r.to_token()])
978            .unwrap_or_default(),
979        Field::Container => file
980            .map(|f| vec![f.properties.container.clone()])
981            .unwrap_or_default(),
982        Field::Path => file.map(|f| vec![f.path.clone()]).unwrap_or_default(),
983        Field::Codec => file
984            .map(|f| {
985                vec![
986                    f.properties.codec.clone(),
987                    f.properties.container.clone(),
988                    f.properties.quality_label(),
989                ]
990            })
991            .unwrap_or_default(),
992        Field::Performing => credits_as_text(context, track, |role, _| {
993            crate::model::is_performing_role(role)
994        }),
995        Field::Credit(role) => credits_as_text(context, track, |credited, attributes| {
996            credited == *role
997                || (*role == "orchestra" && credited == "performing orchestra")
998                || (*role == "soloist"
999                    && matches!(credited, "performer" | "instrument" | "vocal")
1000                    && attributes.iter().any(|attribute| attribute.name == "solo"))
1001        }),
1002        Field::Instrument => {
1003            let mut values = Vec::new();
1004            for credit in context
1005                .local
1006                .credits(context.catalog, EntityKind::Track, track)
1007                .iter()
1008            {
1009                for attribute in &credit.attributes {
1010                    push_unique(&mut values, attribute.name.clone());
1011                    if let Some(value) = &attribute.value {
1012                        push_unique(&mut values, value.clone());
1013                    }
1014                    if let Some(credited_as) = &attribute.credited_as {
1015                        push_unique(&mut values, credited_as.clone());
1016                    }
1017                }
1018            }
1019            for credit in sourced_credits(context, track) {
1020                for attribute in &credit.attributes {
1021                    push_unique(&mut values, attribute.name.clone());
1022                    if let Some(value) = &attribute.value {
1023                        push_unique(&mut values, value.clone());
1024                    }
1025                    if let Some(credited_as) = &attribute.credited_as {
1026                        push_unique(&mut values, credited_as.clone());
1027                    }
1028                }
1029            }
1030            values
1031        }
1032        Field::Guest | Field::CompilationArtist | Field::Contributor | Field::Collaborator => {
1033            participation_values(field, context, track, release)
1034        }
1035        Field::Tag(scope) => annotation(*scope, context, track)
1036            .map(|a| a.tags.iter().cloned().collect())
1037            .unwrap_or_default(),
1038        Field::Note(scope) => annotation(*scope, context, track)
1039            .and_then(|a| a.note.clone())
1040            .map(|n| vec![n])
1041            .unwrap_or_default(),
1042        // A bare word searches where a person would expect it to.
1043        Field::Anything => {
1044            let mut all = vec![track_row.title.clone()];
1045            all.extend(texts_of(&Field::Artist, context, track));
1046            all.extend(texts_of(&Field::Album, context, track));
1047            all
1048        }
1049        _ => Vec::new(),
1050    }
1051}
1052
1053fn push_unique(values: &mut Vec<String>, value: String) {
1054    if !values.iter().any(|existing| existing == &value) {
1055        values.push(value);
1056    }
1057}
1058
1059fn artist_values(catalog: &Catalog, artist_id: Id) -> Vec<String> {
1060    let Some(artist) = catalog.artist(artist_id) else {
1061        return Vec::new();
1062    };
1063    let mut values = vec![artist.name.clone()];
1064    if let Some(mbid) = &artist.mbid {
1065        values.push(mbid.clone());
1066    }
1067    values.extend(artist.aliases.iter().cloned());
1068    values
1069}
1070
1071fn extend_artist_values(catalog: &Catalog, artist_id: Id, values: &mut Vec<String>) {
1072    for value in artist_values(catalog, artist_id) {
1073        push_unique(values, value);
1074    }
1075}
1076
1077fn sourced_credits<'a>(
1078    context: &'a Context<'_>,
1079    track_id: Id,
1080) -> Vec<&'a crate::sources::CreditLink> {
1081    let Some(track) = context.catalog.track(track_id) else {
1082        return Vec::new();
1083    };
1084    context
1085        .sourced
1086        .credits
1087        .get(&track.recording_id)
1088        .into_iter()
1089        .flat_map(|credits| credits.iter())
1090        .chain(
1091            track
1092                .release_id
1093                .and_then(|id| context.sourced.edition_credits.get(&id))
1094                .into_iter()
1095                .flat_map(|credits| credits.iter()),
1096        )
1097        .collect()
1098}
1099
1100fn extend_sourced_credit_values(credit: &crate::sources::CreditLink, values: &mut Vec<String>) {
1101    push_unique(values, credit.artist_name.clone());
1102    push_unique(values, credit.artist_mbid.clone());
1103    if let Some(credited_as) = &credit.credited_as {
1104        push_unique(values, credited_as.clone());
1105    }
1106}
1107
1108fn credits_as_text(
1109    context: &Context,
1110    track: Id,
1111    wanted: impl Fn(&str, &[crate::model::CreditAttribute]) -> bool,
1112) -> Vec<String> {
1113    let catalog = context.catalog;
1114    let mut values = Vec::new();
1115    for credit in local_credits(context, track)
1116        .into_iter()
1117        .filter(|credit| wanted(&credit.role, &credit.attributes))
1118    {
1119        extend_artist_values(catalog, credit.artist_id, &mut values);
1120        if let Some(credited_as) = &credit.credited_as {
1121            push_unique(&mut values, credited_as.clone());
1122        }
1123    }
1124    for credit in sourced_credits(context, track)
1125        .into_iter()
1126        .filter(|credit| wanted(&credit.role, &credit.attributes))
1127    {
1128        extend_sourced_credit_values(credit, &mut values);
1129    }
1130    values
1131}
1132
1133fn participation_values(
1134    field: &Field,
1135    context: &Context,
1136    track: Id,
1137    release: Option<&crate::model::Release>,
1138) -> Vec<String> {
1139    let catalog = context.catalog;
1140    if catalog.track(track).is_none() {
1141        return Vec::new();
1142    }
1143    let performing: Vec<&crate::model::Credit> = context
1144        .local
1145        .credits(context.catalog, EntityKind::Track, track)
1146        .iter()
1147        .copied()
1148        .filter(|credit| crate::model::is_performing_role(&credit.role))
1149        .collect();
1150    let sourced_performing: Vec<&crate::sources::CreditLink> = sourced_credits(context, track)
1151        .into_iter()
1152        .filter(|credit| crate::model::is_performing_role(&credit.role))
1153        .collect();
1154    let mut distinct_performers: BTreeSet<String> = performing
1155        .iter()
1156        .map(|credit| local_artist_key(catalog, credit.artist_id))
1157        .collect();
1158    distinct_performers.extend(
1159        sourced_performing
1160            .iter()
1161            .map(|credit| sourced_artist_key(catalog, credit)),
1162    );
1163    if matches!(field, Field::Contributor) {
1164        let mut values = Vec::new();
1165        for credit in local_credits(context, track)
1166            .into_iter()
1167            .filter(|credit| !crate::model::is_performing_role(&credit.role))
1168        {
1169            extend_artist_values(catalog, credit.artist_id, &mut values);
1170        }
1171        for credit in sourced_credits(context, track)
1172            .into_iter()
1173            .filter(|credit| !crate::model::is_performing_role(&credit.role))
1174        {
1175            extend_sourced_credit_values(credit, &mut values);
1176        }
1177        return values;
1178    }
1179    let Some(release) = release else {
1180        return if matches!(field, Field::Collaborator) && distinct_performers.len() > 1 {
1181            performer_values(catalog, &performing, &sourced_performing)
1182        } else {
1183            Vec::new()
1184        };
1185    };
1186    let mut values = Vec::new();
1187    match field {
1188        Field::Guest => {
1189            if !release.is_compilation {
1190                for credit in performing {
1191                    if release.album_artist_id != Some(credit.artist_id) {
1192                        extend_artist_values(catalog, credit.artist_id, &mut values);
1193                    }
1194                }
1195                for credit in sourced_performing {
1196                    if !is_album_artist(catalog, release, credit) {
1197                        extend_sourced_credit_values(credit, &mut values);
1198                    }
1199                }
1200            }
1201        }
1202        Field::CompilationArtist => {
1203            if release.is_compilation {
1204                for credit in performing {
1205                    if release.album_artist_id != Some(credit.artist_id) {
1206                        extend_artist_values(catalog, credit.artist_id, &mut values);
1207                    }
1208                }
1209                for credit in sourced_performing {
1210                    if !is_album_artist(catalog, release, credit) {
1211                        extend_sourced_credit_values(credit, &mut values);
1212                    }
1213                }
1214            }
1215        }
1216        Field::Contributor => unreachable!("contributors are handled before release lookup"),
1217        Field::Collaborator if distinct_performers.len() > 1 => {
1218            return performer_values(catalog, &performing, &sourced_performing);
1219        }
1220        _ => {}
1221    }
1222    values
1223}
1224
1225fn local_artist_key(catalog: &Catalog, artist_id: Id) -> String {
1226    catalog
1227        .artist(artist_id)
1228        .map(|artist| {
1229            artist
1230                .mbid
1231                .clone()
1232                .unwrap_or_else(|| format!("name:{}", artist.key))
1233        })
1234        .unwrap_or_else(|| format!("local:{artist_id}"))
1235}
1236
1237fn sourced_artist_key(catalog: &Catalog, credit: &crate::sources::CreditLink) -> String {
1238    if !credit.artist_mbid.trim().is_empty() {
1239        return credit.artist_mbid.clone();
1240    }
1241    let credited_key = credit.credited_as.as_deref().map(text::normalize);
1242    if let Some(artist) = catalog.artists.iter().find(|artist| {
1243        artist.key == text::normalize(&credit.artist_name)
1244            || credited_key.as_deref() == Some(artist.key.as_str())
1245    }) {
1246        return local_artist_key(catalog, artist.id);
1247    }
1248    format!("name:{}", text::normalize(&credit.artist_name))
1249}
1250
1251fn is_album_artist(
1252    catalog: &Catalog,
1253    release: &crate::model::Release,
1254    credit: &crate::sources::CreditLink,
1255) -> bool {
1256    let Some(artist) = release.album_artist_id.and_then(|id| catalog.artist(id)) else {
1257        return false;
1258    };
1259    local_artist_key(catalog, artist.id) == sourced_artist_key(catalog, credit)
1260}
1261
1262fn performer_values(
1263    catalog: &Catalog,
1264    local: &[&crate::model::Credit],
1265    sourced: &[&crate::sources::CreditLink],
1266) -> Vec<String> {
1267    let mut values = Vec::new();
1268    let mut seen = BTreeSet::new();
1269    for credit in local {
1270        if seen.insert(credit.artist_id) {
1271            extend_artist_values(catalog, credit.artist_id, &mut values);
1272        }
1273    }
1274    for credit in sourced {
1275        extend_sourced_credit_values(credit, &mut values);
1276    }
1277    values
1278}
1279
1280#[cfg(test)]
1281#[path = "query_tests.rs"]
1282mod tests;