Skip to content

musikr: stable playlist song identity across UID changes#1321

Open
Victor239 wants to merge 4 commits intoOxygenCobalt:devfrom
Victor239:fix/uid-migration
Open

musikr: stable playlist song identity across UID changes#1321
Victor239 wants to merge 4 commits intoOxygenCobalt:devfrom
Victor239:fix/uid-migration

Conversation

@Victor239
Copy link
Copy Markdown

@Victor239 Victor239 commented Apr 6, 2026

What is it?

  • Bugfix (user facing)
  • Feature (user facing)
  • Codebase improvement (dev facing)
  • Meta improvement to the project (dev facing)

Description of changes

Songs in playlists could silently disappear whenever their UID changed — either because a MusicBrainz ID was added to a previously hash-identified song, or because tag edits produced a different hash. This PR fixes that end-to-end.

  • Add a priority-based UID hierarchy for songs: ACOUSTID_FINGERPRINT (content-based, most stable) > MUSICBRAINZ_TRACKID (recording ID) > MUSICBRAINZ_RELEASETRACKID (release track ID, previous behaviour) > hash of tag metadata
  • Add Music.UID.Format.ACOUSTID and Music.UID.acoustid() factory; update fromString() to parse the new format
  • Add TagFields.musicBrainzRecordingId() and TagFields.acoustidFingerprint() tag accessors across Xiph/MP4/ID3v2
  • Add PreSong.legacyUids: all lower-priority UIDs a song carried before a stable identifier superseded them. During MusicGraph.build(), each song probes playlist pointer maps with its legacy UIDs and records any matches in uidMigrations, which are written to the DB after indexing
  • Add SongUriRecord (DB version 30→31, non-destructive migration): a persistent fileUri → songUid index updated after every rescan. computeUriMigrations diffs the stored mapping against the current one to catch any UID change the legacyUids probe cannot — specifically when the tags that make up the hash are also edited, or when a unique identifier like a MusicBrainz ID is gained at the same time other tags change
  • Add MusicGraph.applyMigrations(): patches playlist vertices in-memory after URI migrations are computed so corrected playlist contents are visible in the same rescan rather than requiring a second one

Fixes the following issues

Songs added to a playlist disappear after retagging — whether that is editing metadata fields, adding a MusicBrainz ID, or both at once. Also #1004.

Any additional information

The two migration mechanisms are complementary:

The legacyUids probe works purely from tag data computed at index time. It handles the clean case of a song gaining a stable identifier (MusicBrainz/AcoustID) without other tag changes, and requires no DB read beyond the playlist itself.

The URI index is the fallback for everything else. Because it tracks the last-known UID per file path across rescans, it can detect any UID change regardless of cause — hash drift from tag edits, gaining a stable ID alongside other edits, or any future UID format change. Both sources are merged before being applied.

APK testing

debug.zip

Due diligence

When a song was indexed without a MusicBrainz ID its playlist entries
were stored under a hash-based UID. If an external app later tagged the
file with a MusicBrainz ID, the next index run would assign the song a
new MusicBrainz UID, leaving the stored hash UID unresolved — silently
dropping the song from all playlists with no recovery path.

Fix this with a two-part approach:

1. TagInterpreter now always computes the three hash-based UIDs
   (v363/v400/v401) unconditionally, even when a MusicBrainz ID is
   present. When a MusicBrainz ID supersedes them they are stored as
   PreSong.legacyUids; otherwise legacyUids is empty and behaviour is
   unchanged for songs without a MusicBrainz ID.

2. During graph playlist resolution, each song's legacyUids are probed
   against the stored playlist pointerMap in addition to the three
   existing canonical probes. A successful legacy-UID match resolves the
   song correctly for the current run and records an oldHash → newMbUid
   entry in a uidMigrations map on the built MusicGraph.

After the graph is built, EvaluateStep calls
StoredPlaylists.migrate(uidMigrations), which rewrites PlaylistSong and
PlaylistSongCrossRef rows in the DB from the old hash UID to the new
MusicBrainz UID. Subsequent index runs resolve cleanly via canonical
probes and the migration is a no-op (empty map).
The previous migration only handled the case where a song gains a
MusicBrainz ID without any other tag changes, relying on the
legacyUids probe populated during playlist resolution.

This extends the approach with a persistent URI → UID index
(SongUriRecord, DB version 30→31). After every index run the current
mapping is saved. On the next run the stored mapping is diffed against
the new one and any songs whose UID changed (for any reason: MB ID
added alongside other tag edits, pure tag-hash drift, etc.) are
included in the migration set alongside the legacyUids-derived
migrations.

Changes:
- Add SongUriRecord entity and DB migration 30→31
- Add computeUriMigrations / updateUriIndex to StoredPlaylists
- EvaluateStep merges both migration sources before calling migrate()
- Minor KDoc reformatting (spotless)
…elease > hash

Previously the canonical song UID was either the MusicBrainz release track ID
(MUSICBRAINZ_RELEASETRACKID) or a hash of tag metadata. This introduces a fuller
priority hierarchy so that the most stable available identifier is always used:

1. ACOUSTID_FINGERPRINT — content-based fingerprint; survives any metadata edit
2. MUSICBRAINZ_TRACKID — recording ID; stable across different releases of the same
   recording
3. MUSICBRAINZ_RELEASETRACKID — release-specific track ID (previous behaviour)
4. Hash of tag metadata (previous fallback; URI-index handles drift)

Changes:
- Music.UID: new ACOUSTID format ('c' micronamespace, "org.acoustid" namespace);
  Music.UID.acoustid() factory SHA-256 hashes the fingerprint string into a UUID;
  fromString() updated to parse the new format
- TagFields: musicBrainzRecordingId() reads MUSICBRAINZ_TRACKID across Xiph/MP4/ID3v2;
  acoustidFingerprint() reads ACOUSTID_FINGERPRINT across the same formats
- ParsedTags / TagParser: plumb the two new fields through from raw metadata
- TagInterpreter: stableUid selected by priority; legacyUids now includes all
  lower-priority UIDs (MB recording, MB release track, all three hash variants) so
  any playlist stored under any previous UID resolves and migrates on next rescan
…rescan

Previously, URI-based UID migrations were computed and written to the DB after
MusicGraph.build() had already resolved playlist vertices using the pre-migration
DB state. This meant songs whose UID changed in a given rescan appeared missing
from playlists until a second rescan applied the now-correct DB state.

Fix by calling graph.applyMigrations(uriMigrations) after the DB is updated but
before libraryFactory.create() consumes the graph. This walks the playlist vertices
and fills in any positions that the URI migration resolved, so the corrected playlist
contents are visible immediately in the same rescan that triggered the UID change.
@OxygenCobalt
Copy link
Copy Markdown
Owner

OxygenCobalt commented Apr 12, 2026

Thanks for looking at this. This seems to be made my Claude so I'll have to look at this deeply & also have to rewrite. Possible that changes like these can be extremely naive and actually make things worse.

@Victor239
Copy link
Copy Markdown
Author

No problem. I have extensively tested and reviewed the code, just had Claude create it as I'm unfamiliar with Kotlin. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants