aede_core/
user_identity.rs

1//! Evidence-backed relocation and compatibility with old album references.
2
3use super::*;
4use crate::json::Json;
5use crate::model::AudioFile;
6
7/// Last observed identity evidence for a file named by personal data.
8///
9/// A basename alone never proves a move. Size, stream characteristics and
10/// identifying tags must agree, as must an acoustic fingerprint when one was
11/// available. This is a conservative relocation heuristic, not a byte checksum.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct TrackIdentity {
14    /// Path at which the evidence was observed.
15    pub path: String,
16    /// File size in bytes.
17    pub size: u64,
18    /// Codec declared by the stream.
19    pub codec: String,
20    /// Duration measured from the stream.
21    pub duration_ms: Option<u64>,
22    /// Sampling frequency in hertz.
23    pub sample_rate: Option<u32>,
24    /// Stream precision when meaningful.
25    pub bit_depth: Option<u16>,
26    /// Number of channels.
27    pub channels: Option<u16>,
28    /// Identifying tags, without notes, lyrics or other large text fields.
29    pub metadata: BTreeMap<String, Vec<String>>,
30    /// Acoustic fingerprint, when already known without reading the file.
31    pub fingerprint: Option<String>,
32}
33
34impl TrackIdentity {
35    fn of(file: &AudioFile) -> Self {
36        let metadata = [
37            "title",
38            "artist",
39            "artists",
40            "albumartist",
41            "albumartists",
42            "album",
43            "tracknumber",
44            "discnumber",
45            "musicbrainz_recordingid",
46            "musicbrainz_albumid",
47            "isrc",
48        ]
49        .into_iter()
50        .filter_map(|key| {
51            file.tags
52                .get(key)
53                .map(|values| (key.into(), values.clone()))
54        })
55        .collect();
56        Self {
57            path: file.path.clone(),
58            size: file.size,
59            codec: file.properties.codec.clone(),
60            duration_ms: file.properties.duration_ms,
61            sample_rate: file.properties.sample_rate,
62            bit_depth: file.properties.bit_depth,
63            channels: file.properties.channels,
64            metadata,
65            fingerprint: file.fingerprint.as_ref().map(|fp| fp.data.clone()),
66        }
67    }
68
69    fn matches(&self, file: &AudioFile) -> bool {
70        let current = Self::of(file);
71        self.size == current.size
72            && self.codec == current.codec
73            && self.duration_ms == current.duration_ms
74            && self.sample_rate == current.sample_rate
75            && self.bit_depth == current.bit_depth
76            && self.channels == current.channels
77            && self.metadata == current.metadata
78            && self
79                .fingerprint
80                .as_ref()
81                .is_none_or(|fp| current.fingerprint.as_ref() == Some(fp))
82    }
83
84    pub(super) fn to_json(&self) -> Json {
85        let mut row = Json::obj();
86        row.set("path", self.path.as_str().into());
87        row.set("size", self.size.into());
88        row.set("codec", self.codec.as_str().into());
89        if let Some(value) = self.duration_ms {
90            row.set("duration_ms", value.into());
91        }
92        if let Some(value) = self.sample_rate {
93            row.set("sample_rate", value.into());
94        }
95        if let Some(value) = self.bit_depth {
96            row.set("bit_depth", u32::from(value).into());
97        }
98        if let Some(value) = self.channels {
99            row.set("channels", u32::from(value).into());
100        }
101        if let Some(value) = &self.fingerprint {
102            row.set("fingerprint", value.as_str().into());
103        }
104        let mut metadata = Json::obj();
105        for (key, values) in &self.metadata {
106            metadata.set(
107                key,
108                Json::Arr(values.iter().map(|value| value.as_str().into()).collect()),
109            );
110        }
111        row.set("metadata", metadata);
112        row
113    }
114
115    pub(super) fn from_json(row: &Json) -> Option<Self> {
116        let Json::Obj(fields) = row.get("metadata")? else {
117            return None;
118        };
119        let metadata = fields
120            .iter()
121            .map(|(key, values)| {
122                Some((
123                    key.clone(),
124                    values
125                        .as_arr()?
126                        .iter()
127                        .map(Json::as_string)
128                        .collect::<Option<Vec<_>>>()?,
129                ))
130            })
131            .collect::<Option<BTreeMap<_, _>>>()?;
132        Some(Self {
133            path: row.field_str("path")?,
134            size: row.field_u64("size")?,
135            codec: row.field_str("codec")?,
136            duration_ms: row.field_u64("duration_ms"),
137            sample_rate: row.field_u32("sample_rate"),
138            bit_depth: row.field_u32("bit_depth").and_then(|n| n.try_into().ok()),
139            channels: row.field_u32("channels").and_then(|n| n.try_into().ok()),
140            metadata,
141            fingerprint: row.field_str("fingerprint"),
142        })
143    }
144}
145
146/// Counts personal records attached, waiting, or relocated during reconciliation.
147#[derive(Debug, Clone, Copy, Default, PartialEq)]
148pub struct Attachment {
149    /// Records whose target is in the catalog.
150    pub attached: usize,
151    /// Records whose target is absent and retained unchanged.
152    pub waiting: usize,
153    /// Records whose reference was migrated or whose file moved.
154    pub moved: usize,
155}
156
157pub(super) fn resolve_release(reference: &EntityRef, catalog: &Catalog) -> Option<Id> {
158    if let Some(release) = catalog.releases.iter().find(|release| {
159        EntityRef::of(catalog, EntityKind::Release, release.id).as_ref() == Some(reference)
160    }) {
161        return Some(release.id);
162    }
163    let (prefix, folder) = reference.key.rsplit_once(RELEASE_KEY_SEPARATOR)?;
164    let legacy_parent = text::disc_folder(text::file_name(folder)).map(|_| text::folder(folder));
165    let mut matches = catalog.releases.iter().filter(|release| {
166        EntityRef::of(catalog, EntityKind::Release, release.id).is_some_and(|current| {
167            let Some((current_prefix, current_folder)) =
168                current.key.rsplit_once(RELEASE_KEY_SEPARATOR)
169            else {
170                return false;
171            };
172            current_prefix == prefix
173                && (current_folder == folder || legacy_parent == Some(current_folder))
174        })
175    });
176    let found = matches.next()?;
177    matches.next().is_none().then_some(found.id)
178}
179
180/// Resolution for a reconciliation pass, including artist MBIDs and legacy recording aliases.
181/// Building once keeps a referenced library from being walked for every record.
182struct ReferenceIndex {
183    current: BTreeMap<EntityRef, (Id, usize)>,
184}
185
186impl ReferenceIndex {
187    fn new(catalog: &Catalog, kinds: BTreeSet<EntityKind>) -> Self {
188        let mut current: BTreeMap<EntityRef, (Id, usize)> = BTreeMap::new();
189        for kind in kinds {
190            let ids: Vec<_> = match kind {
191                EntityKind::Track => catalog.tracks.iter().map(|row| row.id).collect(),
192                EntityKind::Release => catalog.releases.iter().map(|row| row.id).collect(),
193                EntityKind::Artist => catalog.artists.iter().map(|row| row.id).collect(),
194                EntityKind::Recording => catalog.recordings.iter().map(|row| row.id).collect(),
195                EntityKind::Work => catalog.works.iter().map(|row| row.id).collect(),
196                EntityKind::ReleaseGroup => {
197                    catalog.release_groups.iter().map(|row| row.id).collect()
198                }
199                EntityKind::Label => catalog.labels.iter().map(|row| row.id).collect(),
200                EntityKind::Genre => catalog.genres.iter().map(|row| row.id).collect(),
201            };
202            for id in ids {
203                if let Some(reference) = EntityRef::of(catalog, kind, id) {
204                    current
205                        .entry(reference)
206                        .and_modify(|value| value.1 += 1)
207                        .or_insert((id, 1));
208                }
209            }
210            if kind == EntityKind::Artist {
211                let mut by_mbid = BTreeMap::<&str, (Id, usize)>::new();
212                for artist in &catalog.artists {
213                    if let Some(mbid) = artist.mbid.as_deref() {
214                        by_mbid
215                            .entry(mbid)
216                            .and_modify(|row| row.1 += 1)
217                            .or_insert((artist.id, 1));
218                    }
219                }
220                for (mbid, (id, count)) in by_mbid {
221                    if count == 1 {
222                        // Match EntityRef::resolve: exact keys win, and a
223                        // fallback MBID must identify precisely one artist.
224                        current
225                            .entry(EntityRef::new(kind, format!("mbid:{mbid}")))
226                            .or_insert((id, 1));
227                    }
228                }
229            }
230            if kind == EntityKind::Recording {
231                for recording in &catalog.recordings {
232                    for track in &recording.track_ids {
233                        if let Some(file) = catalog
234                            .track(*track)
235                            .and_then(|track| catalog.file(track.file_id))
236                        {
237                            current
238                                .entry(EntityRef::new(kind, format!("local:{}", file.path)))
239                                .or_insert((recording.id, 1));
240                        }
241                    }
242                }
243            }
244        }
245        Self { current }
246    }
247
248    fn resolve(&self, target: &EntityRef) -> Option<Id> {
249        if let Some((id, _)) = self.current.get(target) {
250            return Some(*id);
251        }
252        if target.kind != EntityKind::Release {
253            return None;
254        }
255        let (prefix, folder) = target.key.rsplit_once(RELEASE_KEY_SEPARATOR)?;
256        text::disc_folder(text::file_name(folder))?;
257        let parent = EntityRef::new(
258            EntityKind::Release,
259            format!("{prefix}{RELEASE_KEY_SEPARATOR}{}", text::folder(folder)),
260        );
261        self.current
262            .get(&parent)
263            .and_then(|(id, count)| (*count == 1).then_some(*id))
264    }
265}
266
267/// Reconciles personal references without guessing from a filename alone.
268///
269/// Existing references capture optional file evidence. Missing tracks move only
270/// to one matching basename, size, metadata and stream identity, with no personal
271/// data already at the destination for that owner. Legacy references lacking
272/// evidence remain waiting. Album references using a former disc folder migrate
273/// to the release's canonical folder only when their resolution is unambiguous.
274/// Artist MBID aliases follow the same unique-identity rule as direct lookup;
275/// canonical keys and conflicting personal records retain their precedence.
276/// No annotation, play or counter is deleted.
277pub fn reconcile(data: &mut UserData, catalog: &Catalog) -> Attachment {
278    let mut report = Attachment::default();
279    let mut files_by_name: BTreeMap<&str, Vec<&AudioFile>> = BTreeMap::new();
280    for file in &catalog.files {
281        files_by_name
282            .entry(file.file_name())
283            .or_default()
284            .push(file);
285    }
286    let identities: BTreeMap<_, _> = data
287        .track_identities
288        .iter()
289        .map(|identity| (identity.path.as_str(), identity))
290        .collect();
291    let existing: BTreeSet<_> = data
292        .annotations
293        .iter()
294        .map(|a| (a.owner.clone(), a.target.clone()))
295        .chain(
296            data.plays
297                .iter()
298                .map(|p| (p.owner.clone(), p.track.clone())),
299        )
300        .chain(
301            data.counts
302                .iter()
303                .map(|c| (c.owner.clone(), c.track.clone())),
304        )
305        .chain(
306            data.scrobbles
307                .iter()
308                .map(|p| (p.owner.clone(), p.track.clone())),
309        )
310        .chain(data.playlists.iter().flat_map(|p| {
311            p.tracks
312                .iter()
313                .map(|track| (p.owner.clone(), track.clone()))
314        }))
315        .chain(data.relation_annotations.iter().flat_map(|a| {
316            [
317                (a.owner.clone(), a.relation.source.clone()),
318                (a.owner.clone(), a.relation.target.clone()),
319            ]
320        }))
321        .collect();
322    let references = ReferenceIndex::new(
323        catalog,
324        existing.iter().map(|(_, target)| target.kind).collect(),
325    );
326    let undone: BTreeSet<_> = data
327        .relinks
328        .iter()
329        .filter(|event| event.undone_at.is_some())
330        .map(|event| (&event.owner, &event.from))
331        .collect();
332    let mut moves = BTreeMap::new();
333    for (owner, target) in &existing {
334        let next = if let Some(id) = references.resolve(target) {
335            EntityRef::of(catalog, target.kind, id)
336        } else if target.kind == EntityKind::Track {
337            if undone.contains(&(owner, target)) {
338                continue;
339            }
340            identities.get(target.key.as_str()).and_then(|identity| {
341                let mut matches = files_by_name
342                    .get(text::file_name(&target.key))?
343                    .iter()
344                    .filter(|file| identity.matches(file));
345                let candidate = matches.next()?;
346                matches
347                    .next()
348                    .is_none()
349                    .then(|| EntityRef::new(EntityKind::Track, &candidate.path))
350            })
351        } else {
352            None
353        };
354        if let Some(next) = next
355            && next != *target
356            && !existing.contains(&(owner.clone(), next.clone()))
357        {
358            moves.insert((owner.clone(), target.clone()), next);
359        }
360    }
361    // Two missing references converging on one file are just as ambiguous as
362    // two candidate files. Do not manufacture duplicate owner/target records.
363    let mut destinations = BTreeMap::new();
364    for ((owner, _), to) in &moves {
365        *destinations
366            .entry((owner.clone(), to.clone()))
367            .or_insert(0usize) += 1;
368    }
369    moves.retain(|(owner, _), to| destinations.get(&(owner.clone(), to.clone())) == Some(&1));
370    for (owner, target) in data
371        .annotations
372        .iter_mut()
373        .map(|a| (&a.owner, &mut a.target))
374        .chain(data.plays.iter_mut().map(|p| (&p.owner, &mut p.track)))
375        .chain(data.counts.iter_mut().map(|c| (&c.owner, &mut c.track)))
376        .chain(data.scrobbles.iter_mut().map(|p| (&p.owner, &mut p.track)))
377        .chain(
378            data.playlists
379                .iter_mut()
380                .flat_map(|p| p.tracks.iter_mut().map(|track| (&p.owner, track))),
381        )
382    {
383        if let Some(next) = moves.get(&(owner.clone(), target.clone())) {
384            *target = next.clone();
385            report.moved += 1;
386        }
387        if references.resolve(target).is_some() {
388            report.attached += 1;
389        } else {
390            report.waiting += 1;
391        }
392    }
393    for annotation in &mut data.relation_annotations {
394        for endpoint in [
395            &mut annotation.relation.source,
396            &mut annotation.relation.target,
397        ] {
398            if let Some(next) = moves.get(&(annotation.owner.clone(), endpoint.clone())) {
399                *endpoint = next.clone();
400            }
401        }
402    }
403    let paths: BTreeSet<_> = data
404        .annotations
405        .iter()
406        .map(|a| &a.target)
407        .chain(data.plays.iter().map(|p| &p.track))
408        .chain(data.counts.iter().map(|c| &c.track))
409        .chain(data.scrobbles.iter().map(|p| &p.track))
410        .chain(data.playlists.iter().flat_map(|p| &p.tracks))
411        .chain(
412            data.relation_annotations
413                .iter()
414                .flat_map(|a| [&a.relation.source, &a.relation.target]),
415        )
416        .filter(|target| target.kind == EntityKind::Track)
417        .map(|target| target.key.as_str())
418        .collect();
419    let mut identities: BTreeMap<_, _> = std::mem::take(&mut data.track_identities)
420        .into_iter()
421        .map(|identity| (identity.path.clone(), identity))
422        .collect();
423    for file in catalog
424        .files
425        .iter()
426        .filter(|file| paths.contains(file.path.as_str()))
427    {
428        identities.insert(file.path.clone(), TrackIdentity::of(file));
429    }
430    let relink_paths: BTreeSet<_> = data
431        .relinks
432        .iter()
433        .filter(|event| event.from.kind == EntityKind::Track)
434        .flat_map(|event| [&event.from.key, &event.to.key])
435        .collect();
436    data.track_identities = identities
437        .into_values()
438        .filter(|identity| {
439            paths.contains(identity.path.as_str()) || relink_paths.contains(&identity.path)
440        })
441        .collect();
442    report
443}
444
445#[cfg(test)]
446#[path = "user_identity_tests.rs"]
447mod tests;