aede_core/parental/
visibility.rs

1//! Build one compact, derived graph; public identities remain key/path based.
2use super::remap::{Maps, compact};
3use crate::{
4    model::{Catalog, EntityKind},
5    user::EntityRef,
6};
7use std::collections::BTreeSet;
8
9/// Whether an exact public identity resolves inside an already filtered catalogue.
10pub fn allows_reference(visible: &Catalog, token: &str) -> bool {
11    EntityRef::parse_token(token)
12        .filter(|reference| !reference.key.is_empty())
13        .and_then(|reference| reference.resolve(visible))
14        .is_some()
15}
16/// Derive a catalogue with restricted artists and all their credited audio removed.
17/// The original is never mutated or persisted. Dense identifiers are rebuilt only
18/// inside this view, while every surviving public key, path and MBID stays intact.
19pub fn filtered_catalog(catalog: &Catalog, blocked_artists: &[String]) -> Result<Catalog, String> {
20    let mut maps = Maps::new(catalog)?;
21    let mut blocked = BTreeSet::new();
22    for token in blocked_artists {
23        let reference = EntityRef::parse_token(token).ok_or("invalid blocked artist reference")?;
24        if reference.kind != EntityKind::Artist || reference.key.is_empty() {
25            return Err("invalid blocked artist reference".into());
26        }
27        if let Some(id) = reference.resolve(catalog) {
28            blocked.insert(id);
29        }
30    }
31    if blocked.is_empty() {
32        return Ok(catalog.clone());
33    }
34    let mut tracks = vec![false; catalog.tracks.len()];
35    let mut releases = catalog
36        .releases
37        .iter()
38        .map(|release| {
39            release
40                .album_artist_id
41                .is_some_and(|id| blocked.contains(&id))
42        })
43        .collect::<Vec<_>>();
44    let mut recordings = vec![false; catalog.recordings.len()];
45    let mut works = vec![false; catalog.works.len()];
46    for credit in &catalog.credits {
47        if !blocked.contains(&credit.artist_id) {
48            continue;
49        }
50        let table = match credit.entity_kind {
51            EntityKind::Track => &mut tracks,
52            EntityKind::Release => &mut releases,
53            EntityKind::Recording => &mut recordings,
54            EntityKind::Work => &mut works,
55            _ => continue,
56        };
57        *table
58            .get_mut(credit.entity_id as usize)
59            .ok_or("invalid catalogue credit in parental projection")? = true;
60    }
61    for recording in &catalog.recordings {
62        if recording
63            .work_ids
64            .iter()
65            .any(|id| works.get(*id as usize).copied().unwrap_or(true))
66        {
67            *recordings
68                .get_mut(recording.id as usize)
69                .ok_or("invalid catalogue recording")? = true;
70        }
71    }
72    // The same recording placed on a compilation is still the same audio.
73    // Removing all of its placements also keeps local recording fallback keys stable.
74    for track in &catalog.tracks {
75        let denied = tracks
76            .get(track.id as usize)
77            .copied()
78            .ok_or("invalid catalogue track")?
79            || track
80                .release_id
81                .is_some_and(|id| releases.get(id as usize).copied().unwrap_or(true));
82        if denied {
83            *recordings
84                .get_mut(track.recording_id as usize)
85                .ok_or("invalid catalogue recording")? = true;
86        }
87    }
88    for track in &catalog.tracks {
89        tracks[track.id as usize] = recordings
90            .get(track.recording_id as usize)
91            .copied()
92            .unwrap_or(true);
93    }
94    for track in catalog
95        .tracks
96        .iter()
97        .filter(|track| !tracks[track.id as usize])
98    {
99        maps.tracks.keep(track.id)?;
100        maps.files.keep(track.file_id)?;
101        maps.recordings.keep(track.recording_id)?;
102        if let Some(release) = track.release_id {
103            maps.releases.keep(release)?;
104        }
105    }
106    for recording in catalog
107        .recordings
108        .iter()
109        .filter(|row| maps.recordings.has(row.id))
110    {
111        for id in &recording.work_ids {
112            maps.works.keep(*id)?;
113        }
114    }
115    for release in catalog
116        .releases
117        .iter()
118        .filter(|row| maps.releases.has(row.id))
119    {
120        if let Some(id) = release.album_artist_id {
121            maps.artists.keep(id)?;
122        }
123        if let Some(id) = release.release_group_id {
124            maps.release_groups.keep(id)?;
125        }
126        for id in &release.label_ids {
127            maps.labels.keep(*id)?;
128        }
129    }
130    for credit in &catalog.credits {
131        if !blocked.contains(&credit.artist_id) && maps.kept(credit.entity_kind, credit.entity_id) {
132            maps.artists.keep(credit.artist_id)?;
133        }
134    }
135    for link in &catalog.genre_links {
136        if maps.kept(link.entity_kind, link.entity_id) {
137            maps.genres.keep(link.genre_id)?;
138        }
139    }
140    maps.finish()?;
141    compact(catalog, &maps)
142}
143#[cfg(test)]
144#[path = "visibility_tests.rs"]
145mod tests;