aede_core/query/
browse.rs

1//! Versioned dynamic browsing collections; audio and catalog identities remain unchanged.
2use super::{Context, QueryError, error};
3use crate::model::{Catalog, EntityKind, Id};
4use crate::sources::{Facts, Sources};
5use crate::user::EntityRef;
6use serde_json::Value;
7use std::collections::{BTreeMap, BTreeSet};
8
9/// Validated filters retained in the ordinary saved-query representation.
10#[derive(Debug, Clone, PartialEq)]
11pub struct Filter {
12    view: String,
13    reverse: bool,
14    mode: String,
15    years: [u64; 2],
16    alphabet: [u64; 2],
17    unknown: bool,
18    query: String,
19    countries: Vec<Option<String>>,
20    genres: Vec<String>,
21    ratings: Option<[u64; 2]>,
22    tags: Vec<String>,
23    label: Option<EntityRef>,
24    composers: Vec<EntityRef>,
25}
26/// Parse the bounded JSON payload following `browse-v1:`. Unknown keys are errors.
27pub fn parse(input: &str) -> Result<Filter, QueryError> {
28    if input.len() > 8192 {
29        return Err(error("browse filter exceeds 8192 bytes"));
30    }
31    let v: Value = serde_json::from_str(input).map_err(|_| error("invalid browse filter JSON"))?;
32    let o = v
33        .as_object()
34        .ok_or_else(|| error("browse filter must be an object"))?;
35    if o.keys().any(|k| {
36        ![
37            "view",
38            "reverse",
39            "mode",
40            "years",
41            "alphabet",
42            "includeUnknownYear",
43            "query",
44            "countries",
45            "genre",
46            "genres",
47            "ratings",
48            "tags",
49            "label",
50            "composers",
51        ]
52        .contains(&k.as_str())
53    }) {
54        return Err(error("unknown browse filter field"));
55    }
56    let string = |key: &str| {
57        v[key]
58            .as_str()
59            .map(str::to_owned)
60            .ok_or_else(|| error(format!("invalid browse {key}")))
61    };
62    let interval = |key: &str, max: u64| -> Result<[u64; 2], QueryError> {
63        let values = v[key]
64            .as_array()
65            .filter(|a| a.len() == 2)
66            .ok_or_else(|| error("invalid browse interval"))?;
67        let low = values[0]
68            .as_u64()
69            .ok_or_else(|| error("invalid browse bound"))?;
70        let high = values[1]
71            .as_u64()
72            .filter(|h| *h <= max && *h >= low)
73            .ok_or_else(|| error("invalid browse bound"))?;
74        Ok([low, high])
75    };
76    let view = string("view")?;
77    let mode = string("mode")?;
78    if ![
79        "artists",
80        "tracks",
81        "albums",
82        "compilations",
83        "countries",
84        "genres",
85        "composers",
86        "rated",
87        "favourites",
88        "tags",
89        "label",
90    ]
91    .contains(&view.as_str())
92        || !["none", "years", "alphabet", "both"].contains(&mode.as_str())
93    {
94        return Err(error("invalid browse view or mode"));
95    }
96    let reverse = match v.get("reverse") {
97        None => false,
98        Some(value) if view == "tracks" => value
99            .as_bool()
100            .ok_or_else(|| error("invalid track order"))?,
101        Some(_) => return Err(error("reverse requires the tracks view")),
102    };
103    let label = if view == "label" {
104        Some(
105            EntityRef::parse_token(&string("label")?)
106                .filter(|r| r.kind == EntityKind::Label && r.key.len() <= 4096)
107                .ok_or_else(|| error("invalid browse label reference"))?,
108        )
109    } else {
110        if o.contains_key("label") {
111            return Err(error("label requires the label view"));
112        }
113        None
114    };
115    let composers = if view == "composers" {
116        v["composers"]
117            .as_array()
118            .filter(|rows| rows.len() <= 100)
119            .ok_or_else(|| error("invalid browse composers"))?
120            .iter()
121            .map(|value| {
122                value
123                    .as_str()
124                    .and_then(EntityRef::parse_token)
125                    .filter(|reference| {
126                        reference.kind == EntityKind::Artist && reference.key.len() <= 4096
127                    })
128                    .ok_or_else(|| error("invalid browse composer reference"))
129            })
130            .collect::<Result<Vec<_>, _>>()?
131    } else {
132        if o.contains_key("composers") {
133            return Err(error("composers require the composers view"));
134        }
135        Vec::new()
136    };
137    let ratings = if view == "rated" {
138        let bounds = interval("ratings", 5)?;
139        if bounds[0] == 0 {
140            return Err(error("minimum rating must be at least one"));
141        }
142        Some(bounds)
143    } else {
144        if o.contains_key("ratings") {
145            return Err(error("ratings require the rated view"));
146        }
147        None
148    };
149    let countries = match v.get("countries") {
150        None => Vec::new(),
151        Some(value) => value
152            .as_array()
153            .filter(|a| a.len() <= 256)
154            .ok_or_else(|| error("invalid browse countries"))?
155            .iter()
156            .map(|v| {
157                if v.is_null() {
158                    Ok(None)
159                } else {
160                    v.as_str()
161                        .filter(|s| s.len() <= 256)
162                        .map(|s| Some(s.to_owned()))
163                        .ok_or_else(|| error("invalid browse country"))
164                }
165            })
166            .collect::<Result<_, _>>()?,
167    };
168    let mut genres = match v.get("genres") {
169        None => Vec::new(),
170        Some(value) => value
171            .as_array()
172            .filter(|a| a.len() <= 256)
173            .ok_or_else(|| error("invalid browse genres"))?
174            .iter()
175            .map(|value| {
176                value
177                    .as_str()
178                    .filter(|s| !s.trim().is_empty() && s.len() <= 256)
179                    .map(str::to_owned)
180                    .ok_or_else(|| error("invalid browse genre"))
181            })
182            .collect::<Result<Vec<_>, _>>()?,
183    };
184    let genre = match v.get("genre") {
185        None => None,
186        Some(value) => Some(
187            value
188                .as_str()
189                .filter(|s| s.len() <= 256)
190                .ok_or_else(|| error("invalid browse genre"))?
191                .to_owned(),
192        ),
193    };
194    if let Some(genre) = genre {
195        genres.push(genre);
196    }
197    genres.sort();
198    genres.dedup();
199    let tags = if view == "tags" {
200        if mode != "none" || !countries.is_empty() || !genres.is_empty() {
201            return Err(error(
202                "tag collections require hidden rails and no country/genre criteria",
203            ));
204        }
205        v["tags"]
206            .as_array()
207            .filter(|a| a.len() <= 100)
208            .ok_or_else(|| error("invalid browse tags"))?
209            .iter()
210            .map(|value| {
211                value
212                    .as_str()
213                    .filter(|s| !s.trim().is_empty() && s.len() <= 256 && !s.contains('\0'))
214                    .map(folded)
215                    .ok_or_else(|| error("invalid browse tag"))
216            })
217            .collect::<Result<Vec<_>, _>>()?
218    } else {
219        if o.contains_key("tags") {
220            return Err(error("tags require the tags view"));
221        }
222        Vec::new()
223    };
224    let query = string("query")?;
225    if query.len() > 2048 {
226        return Err(error("browse search exceeds 2048 bytes"));
227    }
228    if view == "tracks" && (!countries.is_empty() || !genres.is_empty()) {
229        return Err(error(
230            "track filters do not support country or genre facets",
231        ));
232    }
233    Ok(Filter {
234        view,
235        reverse,
236        mode,
237        years: interval("years", 9999)?,
238        alphabet: interval("alphabet", 26)?,
239        unknown: v["includeUnknownYear"]
240            .as_bool()
241            .ok_or_else(|| error("invalid unknown-year choice"))?,
242        query,
243        countries,
244        genres,
245        ratings,
246        tags,
247        label,
248        composers,
249    })
250}
251#[derive(Default)]
252pub(super) struct Claims {
253    origins: BTreeMap<Id, Option<String>>,
254    prose: BTreeMap<Id, String>,
255}
256impl Claims {
257    pub(super) fn new(catalog: &Catalog, sources: &Sources) -> Self {
258        let mut claims = Self::default();
259        for artist in &catalog.artists {
260            let Some(reference) = EntityRef::of(catalog, EntityKind::Artist, artist.id) else {
261                continue;
262            };
263            for record in sources
264                .about(&reference)
265                .filter(|r| sources.is_trusted(catalog, r))
266            {
267                if let Facts::Artist(facts) = &record.facts {
268                    if record.source == crate::sources::MUSICBRAINZ {
269                        claims
270                            .origins
271                            .insert(artist.id, facts.country_code.clone().or(facts.area.clone()));
272                    }
273                    if let Some(prose) = &facts.summary {
274                        claims
275                            .prose
276                            .entry(artist.id)
277                            .or_insert_with(|| prose.text.clone());
278                    }
279                }
280            }
281        }
282        claims
283    }
284}
285fn folded(s: &str) -> String {
286    s.trim()
287        .to_lowercase()
288        .chars()
289        .flat_map(crate::text::fold_char)
290        .filter(|c| !('\u{0300}'..='\u{036f}').contains(c))
291        .collect()
292}
293fn initial(s: &str) -> u64 {
294    // Preserve the explicit # bucket for a non-letter first character.
295    let first = s.trim().chars().next().unwrap_or('#');
296    let folded = folded(&first.to_string());
297    match folded.bytes().next() {
298        Some(c @ b'a'..=b'z') => u64::from(c - b'a' + 1),
299        _ => 0,
300    }
301}
302fn alphabet(name: &str, f: &Filter) -> bool {
303    !["alphabet", "both"].contains(&f.mode.as_str())
304        || (f.alphabet[0]..=f.alphabet[1]).contains(&initial(name))
305}
306fn annotation(context: &Context, kind: EntityKind, id: Id) -> String {
307    EntityRef::of(context.catalog, kind, id)
308        .and_then(|r| context.data.find(context.owner, &r))
309        .map(|a| {
310            format!(
311                "{} {} {} {}",
312                a.tags.iter().cloned().collect::<Vec<_>>().join(" "),
313                a.note.as_deref().unwrap_or(""),
314                a.rating.map(|r| r.to_string()).unwrap_or_default(),
315                if a.loved { "favori" } else { "" }
316            )
317        })
318        .unwrap_or_default()
319}
320/// Evaluate once per collection; each matching edition contributes its complete track order.
321pub(super) fn tracks(f: &Filter, context: &Context) -> Vec<Id> {
322    let c = context.catalog;
323    if f.view == "tracks" {
324        return track_selection(f, context);
325    }
326    if f.view == "tags" {
327        return tagged_tracks(f, context);
328    }
329    let label = f.label.as_ref().and_then(|r| r.resolve(c));
330    if f.label.is_some() && label.is_none() {
331        return Vec::new();
332    }
333    let composer_index = (f.view == "composers").then(|| c.composers_by_release());
334    let selected_composers: BTreeSet<_> = f
335        .composers
336        .iter()
337        .filter_map(|reference| reference.resolve(c))
338        .collect();
339    let artist_view = ["artists", "countries", "genres"].contains(&f.view.as_str());
340    let principal: BTreeSet<_> = c
341        .releases
342        .iter()
343        .filter_map(|r| r.album_artist_id)
344        .collect();
345    let mut artist_text = BTreeMap::new();
346    let mut eligible = BTreeSet::new();
347    for artist in &c.artists {
348        let releases: BTreeSet<_> = c
349            .releases_of_artist(artist.id)
350            .into_iter()
351            .chain(
352                c.releases
353                    .iter()
354                    .filter(|r| r.album_artist_id == Some(artist.id))
355                    .map(|r| r.id),
356            )
357            .collect();
358        let genres: BTreeSet<_> = releases
359            .iter()
360            .flat_map(|id| c.genres_of(EntityKind::Release, *id))
361            .map(|g| g.name.as_str())
362            .collect();
363        let country = context
364            .browse_claims
365            .origins
366            .get(&artist.id)
367            .cloned()
368            .flatten();
369        let valid = (!artist_view || (principal.contains(&artist.id) && alphabet(&artist.name, f)))
370            && (f.countries.is_empty() || f.countries.contains(&country))
371            && (f.genres.is_empty() || f.genres.iter().any(|g| genres.contains(g.as_str())));
372        if valid {
373            eligible.insert(artist.id);
374        }
375        artist_text.insert(
376            artist.id,
377            folded(&format!(
378                "{} {} {} {} {} {} {} {}",
379                artist.name,
380                artist.sort_name,
381                artist.mbid.as_deref().unwrap_or(""),
382                artist.aliases.join(" "),
383                genres.into_iter().collect::<Vec<_>>().join(" "),
384                country.unwrap_or_default(),
385                context
386                    .browse_claims
387                    .prose
388                    .get(&artist.id)
389                    .cloned()
390                    .unwrap_or_default(),
391                annotation(context, EntityKind::Artist, artist.id)
392            )),
393        );
394    }
395    let term = folded(&f.query);
396    let mut tracks = Vec::new();
397    for release in &c.releases {
398        if let Some(index) = &composer_index {
399            let Some(credited) = index.get(&release.id) else {
400                continue;
401            };
402            if !f.composers.is_empty() && credited.is_disjoint(&selected_composers) {
403                continue;
404            }
405        }
406        if label.is_some_and(|id| !release.label_ids.contains(&id)) {
407            continue;
408        }
409        if f.view == "rated" || f.view == "favourites" {
410            let own = EntityRef::of(c, EntityKind::Release, release.id)
411                .and_then(|r| context.data.find(context.owner, &r));
412            let principal = release
413                .album_artist_id
414                .and_then(|id| EntityRef::of(c, EntityKind::Artist, id))
415                .and_then(|r| context.data.find(context.owner, &r));
416            let rating = own
417                .and_then(|a| a.rating)
418                .unwrap_or(0)
419                .max(principal.and_then(|a| a.rating).unwrap_or(0));
420            if f.ratings
421                .is_some_and(|r| !(r[0]..=r[1]).contains(&u64::from(rating)))
422                || (f.view == "favourites"
423                    && !own.is_some_and(|a| a.loved)
424                    && !principal.is_some_and(|a| a.loved))
425            {
426                continue;
427            }
428        }
429        if f.view == "compilations" && !release.is_compilation {
430            continue;
431        }
432        if ["years", "both"].contains(&f.mode.as_str())
433            && !release.year.map_or(f.unknown, |y| {
434                (f.years[0]..=f.years[1]).contains(&u64::from(y))
435            })
436        {
437            continue;
438        }
439        if !artist_view && !alphabet(&release.title, f) {
440            continue;
441        }
442        let mut related: BTreeSet<_> = release.album_artist_id.into_iter().collect();
443        related.extend(
444            c.credits_on(EntityKind::Release, release.id)
445                .into_iter()
446                .map(|(a, _)| a.id),
447        );
448        let mut text = format!(
449            "{} {} {} {}",
450            release.title,
451            release.year.map(|y| y.to_string()).unwrap_or_default(),
452            c.genres_of(EntityKind::Release, release.id)
453                .into_iter()
454                .map(|g| g.name.as_str())
455                .collect::<Vec<_>>()
456                .join(" "),
457            annotation(context, EntityKind::Release, release.id)
458        );
459        for id in &release.track_ids {
460            related.extend(
461                c.credits_on(EntityKind::Track, *id)
462                    .into_iter()
463                    .map(|(a, _)| a.id),
464            );
465            if let Some(file) = c.track(*id).and_then(|t| c.file(t.file_id)) {
466                for (key, values) in &file.tags {
467                    text.push_str(&format!(" {key} {}", values.join(" ")));
468                }
469            }
470        }
471        let text = folded(&text);
472        let matches = related.iter().any(|a| {
473            eligible.contains(a)
474                && (term.is_empty()
475                    || text.contains(&term)
476                    || artist_text.get(a).is_some_and(|s| s.contains(&term)))
477        }) || (!artist_view
478            && related.is_empty()
479            && f.countries.is_empty()
480            && f.genres.is_empty()
481            && (term.is_empty() || text.contains(&term)));
482        if matches {
483            tracks.extend(&release.track_ids);
484        }
485    }
486    tracks
487}
488
489fn tagged_tracks(f: &Filter, context: &Context) -> Vec<Id> {
490    let term = folded(&f.query);
491    let matches = |kind, id| {
492        EntityRef::of(context.catalog, kind, id)
493            .and_then(|r| context.data.find(context.owner, &r))
494            .is_some_and(|a| {
495                a.tags.iter().any(|tag| {
496                    let key = folded(tag);
497                    (f.tags.is_empty() || f.tags.contains(&key)) && key.contains(&term)
498                })
499            })
500    };
501    context
502        .catalog
503        .tracks
504        .iter()
505        .filter(|track| {
506            matches(EntityKind::Track, track.id)
507                || track
508                    .release_id
509                    .and_then(|id| context.catalog.release(id))
510                    .is_some_and(|release| {
511                        matches(EntityKind::Release, release.id)
512                            || release
513                                .album_artist_id
514                                .is_some_and(|id| matches(EntityKind::Artist, id))
515                    })
516        })
517        .map(|track| track.id)
518        .collect()
519}
520#[cfg(test)]
521#[path = "browse_tests.rs"]
522mod tests;
523
524// Track selections retain individual membership, including albumless placements.
525fn track_selection(f: &Filter, context: &Context) -> Vec<Id> {
526    let c = context.catalog;
527    let term = folded(&f.query);
528    let has = |value: &str| folded(value).contains(&term);
529    let mut credited = BTreeSet::new();
530    if !term.is_empty() {
531        for credit in &c.credits {
532            if credit.entity_kind == EntityKind::Track
533                && (has(&credit.role)
534                    || c.artist(credit.artist_id)
535                        .is_some_and(|artist| has(&artist.name)))
536            {
537                credited.insert(credit.entity_id);
538            }
539        }
540    }
541    let mut rows: Vec<_> = c
542        .tracks
543        .iter()
544        .filter(|track| {
545            let release = track.release_id.and_then(|id| c.release(id));
546            if !alphabet(&track.title, f)
547                || (["years", "both"].contains(&f.mode.as_str())
548                    && !release.and_then(|r| r.year).map_or(f.unknown, |y| {
549                        (f.years[0]..=f.years[1]).contains(&u64::from(y))
550                    }))
551            {
552                return false;
553            }
554            term.is_empty()
555                || has(&track.title)
556                || credited.contains(&track.id)
557                || release.is_some_and(|release| {
558                    has(&release.title)
559                        || release
560                            .album_artist_id
561                            .and_then(|id| c.artist(id))
562                            .is_some_and(|artist| has(&artist.name))
563                })
564                || c.file(track.file_id).is_some_and(|file| {
565                    file.tags
566                        .iter()
567                        .any(|(key, values)| has(key) || values.iter().any(|value| has(value)))
568                })
569        })
570        .map(|track| track.id)
571        .collect();
572    if f.reverse {
573        rows.reverse();
574    }
575    rows
576}