aede_core/
user.rs

1//! What the user writes: favourites, ratings, notes, tags, listening history.
2//!
3//! This is the only data in the program that cannot be recovered. Lose the
4//! catalog and a scan rebuilds it in a minute; lose this and it is gone. Two
5//! consequences run through the whole module.
6//!
7//! **It lives in its own file**, never in the catalog. The catalog is derived
8//! from the disk and written whole; a rating that changes on a keystroke has no
9//! business rewriting a library.
10//!
11//! **It is never keyed by a catalog identifier.** Those are positions in a
12//! vector that every scan renumbers — the lesson the imported analyses already
13//! taught, at the cost of a rewrite. A [`EntityRef`] names a thing the way the
14//! thing names itself, and an annotation whose target is missing is **kept
15//! waiting, never dropped**: a folder renamed between two scans must not cost
16//! anybody a note they wrote.
17//!
18//! Favourites, ratings, notes and tags look like four features and are one:
19//! something a person said about an entity. One record per target, so copying
20//! everything said about one album onto another is a record copy rather than a
21//! loop over four kinds.
22
23use std::collections::{BTreeMap, BTreeSet};
24
25use crate::model::{Catalog, EntityKind, Id};
26use crate::text;
27
28#[path = "user_history.rs"]
29mod history;
30#[path = "user_identity.rs"]
31mod identity;
32#[path = "user_preferences.rs"]
33mod preferences;
34pub use preferences::{ClientPreference, valid_preference};
35#[path = "user_playback.rs"]
36mod playback;
37#[path = "user_playlists.rs"]
38mod playlists;
39#[path = "user_relink.rs"]
40mod relink;
41pub use identity::{Attachment, TrackIdentity, reconcile};
42pub use playback::{
43    PLAYBACK_ENTRY_LIMIT, PLAYBACK_PROFILE_LIMIT, PLAYBACK_STATE_LIMIT, PlaybackEntry,
44    PlaybackSettings, PlaybackSource, PlaybackState, valid_playback_profile,
45};
46pub use playlists::{
47    PLAYLIST_LIMIT, PLAYLIST_OWNER_LIMIT, PLAYLIST_TRACK_LIMIT, Playlist, Scrobble,
48};
49pub use relink::{Relink, RelinkSummary};
50
51/// Version of the user file on disk, independent of the catalog's.
52///
53/// The two change for different reasons and at different times, so they are
54/// counted separately. Reading a newer file must fail loudly rather than lose
55/// what it did not understand.
56pub const USER_FORMAT_VERSION: u32 = 2;
57
58/// Name of the user file inside the data folder.
59pub const USER_FILE: &str = "user.json";
60
61/// Whose opinion it is.
62///
63/// Every personal table is scoped by a stable owner from its first version.
64/// Account sessions use immutable account IDs; the CLI and first administrator
65/// retain [`LOCAL_USER`]. A mutable login name is never a personal owner.
66pub type UserRef = String;
67
68/// The owner of a library nobody has named yet.
69pub const LOCAL_USER: &str = aede_accounts::LOCAL_OWNER;
70
71/// What separates the three parts of a release key.
72pub const RELEASE_KEY_SEPARATOR: char = '|';
73
74/// A stable way to name an entity, independent of any scan.
75///
76/// The key is what the thing calls itself rather than where it currently sits:
77/// a path for a track, the release key for an album, the normalized name for
78/// name-based entities. Recordings, works and release groups use an explicit
79/// MusicBrainz identifier when the catalog has one. Source-only artists and
80/// canonical credit exclusions may also use `mbid:<identifier>`.
81#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
82pub struct EntityRef {
83    /// Which kind of thing is named.
84    pub kind: EntityKind,
85    /// Its stable key, in the spelling [`EntityRef`] builds.
86    pub key: String,
87}
88
89impl EntityRef {
90    /// Builds a reference by hand, for a key already in the right shape.
91    pub fn new(kind: EntityKind, key: impl Into<String>) -> EntityRef {
92        EntityRef {
93            kind,
94            key: key.into(),
95        }
96    }
97
98    /// How the reference is written in one token: `track:/music/a.flac`.
99    ///
100    /// The kind comes first and the key may itself contain colons — a path
101    /// does — so the split is on the **first** one only.
102    pub fn to_token(&self) -> String {
103        format!("{}:{}", self.kind.as_str(), self.key)
104    }
105
106    /// Reads back what [`EntityRef::to_token`] wrote.
107    pub fn parse_token(text: &str) -> Option<EntityRef> {
108        let (kind, key) = text.split_once(':')?;
109        Some(EntityRef {
110            kind: EntityKind::parse_kind(kind)?,
111            key: key.to_string(),
112        })
113    }
114
115    /// The reference naming an entity of a catalog, if that entity exists.
116    pub fn of(catalog: &Catalog, kind: EntityKind, id: Id) -> Option<EntityRef> {
117        let key = match kind {
118            // The path is what the file calls itself, and it survives every
119            // rescan that leaves the file where it is.
120            EntityKind::Track => catalog
121                .track(id)
122                .and_then(|t| catalog.file(t.file_id))
123                .map(|f| f.path.clone())?,
124            // The same three parts the release is built on, so a release
125            // recognised as itself by a scan is recognised here too.
126            EntityKind::Release => {
127                let release = catalog.release(id)?;
128                let artist = release
129                    .album_artist_id
130                    .and_then(|a| catalog.artist(a))
131                    .map(|a| a.key.clone())
132                    .unwrap_or_default();
133                let folder = &release.folder;
134                format!(
135                    "{artist}{RELEASE_KEY_SEPARATOR}{}{RELEASE_KEY_SEPARATOR}{folder}",
136                    text::normalize(&release.title)
137                )
138            }
139            EntityKind::Artist => catalog.artist(id).map(|a| a.key.clone())?,
140            EntityKind::Recording => {
141                let recording = catalog.recording(id)?;
142                recording.mbid.clone().or_else(|| {
143                    recording
144                        .track_ids
145                        .first()
146                        .and_then(|track| catalog.track(*track))
147                        .and_then(|track| catalog.file(track.file_id))
148                        .map(|file| format!("local:{}", file.path))
149                })?
150            }
151            EntityKind::Work => catalog.work(id).map(|w| w.mbid.clone())?,
152            EntityKind::ReleaseGroup => catalog.release_group(id).map(|g| g.mbid.clone())?,
153            EntityKind::Label => catalog.label(id).map(|l| l.key.clone())?,
154            EntityKind::Genre => catalog.genres.get(id as usize).map(|g| g.key.clone())?,
155        };
156        Some(EntityRef { kind, key })
157    }
158
159    /// The entity a reference names in this catalog, if it holds one.
160    pub fn resolve(&self, catalog: &Catalog) -> Option<Id> {
161        match self.kind {
162            EntityKind::Track => catalog
163                .tracks
164                .iter()
165                .find(|t| catalog.file(t.file_id).is_some_and(|f| f.path == self.key))
166                .map(|t| t.id),
167            EntityKind::Release => identity::resolve_release(self, catalog),
168            EntityKind::Artist => catalog
169                .artists
170                .iter()
171                .find(|a| a.key == self.key)
172                .or_else(|| {
173                    let mbid = self.key.strip_prefix("mbid:")?;
174                    let mut matched = catalog
175                        .artists
176                        .iter()
177                        .filter(|artist| artist.mbid.as_deref() == Some(mbid));
178                    let artist = matched.next()?;
179                    matched.next().is_none().then_some(artist)
180                })
181                .map(|a| a.id),
182            EntityKind::Recording => catalog
183                .recordings
184                .iter()
185                .find(|recording| {
186                    recording.mbid.as_deref() == Some(&self.key)
187                        || self.key.strip_prefix("local:").is_some_and(|path| {
188                            recording.track_ids.iter().any(|track| {
189                                catalog
190                                    .track(*track)
191                                    .and_then(|track| catalog.file(track.file_id))
192                                    .is_some_and(|file| file.path == path)
193                            })
194                        })
195                })
196                .map(|recording| recording.id),
197            EntityKind::Work => catalog
198                .works
199                .iter()
200                .find(|work| work.mbid == self.key)
201                .map(|work| work.id),
202            EntityKind::ReleaseGroup => catalog
203                .release_groups
204                .iter()
205                .find(|group| group.mbid == self.key)
206                .map(|group| group.id),
207            EntityKind::Label => catalog
208                .labels
209                .iter()
210                .find(|l| l.key == self.key)
211                .map(|l| l.id),
212            EntityKind::Genre => catalog
213                .genres
214                .iter()
215                .find(|g| g.key == self.key)
216                .map(|g| g.id),
217        }
218    }
219
220    /// How the thing is called on screen, or the key when it is gone.
221    pub fn display_name(&self, catalog: &Catalog) -> String {
222        let named = self.resolve(catalog).and_then(|id| match self.kind {
223            EntityKind::Track => catalog.track(id).map(|t| t.title.clone()),
224            EntityKind::Release => catalog.release(id).map(|r| r.title.clone()),
225            EntityKind::Artist => catalog.artist(id).map(|a| a.name.clone()),
226            EntityKind::Recording => catalog.recording(id).map(|r| r.title.clone()),
227            EntityKind::Work => catalog.work(id).map(|w| w.title.clone()),
228            EntityKind::ReleaseGroup => catalog.release_group(id).map(|g| g.title.clone()),
229            EntityKind::Label => catalog.label(id).map(|l| l.name.clone()),
230            EntityKind::Genre => catalog.genres.get(id as usize).map(|g| g.name.clone()),
231        });
232        named.unwrap_or_else(|| match self.kind {
233            // A path is unreadable in a column; its file name is not.
234            EntityKind::Track => text::file_name(&self.key).to_string(),
235            _ => self.key.clone(),
236        })
237    }
238}
239
240enum RelationAnnotationMatch {
241    Absent,
242    Unique(usize),
243    Ambiguous,
244}
245
246pub(crate) fn same_annotation_relation(
247    catalog: &Catalog,
248    held: &crate::graph::RelationRef,
249    current: &crate::graph::RelationRef,
250) -> bool {
251    held == current
252        || (current.kind.starts_with("credit:")
253            && crate::graph::same_credit_relation(catalog, held, current))
254}
255
256/// Everything one person said about one entity.
257///
258/// One record per target rather than one per fact: a favourite, a rating, a
259/// note and a set of tags are four ways of having an opinion, not four
260/// features, and keeping them together is what makes copying what was said
261/// about an album onto another album a single operation.
262#[derive(Debug, Clone, Default, PartialEq)]
263pub struct Annotation {
264    /// Whose opinion it is.
265    pub owner: UserRef,
266    /// What it is about.
267    pub target: EntityRef,
268    /// A favourite.
269    pub loved: bool,
270    /// One to five stars, or nothing said.
271    pub rating: Option<u8>,
272    /// Free text, as long as the user cares to make it.
273    pub note: Option<String>,
274    /// Free labels: "to rip again", "vinyl", "for the car".
275    pub tags: BTreeSet<String>,
276    /// When it was first written (Unix epoch, seconds).
277    pub created_at: u64,
278    /// When it was last touched.
279    pub updated_at: u64,
280}
281
282impl Default for EntityRef {
283    fn default() -> EntityRef {
284        EntityRef {
285            kind: EntityKind::Track,
286            key: String::new(),
287        }
288    }
289}
290
291impl Annotation {
292    /// `true` when the record says nothing at all and can be dropped.
293    ///
294    /// Removing the last thing said about an album must remove the record, not
295    /// leave an empty one that a listing would then have to filter out — and
296    /// that an export would carry for ever.
297    pub fn is_empty(&self) -> bool {
298        // A note of `Some("")` says nothing, the same as `None` — the
299        // difference between the two is an implementation detail (typed
300        // empty text, an empty `--file`, a round trip through export and
301        // import) that nobody looking at this record should have to know
302        // about. Treating it as content would keep a record alive, and
303        // therefore visible, for having said nothing at all.
304        !self.loved
305            && self.rating.is_none()
306            && self.note.as_deref().is_none_or(|n| n.trim().is_empty())
307            && self.tags.is_empty()
308    }
309}
310
311/// A personal note and free-form labels attached to one relationship.
312///
313/// Relations are not entities: the opinion belongs to the edge itself, not to
314/// either endpoint. Keeping it in the user layer also preserves the invariant
315/// that the shared catalog contains facts while each person's notes remain
316/// private.
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct RelationAnnotation {
319    /// Whose opinion it is.
320    pub owner: UserRef,
321    /// Stable relationship being described.
322    pub relation: crate::graph::RelationRef,
323    /// Free text about this exact relationship.
324    pub note: Option<String>,
325    /// Personal labels such as `dubious`, `favourite collaboration`…
326    pub tags: BTreeSet<String>,
327    /// When the annotation was first written.
328    pub created_at: u64,
329    /// When it was last changed.
330    pub updated_at: u64,
331}
332
333impl RelationAnnotation {
334    /// Whether the annotation says nothing and can be removed.
335    pub fn is_empty(&self) -> bool {
336        self.note
337            .as_deref()
338            .is_none_or(|note| note.trim().is_empty())
339            && self.tags.is_empty()
340    }
341}
342
343/// One listening event.
344///
345/// An annotation is a statement; a play is an event, and events accumulate.
346#[derive(Debug, Clone, PartialEq)]
347pub struct Play {
348    /// Who listened.
349    pub owner: UserRef,
350    /// What was played.
351    pub track: EntityRef,
352    /// When it started (Unix epoch, seconds).
353    pub at: u64,
354    /// How much of it was actually heard.
355    pub ms_played: u64,
356    /// Whether it played to the end.
357    ///
358    /// A track skipped after eight seconds is evidence *against* it, and a
359    /// history that cannot tell a skip from a listen measures the wrong thing.
360    pub completed: bool,
361}
362
363/// How often one person played one track, for as long as the library lasts.
364///
365/// Deliberately not derived from the log: the log is bounded so that the file
366/// stays small, and "what have I never heard" cannot be answered from a
367/// truncated one. The bounded events and all-time totals answer different
368/// questions without implying a history-weighted shuffle policy.
369#[derive(Debug, Clone, Default, PartialEq)]
370pub struct PlayCount {
371    /// Whose count it is.
372    pub owner: UserRef,
373    /// Which track.
374    pub track: EntityRef,
375    /// All-time native listens and client-declared scrobbles combined.
376    pub count: u32,
377    /// All-time completed Play events; duration-less scrobbles never increment this.
378    pub completed_count: u32,
379    /// When it was last played.
380    pub last_played: u64,
381}
382
383/// A query saved under a name: a smart collection.
384///
385/// It holds the expression rather than the result, so it answers with what the
386/// library holds *now* — which is the whole difference between a smart
387/// collection and a playlist. And since running one produces a selection, and a
388/// selection is what `--csv`, `--m3u` and M3's queue consume, a saved query is
389/// playable the day it is written, with nothing built for it.
390#[derive(Debug, Clone, PartialEq)]
391pub struct Collection {
392    /// Whose collection it is.
393    pub owner: UserRef,
394    /// What it is called, as typed.
395    pub name: String,
396    /// The expression, kept as written rather than as parsed: a grammar that
397    /// gains a field must not silently change what an old collection means.
398    pub expression: String,
399    /// When it was first saved.
400    pub created_at: u64,
401    /// When it was last changed.
402    pub updated_at: u64,
403}
404
405/// Events kept per owner in each native-play or scrobble journal before that
406/// owner's oldest events are forgotten.
407///
408/// The log answers "what did I listen to last night"; the counters answer
409/// everything that needs all of history. Bounding the first keeps the file
410/// small without costing the second anything.
411pub const HISTORY_LIMIT: usize = 500;
412/// Global journal capacity, independent of per-owner retention. Publication
413/// refuses this bound rather than evicting another owner's records.
414pub const HISTORY_STORE_LIMIT: usize = HISTORY_LIMIT * aede_accounts::MAX_ACCOUNTS;
415/// Maximum UTF-8 bytes read from or published to the personal store.
416pub const USER_MAX_BYTES: usize = 64 * 1024 * 1024;
417
418/// A record the user has taken off a report, and why that is allowed.
419///
420/// **The one thing in this file that is not keyed on an entity of the
421/// catalog.** Everything else here describes something the library holds; this
422/// describes something it deliberately does *not*. `aede missing` lists albums a
423/// source credits to an artist and the shelf lacks, and sometimes the source is
424/// simply wrong about what an album is — a demo, a compilation and a single all
425/// arrive typed `Album` until somebody says otherwise on MusicBrainz.
426///
427/// Aède will not overrule a source: the whole attributed layer exists so that
428/// what somebody else said stays what they said, correctable only by them. But
429/// it will record that **you** disagree, which is what this file has always
430/// been for, and stop putting the record in front of you.
431#[derive(Debug, Clone, PartialEq, Eq)]
432pub struct SetAside {
433    /// Whose decision it is.
434    pub owner: UserRef,
435    /// The MusicBrainz release-group identifier, which is the key.
436    ///
437    /// Globally unique, so no artist is needed to tell two apart, and stable
438    /// across a re-fetch and a rescan — where a title is neither.
439    pub release_group: String,
440    /// The title as it read when it was set aside.
441    ///
442    /// Kept so a listing can show something a person recognises. Without it the
443    /// list is a column of identifiers, and a decision nobody can read is one
444    /// nobody can undo.
445    pub title: String,
446    /// When it was set aside.
447    pub created_at: u64,
448}
449
450/// Two spellings the owner of the disk says are one musician.
451///
452/// The second half of artist identity, and the half no authority can settle.
453/// `MUSICBRAINZ_ARTISTID` answers for the files that met Picard; for an old
454/// rip, a download, a friend's drive, **nobody on earth knows that a particular
455/// `O. Osbourne` is Ozzy except the person whose shelf it is**. Comparing the
456/// strings cannot stand in for them — a fragment match merges Angus Young with
457/// Neil Young — so the program asks instead of guessing, and keeps the answer
458/// here, with everything else that was said rather than derived.
459///
460/// **Both keys are normalised**, because that is the form the merge is decided
461/// on everywhere else in the program: a statement about `Ozzy  Osbourne` must
462/// still be about `ozzy osbourne` on the next scan. They are readable as they
463/// stand — unlike a [`SetAside`], whose key is an identifier and which
464/// therefore has to carry a title beside it.
465///
466/// Nothing in an audio file changes, ever. This is a statement about how the
467/// shelf is *read*, and `aede merge --forget` takes it back.
468#[derive(Debug, Clone, PartialEq, Eq)]
469pub struct SameArtist {
470    /// Whose statement it is.
471    pub owner: UserRef,
472    /// The spelling that gives way, normalised.
473    pub spelling: String,
474    /// The spelling it is filed under, normalised.
475    pub filed_as: String,
476    /// When it was said.
477    pub created_at: u64,
478}
479
480/// Everything the user wrote, as it sits on disk.
481#[derive(Debug, Clone, Default)]
482pub struct UserData {
483    /// Optional owner-private application preferences; retained across scans and exports.
484    pub client_preferences: Vec<ClientPreference>,
485    /// One record per owner and target.
486    pub annotations: Vec<Annotation>,
487    /// Notes and labels attached to graph relationships.
488    pub relation_annotations: Vec<RelationAnnotation>,
489    /// The most recent plays, oldest first, bounded by [`HISTORY_LIMIT`].
490    pub plays: Vec<Play>,
491    /// All-time counts, which never forget.
492    pub counts: Vec<PlayCount>,
493    /// Saved queries, by name.
494    pub collections: Vec<Collection>,
495    /// Private, ordered static playlists, including repeated/missing tracks.
496    pub playlists: Vec<Playlist>,
497    /// Client-declared listens with unknown duration/completion, oldest first.
498    pub scrobbles: Vec<Scrobble>,
499    /// Optional named native-player checkpoints, private to their immutable owner.
500    pub playback_states: Vec<PlaybackState>,
501    /// Records taken off the `missing` report — see [`SetAside`].
502    pub set_aside: Vec<SetAside>,
503    /// Spellings the owner says are one artist — see [`SameArtist`].
504    pub same_artist: Vec<SameArtist>,
505    /// Last observed evidence for referenced files, used for safe relocation.
506    pub track_identities: Vec<TrackIdentity>,
507    /// Explicit owner-scoped reattachments, retained for conflict-safe undo.
508    pub relinks: Vec<Relink>,
509}
510
511impl UserData {
512    /// The record for one owner and target, if there is one.
513    pub fn find(&self, owner: &str, target: &EntityRef) -> Option<&Annotation> {
514        self.annotations
515            .iter()
516            .find(|a| a.owner == owner && &a.target == target)
517    }
518
519    /// The record for one owner and target, opening one if needed.
520    ///
521    /// `now` stamps a record that did not exist; an existing one keeps its
522    /// creation date and has its `updated_at` moved by the caller.
523    pub fn entry(&mut self, owner: &str, target: &EntityRef, now: u64) -> &mut Annotation {
524        let found = self
525            .annotations
526            .iter()
527            .position(|a| a.owner == owner && &a.target == target);
528        let at = match found {
529            Some(at) => at,
530            None => {
531                self.annotations.push(Annotation {
532                    owner: owner.to_string(),
533                    target: target.clone(),
534                    created_at: now,
535                    updated_at: now,
536                    ..Default::default()
537                });
538                self.annotations.len() - 1
539            }
540        };
541        &mut self.annotations[at]
542    }
543
544    /// Drops the records that no longer say anything.
545    ///
546    /// Called after every change, so that un-loving the one album somebody ever
547    /// loved leaves nothing behind.
548    pub fn forget_empty(&mut self) {
549        self.annotations.retain(|a| !a.is_empty());
550        self.relation_annotations.retain(|a| !a.is_empty());
551    }
552
553    /// Personal annotation for one relation, if one exists.
554    pub fn find_relation(
555        &self,
556        owner: &str,
557        relation: &crate::graph::RelationRef,
558    ) -> Option<&RelationAnnotation> {
559        self.relation_annotations
560            .iter()
561            .find(|annotation| annotation.owner == owner && &annotation.relation == relation)
562    }
563
564    /// Finds one owner’s credit context across exact catalog identity aliases.
565    ///
566    /// An exact reference wins. A unique equivalent credit is usable when the
567    /// catalog can resolve both endpoints; ambiguous aliases select nothing.
568    /// Other relationship kinds retain exact-reference behavior.
569    pub fn find_relation_in(
570        &self,
571        catalog: &Catalog,
572        owner: &str,
573        relation: &crate::graph::RelationRef,
574    ) -> Option<&RelationAnnotation> {
575        match self.relation_annotation_match(catalog, owner, relation) {
576            RelationAnnotationMatch::Unique(index) => self.relation_annotations.get(index),
577            RelationAnnotationMatch::Absent | RelationAnnotationMatch::Ambiguous => None,
578        }
579    }
580
581    /// Opens existing credit context without combining historical annotations.
582    /// A new credit keeps its known artist MBID when local tags later disappear.
583    pub fn relation_entry_in(
584        &mut self,
585        catalog: &Catalog,
586        owner: &str,
587        relation: &crate::graph::RelationRef,
588        now: u64,
589    ) -> Result<&mut RelationAnnotation, String> {
590        match self.relation_annotation_match(catalog, owner, relation) {
591            RelationAnnotationMatch::Unique(index) => Ok(&mut self.relation_annotations[index]),
592            RelationAnnotationMatch::Ambiguous => Err(
593                "several saved annotations match this credit; use their exact saved references"
594                    .into(),
595            ),
596            RelationAnnotationMatch::Absent => {
597                let reference = if relation.kind.starts_with("credit:") {
598                    crate::graph::canonical_credit_relation(catalog, relation.clone())
599                } else {
600                    relation.clone()
601                };
602                Ok(self.relation_entry(owner, &reference, now))
603            }
604        }
605    }
606
607    /// Removes one exact or uniquely equivalent owner annotation.
608    /// Ambiguous history is refused without deleting or merging any row.
609    pub fn remove_relation_in(
610        &mut self,
611        catalog: &Catalog,
612        owner: &str,
613        relation: &crate::graph::RelationRef,
614    ) -> Result<bool, String> {
615        match self.relation_annotation_match(catalog, owner, relation) {
616            RelationAnnotationMatch::Unique(index) => {
617                self.relation_annotations.remove(index);
618                Ok(true)
619            }
620            RelationAnnotationMatch::Absent => Ok(false),
621            RelationAnnotationMatch::Ambiguous => Err(
622                "several saved annotations match this credit; use their exact saved references"
623                    .into(),
624            ),
625        }
626    }
627
628    fn relation_annotation_match(
629        &self,
630        catalog: &Catalog,
631        owner: &str,
632        relation: &crate::graph::RelationRef,
633    ) -> RelationAnnotationMatch {
634        if let Some(index) = self
635            .relation_annotations
636            .iter()
637            .position(|annotation| annotation.owner == owner && &annotation.relation == relation)
638        {
639            return RelationAnnotationMatch::Unique(index);
640        }
641        if !relation.kind.starts_with("credit:") {
642            return RelationAnnotationMatch::Absent;
643        }
644        let mut matching =
645            self.relation_annotations
646                .iter()
647                .enumerate()
648                .filter(|(_, annotation)| {
649                    annotation.owner == owner
650                        && same_annotation_relation(catalog, &annotation.relation, relation)
651                });
652        match (matching.next(), matching.next()) {
653            (None, _) => RelationAnnotationMatch::Absent,
654            (Some((index, _)), None) => RelationAnnotationMatch::Unique(index),
655            (Some(_), Some(_)) => RelationAnnotationMatch::Ambiguous,
656        }
657    }
658
659    /// Opens the personal annotation for one relation.
660    pub fn relation_entry(
661        &mut self,
662        owner: &str,
663        relation: &crate::graph::RelationRef,
664        now: u64,
665    ) -> &mut RelationAnnotation {
666        let found = self
667            .relation_annotations
668            .iter()
669            .position(|annotation| annotation.owner == owner && &annotation.relation == relation);
670        let index = match found {
671            Some(index) => index,
672            None => {
673                self.relation_annotations.push(RelationAnnotation {
674                    owner: owner.to_string(),
675                    relation: relation.clone(),
676                    note: None,
677                    tags: BTreeSet::new(),
678                    created_at: now,
679                    updated_at: now,
680                });
681                self.relation_annotations.len() - 1
682            }
683        };
684        &mut self.relation_annotations[index]
685    }
686
687    /// Copies everything said about one target onto another.
688    ///
689    /// Returns `false` when there was nothing to copy — which is worth telling
690    /// the user, since a silent success there means they typed the wrong name
691    /// and will not find out until they look.
692    pub fn copy(&mut self, owner: &str, from: &EntityRef, to: &EntityRef, now: u64) -> bool {
693        let Some(source) = self.find(owner, from).cloned() else {
694            return false;
695        };
696        let target = self.entry(owner, to, now);
697        target.loved = source.loved;
698        target.rating = source.rating;
699        target.note = source.note.clone();
700        target.tags = source.tags;
701        target.updated_at = now;
702        true
703    }
704
705    /// Takes back the most recent play of a track, log and counter together.
706    ///
707    /// The counter is not a summary of the log — the log is bounded at
708    /// [`HISTORY_LIMIT`] and the counter is not — so a removal that touched one
709    /// and not the other would leave a track played "three times" with two
710    /// plays behind it, and nothing on screen to say which was right. They are
711    /// written together by [`UserData::record_play`] and they are taken back
712    /// together here.
713    ///
714    /// Returns `false` when there was nothing to take back, which the caller
715    /// says rather than reporting a removal that did not happen.
716    pub fn forget_last_play(&mut self, owner: &str, track: &EntityRef) -> bool {
717        let Some(index) = self
718            .plays
719            .iter()
720            .rposition(|p| p.owner == owner && &p.track == track)
721        else {
722            // The log may have rolled the play off its front while the counter
723            // still holds it. Decrementing on a play nobody can point at would
724            // be guessing, so it is refused.
725            return false;
726        };
727        let completed = self.plays.remove(index).completed;
728        if let Some(counter) = self
729            .counts
730            .iter_mut()
731            .find(|c| c.owner == owner && &c.track == track)
732        {
733            counter.count = counter.count.saturating_sub(1);
734            counter.completed_count = counter.completed_count.saturating_sub(u32::from(completed));
735            // The last-played date now belongs to whatever play is newest of
736            // those left, and to nothing at all when none is.
737            counter.last_played = self
738                .plays
739                .iter()
740                .filter(|p| p.owner == owner && &p.track == track)
741                .map(|p| p.at)
742                .chain(
743                    self.scrobbles
744                        .iter()
745                        .filter(|p| p.owner == owner && &p.track == track)
746                        .map(|p| p.at_ms / 1000),
747                )
748                .max()
749                .unwrap_or(0);
750        }
751        self.counts
752            .retain(|c| c.count > 0 || c.owner != owner || &c.track != track);
753        true
754    }
755
756    /// Forgets everything an owner ever played.
757    ///
758    /// Native events, declared scrobbles and counters are cleared together.
759    /// Returns how many events/declarations and how many counters went,
760    /// because "your history is cleared" is a claim nobody
761    /// can check and a number is.
762    pub fn forget_history(&mut self, owner: &str) -> (usize, usize) {
763        let plays = self.plays.iter().filter(|p| p.owner == owner).count()
764            + self.scrobbles.iter().filter(|p| p.owner == owner).count();
765        let counts = self.counts.iter().filter(|c| c.owner == owner).count();
766        self.plays.retain(|p| p.owner != owner);
767        self.scrobbles.retain(|p| p.owner != owner);
768        self.counts.retain(|c| c.owner != owner);
769        (plays, counts)
770    }
771
772    /// Records one listen: the bounded log and the all-time counter together.
773    ///
774    /// Both, always — see [`UserData::forget_last_play`] for why the two must
775    /// never be written apart.
776    pub fn record_play(&mut self, play: Play) {
777        match self
778            .counts
779            .iter_mut()
780            .find(|c| c.owner == play.owner && c.track == play.track)
781        {
782            Some(counter) => {
783                counter.count = counter.count.saturating_add(1);
784                counter.completed_count = counter
785                    .completed_count
786                    .saturating_add(u32::from(play.completed));
787                counter.last_played = counter.last_played.max(play.at);
788            }
789            None => self.counts.push(PlayCount {
790                owner: play.owner.clone(),
791                track: play.track.clone(),
792                count: 1,
793                completed_count: u32::from(play.completed),
794                last_played: play.at,
795            }),
796        }
797        self.plays.push(play);
798        // Offline clients can submit older listens after newer ones. Stable
799        // ordering keeps equal-time events in arrival order without deduping.
800        self.plays.sort_by_key(|play| play.at);
801        // Oldest first, so the excess falls off the front.
802        history::retain(&mut self.plays, |play| &play.owner);
803    }
804
805    /// A saved query by name, compared without regard to case or accents:
806    /// somebody typing `aede collection Metal` means the one they saved as
807    /// "metal".
808    pub fn collection(&self, owner: &str, name: &str) -> Option<&Collection> {
809        let wanted = text::normalize(name);
810        self.collections
811            .iter()
812            .find(|c| c.owner == owner && text::normalize(&c.name) == wanted)
813    }
814
815    /// Saves a query under a name, replacing one of the same name.
816    ///
817    /// Returns `true` when a collection was replaced, which the caller says out
818    /// loud: overwriting somebody's saved query in silence is how they find out
819    /// a week later.
820    pub fn save_collection(&mut self, owner: &str, name: &str, expression: &str, now: u64) -> bool {
821        let wanted = text::normalize(name);
822        if let Some(existing) = self
823            .collections
824            .iter_mut()
825            .find(|c| c.owner == owner && text::normalize(&c.name) == wanted)
826        {
827            existing.expression = expression.to_string();
828            existing.updated_at = now;
829            return true;
830        }
831        self.collections.push(Collection {
832            owner: owner.to_string(),
833            name: name.to_string(),
834            expression: expression.to_string(),
835            created_at: now,
836            updated_at: now,
837        });
838        false
839    }
840
841    /// Drops a saved query; `false` when there was none by that name.
842    pub fn forget_collection(&mut self, owner: &str, name: &str) -> bool {
843        let wanted = text::normalize(name);
844        let before = self.collections.len();
845        self.collections
846            .retain(|c| !(c.owner == owner && text::normalize(&c.name) == wanted));
847        self.collections.len() != before
848    }
849
850    /// How many times one person played a track, all-time.
851    pub fn play_count(&self, owner: &str, track: &EntityRef) -> u32 {
852        self.counts
853            .iter()
854            .find(|c| c.owner == owner && &c.track == track)
855            .map(|c| c.count)
856            .unwrap_or(0)
857    }
858}
859
860// --------------------------------------------------------------------------
861// On disk
862// --------------------------------------------------------------------------
863
864/// Where the user file sits inside a data folder.
865pub fn user_path(data_dir: &std::path::Path) -> std::path::PathBuf {
866    data_dir.join(USER_FILE)
867}
868
869/// Saves what the user wrote, atomically.
870///
871/// Written **pretty**, unlike the catalog. The catalog is machine output that
872/// nobody reads; this file is the one a user may want to open, grep, or repair
873/// by hand after a bad restore, and it is small enough for that to cost
874/// nothing.
875/// An exclusive temporary output prevents an existing temporary symlink from
876/// being followed; a symlink at the final path is refused.
877pub fn save(data: &UserData, path: &std::path::Path) -> Result<(), crate::store::StoreError> {
878    preferences::validate(&data.client_preferences)?;
879    history::validate(data)?;
880    playlists::validate(data)?;
881    playback::validate(data)?;
882    let text = to_json(data)
883        .to_string_pretty_bounded(USER_MAX_BYTES)
884        .map_err(|_| crate::store::StoreError::Invalid("personal store byte limit exceeded"))?;
885    crate::atomic_file::write(path, text.as_bytes())?;
886    Ok(())
887}
888
889/// Loads what the user wrote; `Ok(None)` when nothing has been written yet.
890pub fn load(path: &std::path::Path) -> Result<Option<UserData>, crate::store::StoreError> {
891    use std::io::Read;
892    let file = match std::fs::File::open(path) {
893        Ok(file) => file,
894        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
895        Err(error) => return Err(error.into()),
896    };
897    let mut bytes = Vec::new();
898    file.take(USER_MAX_BYTES as u64 + 1)
899        .read_to_end(&mut bytes)?;
900    if bytes.len() > USER_MAX_BYTES {
901        return Err(crate::store::StoreError::Invalid(
902            "personal store byte limit exceeded",
903        ));
904    }
905    let text = String::from_utf8(bytes).map_err(|failure| {
906        std::io::Error::new(std::io::ErrorKind::InvalidData, failure.utf8_error())
907    })?;
908    let value = crate::json::parse(&text).map_err(crate::store::StoreError::Parse)?;
909    from_json(&value).map(Some)
910}
911
912/// The document, one array per table.
913pub fn to_json(data: &UserData) -> crate::json::Json {
914    use crate::json::Json;
915    let mut root = Json::obj();
916    root.set("format_version", USER_FORMAT_VERSION.into());
917
918    let annotations: Vec<Json> = data
919        .annotations
920        .iter()
921        .map(|a| {
922            let mut o = Json::obj();
923            o.set("owner", a.owner.as_str().into());
924            o.set("target", a.target.to_token().into());
925            if a.loved {
926                o.set("loved", Json::Bool(true));
927            }
928            if let Some(rating) = a.rating {
929                o.set("rating", u32::from(rating).into());
930            }
931            // A blank note — `Some("")`, or whitespace only — says nothing
932            // `None` did not already say, and writing the key anyway would
933            // hand a reader of this file (or the next `--import` of it) the
934            // exact record `forget_empty` exists to drop on the way in.
935            if let Some(note) = a.note.as_deref().filter(|n| !n.trim().is_empty()) {
936                o.set("note", note.into());
937            }
938            if !a.tags.is_empty() {
939                o.set(
940                    "tags",
941                    Json::Arr(a.tags.iter().map(|t| t.as_str().into()).collect()),
942                );
943            }
944            o.set("created_at", a.created_at.into());
945            o.set("updated_at", a.updated_at.into());
946            o
947        })
948        .collect();
949    root.set("annotations", Json::Arr(annotations));
950
951    let relation_annotations: Vec<Json> = data
952        .relation_annotations
953        .iter()
954        .map(|annotation| {
955            let mut row = Json::obj();
956            row.set("owner", annotation.owner.as_str().into());
957            row.set("source", annotation.relation.source.to_token().into());
958            row.set("relation", annotation.relation.kind.as_str().into());
959            row.set("target", annotation.relation.target.to_token().into());
960            row.set("provenance", annotation.relation.provenance.as_str().into());
961            if let Some(source_id) = &annotation.relation.source_id {
962                row.set("source_id", source_id.as_str().into());
963            }
964            if let Some(note) = annotation
965                .note
966                .as_deref()
967                .filter(|note| !note.trim().is_empty())
968            {
969                row.set("note", note.into());
970            }
971            if !annotation.tags.is_empty() {
972                row.set(
973                    "tags",
974                    Json::Arr(
975                        annotation
976                            .tags
977                            .iter()
978                            .map(|tag| tag.as_str().into())
979                            .collect(),
980                    ),
981                );
982            }
983            row.set("created_at", annotation.created_at.into());
984            row.set("updated_at", annotation.updated_at.into());
985            row
986        })
987        .collect();
988    root.set("relation_annotations", Json::Arr(relation_annotations));
989
990    let plays: Vec<Json> = data
991        .plays
992        .iter()
993        .map(|p| {
994            let mut o = Json::obj();
995            o.set("owner", p.owner.as_str().into());
996            o.set("track", p.track.to_token().into());
997            o.set("at", p.at.into());
998            o.set("ms_played", p.ms_played.into());
999            o.set("completed", Json::Bool(p.completed));
1000            o
1001        })
1002        .collect();
1003    root.set("plays", Json::Arr(plays));
1004
1005    let counts: Vec<Json> = data
1006        .counts
1007        .iter()
1008        .map(|c| {
1009            let mut o = Json::obj();
1010            o.set("owner", c.owner.as_str().into());
1011            o.set("track", c.track.to_token().into());
1012            o.set("count", c.count.into());
1013            o.set("completed_count", c.completed_count.into());
1014            o.set("last_played", c.last_played.into());
1015            o
1016        })
1017        .collect();
1018    root.set("counts", Json::Arr(counts));
1019
1020    let collections: Vec<Json> = data
1021        .collections
1022        .iter()
1023        .map(|c| {
1024            let mut o = Json::obj();
1025            o.set("owner", c.owner.as_str().into());
1026            o.set("name", c.name.as_str().into());
1027            o.set("query", c.expression.as_str().into());
1028            o.set("created_at", c.created_at.into());
1029            o.set("updated_at", c.updated_at.into());
1030            o
1031        })
1032        .collect();
1033    root.set("collections", Json::Arr(collections));
1034    playlists::write_tables(data, &mut root);
1035    playback::write_table(data, &mut root);
1036    preferences::write(data, &mut root);
1037
1038    let set_aside: Vec<Json> = data
1039        .set_aside
1040        .iter()
1041        .map(|a| {
1042            let mut o = Json::obj();
1043            o.set("owner", a.owner.as_str().into());
1044            o.set("release_group", a.release_group.as_str().into());
1045            o.set("title", a.title.as_str().into());
1046            o.set("created_at", a.created_at.into());
1047            o
1048        })
1049        .collect();
1050    root.set("set_aside", Json::Arr(set_aside));
1051
1052    let same_artist: Vec<Json> = data
1053        .same_artist
1054        .iter()
1055        .map(|m| {
1056            let mut o = Json::obj();
1057            o.set("owner", m.owner.as_str().into());
1058            o.set("spelling", m.spelling.as_str().into());
1059            o.set("filed_as", m.filed_as.as_str().into());
1060            o.set("created_at", m.created_at.into());
1061            o
1062        })
1063        .collect();
1064    root.set("same_artist", Json::Arr(same_artist));
1065    root.set(
1066        "track_identities",
1067        Json::Arr(
1068            data.track_identities
1069                .iter()
1070                .map(TrackIdentity::to_json)
1071                .collect(),
1072        ),
1073    );
1074    root.set(
1075        "relinks",
1076        Json::Arr(data.relinks.iter().map(Relink::to_json).collect()),
1077    );
1078    root
1079}
1080
1081/// Reads the document back.
1082///
1083/// A legacy row whose target cannot be parsed is **skipped rather than fatal**: one
1084/// corrupted line must not cost the user every note they ever wrote, which is
1085/// the opposite of the catalog's rule, where a broken row means the graph does
1086/// not hold together and refusing is the safe answer. Here there is no graph —
1087/// only statements, each standing alone.
1088/// Missing optional tables remain compatible with old stores. A present table
1089/// of the wrong JSON type is refused before a later write can empty it.
1090/// New static-playlist and scrobble rows are strictly validated: malformed or
1091/// oversized rows fail the load, preserving the original file for recovery.
1092pub fn from_json(value: &crate::json::Json) -> Result<UserData, crate::store::StoreError> {
1093    value
1094        .check_pretty_size(USER_MAX_BYTES)
1095        .map_err(|_| crate::store::StoreError::Invalid("personal store byte limit exceeded"))?;
1096    use crate::store::StoreError;
1097    let found = value.field_u32("format_version").unwrap_or(0);
1098    if !matches!(found, 1 | USER_FORMAT_VERSION) {
1099        return Err(StoreError::Version {
1100            found,
1101            expected: USER_FORMAT_VERSION,
1102        });
1103    }
1104    for (table, message) in [
1105        ("annotations", "user annotations must be an array"),
1106        (
1107            "relation_annotations",
1108            "user relation annotations must be an array",
1109        ),
1110        ("plays", "user plays must be an array"),
1111        ("counts", "user counts must be an array"),
1112        ("collections", "user collections must be an array"),
1113        ("playlists", "user playlists must be an array"),
1114        ("scrobbles", "user scrobbles must be an array"),
1115        ("playback_states", "user playback states must be an array"),
1116        ("set_aside", "user set-aside decisions must be an array"),
1117        ("same_artist", "user artist decisions must be an array"),
1118        ("track_identities", "user track identities must be an array"),
1119        ("relinks", "user relink history must be an array"),
1120    ] {
1121        if value
1122            .get(table)
1123            .is_some_and(|value| value.as_arr().is_none())
1124        {
1125            return Err(StoreError::Invalid(message));
1126        }
1127    }
1128    let mut data = UserData {
1129        client_preferences: preferences::read(value)?,
1130        playlists: playlists::read_playlists(value)?,
1131        scrobbles: playlists::read_scrobbles(value)?,
1132        playback_states: playback::read_states(value)?,
1133        track_identities: value
1134            .get("track_identities")
1135            .and_then(crate::json::Json::as_arr)
1136            .unwrap_or(&[])
1137            .iter()
1138            .filter_map(TrackIdentity::from_json)
1139            .collect(),
1140        relinks: value
1141            .get("relinks")
1142            .and_then(crate::json::Json::as_arr)
1143            .unwrap_or(&[])
1144            .iter()
1145            .filter_map(Relink::from_json)
1146            .collect(),
1147        ..Default::default()
1148    };
1149
1150    for row in value
1151        .get("annotations")
1152        .and_then(|v| v.as_arr())
1153        .unwrap_or(&[])
1154    {
1155        let Some(target) = row
1156            .field_str("target")
1157            .and_then(|t| EntityRef::parse_token(&t))
1158        else {
1159            continue;
1160        };
1161        data.annotations.push(Annotation {
1162            owner: row.field_str("owner").unwrap_or_else(|| LOCAL_USER.into()),
1163            target,
1164            loved: row.field_bool("loved"),
1165            rating: row.field_u32("rating").map(|r| r.clamp(1, 5) as u8),
1166            note: row.field_str("note"),
1167            tags: row
1168                .get("tags")
1169                .and_then(|v| v.as_arr())
1170                .map(|list| list.iter().filter_map(|t| t.as_string()).collect())
1171                .unwrap_or_default(),
1172            created_at: row.field_u64("created_at").unwrap_or(0),
1173            updated_at: row.field_u64("updated_at").unwrap_or(0),
1174        });
1175    }
1176
1177    for row in value
1178        .get("relation_annotations")
1179        .and_then(|value| value.as_arr())
1180        .unwrap_or(&[])
1181    {
1182        let (Some(source), Some(kind), Some(target), Some(provenance)) = (
1183            row.field_str("source")
1184                .and_then(|value| EntityRef::parse_token(&value)),
1185            row.field_str("relation"),
1186            row.field_str("target")
1187                .and_then(|value| EntityRef::parse_token(&value)),
1188            row.field_str("provenance"),
1189        ) else {
1190            continue;
1191        };
1192        let annotation = RelationAnnotation {
1193            owner: row.field_str("owner").unwrap_or_else(|| LOCAL_USER.into()),
1194            relation: crate::graph::RelationRef {
1195                source,
1196                kind,
1197                target,
1198                provenance,
1199                source_id: row.field_str("source_id"),
1200            },
1201            note: row.field_str("note"),
1202            tags: row
1203                .get("tags")
1204                .and_then(|value| value.as_arr())
1205                .map(|values| {
1206                    values
1207                        .iter()
1208                        .filter_map(|value| value.as_string())
1209                        .collect()
1210                })
1211                .unwrap_or_default(),
1212            created_at: row.field_u64("created_at").unwrap_or(0),
1213            updated_at: row.field_u64("updated_at").unwrap_or(0),
1214        };
1215        if !annotation.is_empty() {
1216            data.relation_annotations.push(annotation);
1217        }
1218    }
1219
1220    for row in value.get("plays").and_then(|v| v.as_arr()).unwrap_or(&[]) {
1221        let Some(track) = row
1222            .field_str("track")
1223            .and_then(|t| EntityRef::parse_token(&t))
1224        else {
1225            continue;
1226        };
1227        data.plays.push(Play {
1228            owner: row.field_str("owner").unwrap_or_else(|| LOCAL_USER.into()),
1229            track,
1230            at: row.field_u64("at").unwrap_or(0),
1231            ms_played: row.field_u64("ms_played").unwrap_or(0),
1232            completed: row.field_bool("completed"),
1233        });
1234    }
1235
1236    // Legacy recent evidence can be recovered, but an evicted unknown visit cannot be guessed.
1237    let mut legacy_completed = BTreeMap::<(&str, &EntityRef), u32>::new();
1238    for play in data.plays.iter().filter(|play| play.completed) {
1239        let count = legacy_completed
1240            .entry((&play.owner, &play.track))
1241            .or_default();
1242        *count = count.saturating_add(1);
1243    }
1244    for row in value.get("counts").and_then(|v| v.as_arr()).unwrap_or(&[]) {
1245        let Some(track) = row
1246            .field_str("track")
1247            .and_then(|t| EntityRef::parse_token(&t))
1248        else {
1249            continue;
1250        };
1251        let owner = row.field_str("owner").unwrap_or_else(|| LOCAL_USER.into());
1252        let completed_count = match row.get("completed_count") {
1253            Some(value) => value.as_u32().ok_or(crate::store::StoreError::Invalid(
1254                "invalid completed listening count",
1255            ))?,
1256            None => legacy_completed
1257                .get(&(owner.as_str(), &track))
1258                .copied()
1259                .unwrap_or(0)
1260                .min(row.field_u32("count").unwrap_or(0)),
1261        };
1262        data.counts.push(PlayCount {
1263            owner,
1264            track,
1265            count: row.field_u32("count").unwrap_or(0),
1266            completed_count,
1267            last_played: row.field_u64("last_played").unwrap_or(0),
1268        });
1269    }
1270    for row in value
1271        .get("collections")
1272        .and_then(|v| v.as_arr())
1273        .unwrap_or(&[])
1274    {
1275        let (Some(name), Some(expression)) = (row.field_str("name"), row.field_str("query")) else {
1276            continue;
1277        };
1278        data.collections.push(Collection {
1279            owner: row.field_str("owner").unwrap_or_else(|| LOCAL_USER.into()),
1280            name,
1281            expression,
1282            created_at: row.field_u64("created_at").unwrap_or(0),
1283            updated_at: row.field_u64("updated_at").unwrap_or(0),
1284        });
1285    }
1286    for row in value
1287        .get("set_aside")
1288        .and_then(crate::json::Json::as_arr)
1289        .unwrap_or(&[])
1290    {
1291        // No identifier, no decision: the title alone cannot say which record
1292        // was meant, and a wish list quietly shortened by one is worse than
1293        // one item too long.
1294        let Some(release_group) = row.field_str("release_group") else {
1295            continue;
1296        };
1297        data.set_aside.push(SetAside {
1298            owner: row
1299                .field_str("owner")
1300                .unwrap_or_else(|| LOCAL_USER.to_string()),
1301            release_group,
1302            title: row.field_str("title").unwrap_or_default(),
1303            created_at: row.field_u64("created_at").unwrap_or(0),
1304        });
1305    }
1306    for row in value
1307        .get("same_artist")
1308        .and_then(crate::json::Json::as_arr)
1309        .unwrap_or(&[])
1310    {
1311        // Half a statement is not a statement: a row naming one spelling says
1312        // nothing about which artist it gives way to, and filing it under the
1313        // empty key would collect every broken row in the file under one
1314        // artist nobody named.
1315        let (Some(spelling), Some(filed_as)) =
1316            (row.field_str("spelling"), row.field_str("filed_as"))
1317        else {
1318            continue;
1319        };
1320        // Normalised on the way in as well as on the way out. A file edited by
1321        // hand — which is a thing this format invites, being readable — must
1322        // not be able to state a merge the program can never match.
1323        let (spelling, filed_as) = (text::normalize(&spelling), text::normalize(&filed_as));
1324        if spelling.is_empty() || filed_as.is_empty() || spelling == filed_as {
1325            continue;
1326        }
1327        data.same_artist.push(SameArtist {
1328            owner: row
1329                .field_str("owner")
1330                .unwrap_or_else(|| LOCAL_USER.to_string()),
1331            spelling,
1332            filed_as,
1333            created_at: row.field_u64("created_at").unwrap_or(0),
1334        });
1335    }
1336    // Older writers stored arrival order. Normalize in memory so readers and
1337    // undo operations use event time without changing counts or losing events.
1338    data.plays.sort_by_key(|play| play.at);
1339    history::validate(&data)?;
1340    Ok(data)
1341}
1342
1343/// What a merge did, so that an import can say it rather than be trusted.
1344#[derive(Debug, Clone, Copy, Default, PartialEq)]
1345pub struct Merge {
1346    /// Records that did not exist here.
1347    pub added: usize,
1348    /// Records replaced because the incoming one was written later.
1349    pub updated: usize,
1350    /// Records left alone because what is here is newer.
1351    pub kept: usize,
1352    /// Native listening events or client declarations newly added to their logs.
1353    pub plays: usize,
1354    /// Saved queries taken in.
1355    pub collections: usize,
1356}
1357
1358/// Folds one set of user data into another.
1359///
1360/// **Merging, never replacing.** Someone restoring half a backup wants their
1361/// two halves, not the older one; and an import that emptied what was already
1362/// there would be the one operation in this program capable of losing
1363/// everything at once.
1364///
1365/// The rule is the same everywhere: the record written **last** wins, and the
1366/// one that lost is reported rather than dropped in silence. Play counters take
1367/// the larger of the two, since a count is a total and neither side ever
1368/// counted the other's listens.
1369/// Recent listening events are merged as a multiset of their complete values:
1370/// simultaneous identical events keep their multiplicity, and reimporting a
1371/// backup does not append them again. Relink history remains an audit trail;
1372/// importing it never replays reattachment or undo commands.
1373/// Native-player profiles are imported only when absent locally: an import
1374/// never rewinds an existing device checkpoint, even if its timestamp is older.
1375pub fn merge(into: &mut UserData, incoming: UserData) -> Merge {
1376    let mut report = Merge::default();
1377    for entry in incoming.client_preferences {
1378        if !into
1379            .client_preferences
1380            .iter()
1381            .any(|known| known.owner == entry.owner && known.key == entry.key)
1382        {
1383            into.client_preferences.push(entry);
1384        }
1385    }
1386    playlists::merge(into, incoming.playlists, incoming.scrobbles, &mut report);
1387    playback::merge(into, incoming.playback_states, &mut report);
1388
1389    // These are evidence and an audit trail, not commands to replay. Preserve
1390    // local evidence on conflict; imported history never moves current data.
1391    for identity in incoming.track_identities {
1392        if !into
1393            .track_identities
1394            .iter()
1395            .any(|known| known.path == identity.path)
1396        {
1397            into.track_identities.push(identity);
1398        }
1399    }
1400    for mut event in incoming.relinks {
1401        let known = into.relinks.iter().any(|known| {
1402            known.origin_id == event.origin_id
1403                && known.owner == event.owner
1404                && known.from == event.from
1405                && known.to == event.to
1406                && known.at == event.at
1407                && known.before.annotations == event.before.annotations
1408                && known.before.plays == event.before.plays
1409                && known.before.counts == event.before.counts
1410                && known.before.relation_annotations == event.before.relation_annotations
1411                && known.before.playlists == event.before.playlists
1412                && known.before.scrobbles == event.before.scrobbles
1413        });
1414        if known {
1415            continue;
1416        }
1417        if into.relinks.iter().any(|known| known.id == event.id) {
1418            let Some(next) = into
1419                .relinks
1420                .iter()
1421                .map(|known| known.id)
1422                .max()
1423                .unwrap_or(0)
1424                .checked_add(1)
1425            else {
1426                continue;
1427            };
1428            event.id = next;
1429        }
1430        into.relinks.push(event);
1431    }
1432
1433    for annotation in incoming.annotations {
1434        match into
1435            .annotations
1436            .iter_mut()
1437            .find(|a| a.owner == annotation.owner && a.target == annotation.target)
1438        {
1439            Some(existing) if existing.updated_at >= annotation.updated_at => report.kept += 1,
1440            Some(existing) => {
1441                *existing = annotation;
1442                report.updated += 1;
1443            }
1444            None => {
1445                into.annotations.push(annotation);
1446                report.added += 1;
1447            }
1448        }
1449    }
1450
1451    for annotation in incoming.relation_annotations {
1452        match into.relation_annotations.iter_mut().find(|existing| {
1453            existing.owner == annotation.owner && existing.relation == annotation.relation
1454        }) {
1455            Some(existing) if existing.updated_at >= annotation.updated_at => report.kept += 1,
1456            Some(existing) => {
1457                *existing = annotation;
1458                report.updated += 1;
1459            }
1460            None => {
1461                into.relation_annotations.push(annotation);
1462                report.added += 1;
1463            }
1464        }
1465    }
1466
1467    // Equal-time listens can be distinct, including repeated identical events.
1468    // Consume only the multiplicity already present, so restoring a backup
1469    // keeps every listen while importing that same backup again is idempotent.
1470    let play_key = |play: &Play| {
1471        (
1472            play.owner.clone(),
1473            play.track.clone(),
1474            play.at,
1475            play.ms_played,
1476            play.completed,
1477        )
1478    };
1479    let mut available = BTreeMap::new();
1480    for play in &into.plays {
1481        *available.entry(play_key(play)).or_insert(0usize) += 1;
1482    }
1483    for play in incoming.plays {
1484        let copies = available.entry(play_key(&play)).or_default();
1485        if *copies > 0 {
1486            *copies -= 1;
1487        } else {
1488            into.plays.push(play);
1489            report.plays += 1;
1490        }
1491    }
1492    into.plays.sort_by_key(|p| p.at);
1493    history::retain(&mut into.plays, |play| &play.owner);
1494
1495    for count in incoming.counts {
1496        match into
1497            .counts
1498            .iter_mut()
1499            .find(|c| c.owner == count.owner && c.track == count.track)
1500        {
1501            Some(existing) => {
1502                existing.count = existing.count.max(count.count);
1503                existing.completed_count = existing.completed_count.max(count.completed_count);
1504                existing.last_played = existing.last_played.max(count.last_played);
1505            }
1506            None => into.counts.push(count),
1507        }
1508    }
1509
1510    for collection in incoming.collections {
1511        let owner = collection.owner.clone();
1512        match into.collections.iter_mut().find(|c| {
1513            c.owner == owner && text::normalize(&c.name) == text::normalize(&collection.name)
1514        }) {
1515            Some(existing) if existing.updated_at >= collection.updated_at => report.kept += 1,
1516            Some(existing) => {
1517                *existing = collection;
1518                report.collections += 1;
1519            }
1520            None => {
1521                into.collections.push(collection);
1522                report.collections += 1;
1523            }
1524        }
1525    }
1526    // A decision has no versions to arbitrate: it was taken or it was not, so
1527    // importing the same backup twice must not file it twice.
1528    for aside in incoming.set_aside {
1529        let known = into
1530            .set_aside
1531            .iter()
1532            .any(|a| a.owner == aside.owner && a.release_group == aside.release_group);
1533        if !known {
1534            into.set_aside.push(aside);
1535            report.added += 1;
1536        }
1537    }
1538    // Same rule, and the same reason: a merge was stated or it was not.
1539    // Keyed on the spelling that gives way alone, not on the pair — two rows
1540    // filing `o osbourne` under two different artists is not a statement said
1541    // twice, it is a contradiction, and the one already on this disk wins
1542    // because taking it back is one command and finding it is not.
1543    for same in incoming.same_artist {
1544        let known = into
1545            .same_artist
1546            .iter()
1547            .any(|m| m.owner == same.owner && m.spelling == same.spelling);
1548        if !known {
1549            into.same_artist.push(same);
1550            report.added += 1;
1551        }
1552    }
1553    report
1554}
1555
1556#[cfg(test)]
1557#[path = "user_test_support.rs"]
1558mod test_support;
1559
1560#[cfg(test)]
1561#[path = "user_tests.rs"]
1562mod tests;
1563
1564#[cfg(test)]
1565#[path = "user_history_tests.rs"]
1566mod history_tests;