refactor(library): the per-copy fold is a helper the duplicate merge calls (M485 #5285)

MergeDuplicateGroup's loop body — repoint and copy every FK from a removed
copy onto the survivor, inherit its MBID, delete its row, tidy an emptied
album — moves into foldTrackInto, so the missing-pair pass can fold a stale
missing row into its replacement with the same mechanics. The group lock,
file removal and group bookkeeping stay in the merge. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 17:23:32 -04:00
co-authored by Claude Opus 5.5
parent 0766349397
commit 2df28345b4
+103 -68
View File
@@ -118,82 +118,25 @@ func MergeDuplicateGroup(
res := MergeResult{Tier: group.Tier, Survivor: mergedCopyOf(survivor)} res := MergeResult{Tier: group.Tier, Survivor: mergedCopyOf(survivor)}
likers := map[string]struct{}{} likers := map[string]struct{}{}
changes := mergeChanges{} changes := mergeChanges{}
survivorKey := syncpkg.FormatUUID(survivor.ID)
for _, l := range losers { for _, l := range losers {
ids := struct{ survivor, loser pgtype.UUID }{survivor.ID, l.ID} f, err := foldTrackInto(ctx, tq, survivor.ID, l.ID, &changes)
loserKey := syncpkg.FormatUUID(l.ID)
n, err := tq.MergeRepointPlayEvents(ctx, dbq.MergeRepointPlayEventsParams{SurvivorID: ids.survivor, LoserID: ids.loser})
if err != nil {
return MergeResult{}, fmt.Errorf("move play events: %w", err)
}
res.PlayEvents += n
n, err = tq.MergeRepointSkipEvents(ctx, dbq.MergeRepointSkipEventsParams{SurvivorID: ids.survivor, LoserID: ids.loser})
if err != nil {
return MergeResult{}, fmt.Errorf("move skip events: %w", err)
}
res.SkipEvents += n
if _, err := tq.MergeRepointContextualLikes(ctx, dbq.MergeRepointContextualLikesParams{SurvivorID: ids.survivor, LoserID: ids.loser}); err != nil {
return MergeResult{}, fmt.Errorf("move contextual likes: %w", err)
}
if _, err := tq.MergeRepointPlaybackErrors(ctx, dbq.MergeRepointPlaybackErrorsParams{SurvivorID: ids.survivor, LoserID: ids.loser}); err != nil {
return MergeResult{}, fmt.Errorf("move playback errors: %w", err)
}
if _, err := tq.MergeRepointLidarrRequests(ctx, dbq.MergeRepointLidarrRequestsParams{SurvivorID: ids.survivor, LoserID: ids.loser}); err != nil {
return MergeResult{}, fmt.Errorf("move lidarr request matches: %w", err)
}
playlists, err := tq.MergeRepointPlaylistTracks(ctx, dbq.MergeRepointPlaylistTracksParams{SurvivorID: ids.survivor, LoserID: ids.loser})
if err != nil {
return MergeResult{}, fmt.Errorf("move playlist entries: %w", err)
}
res.PlaylistEntries += len(playlists)
for _, pl := range playlists {
plKey := syncpkg.FormatUUID(pl)
changes.playlistDelete(syncpkg.EncodePlaylistTrackID(plKey, loserKey))
changes.playlistUpsert(syncpkg.EncodePlaylistTrackID(plKey, survivorKey))
}
users, err := tq.MergeCopyGeneralLikes(ctx, dbq.MergeCopyGeneralLikesParams{SurvivorID: ids.survivor, LoserID: ids.loser})
if err != nil {
return MergeResult{}, fmt.Errorf("move likes: %w", err)
}
for _, u := range users {
userKey := syncpkg.FormatUUID(u)
likers[userKey] = struct{}{}
changes.likeDelete(syncpkg.EncodeLikeID(userKey, loserKey))
changes.likeUpsert(syncpkg.EncodeLikeID(userKey, survivorKey))
}
if _, err := tq.MergeCopyTrackTags(ctx, dbq.MergeCopyTrackTagsParams{SurvivorID: ids.survivor, LoserID: ids.loser}); err != nil {
return MergeResult{}, fmt.Errorf("merge tags: %w", err)
}
if _, err := tq.MergeCopyTrackSimilarity(ctx, dbq.MergeCopyTrackSimilarityParams{SurvivorID: ids.survivor, LoserID: ids.loser}); err != nil {
return MergeResult{}, fmt.Errorf("merge similarity: %w", err)
}
if err := tq.MergeInheritTrackMbid(ctx, dbq.MergeInheritTrackMbidParams{SurvivorID: ids.survivor, LoserID: ids.loser}); err != nil {
return MergeResult{}, fmt.Errorf("inherit recording mbid: %w", err)
}
// Everything the loser carried now sits on the survivor, so the CASCADE
// this delete sets off has nothing left to destroy.
deleted, err := tq.DeleteTrack(ctx, l.ID)
if err != nil {
return MergeResult{}, fmt.Errorf("delete merged copy: %w", err)
}
tidied, err := tidyEmptiedAlbum(ctx, tq, deleted.AlbumID)
if err != nil { if err != nil {
return MergeResult{}, err return MergeResult{}, err
} }
if tidied.AlbumID != nil { res.PlayEvents += f.PlayEvents
res.DeletedAlbumIDs = append(res.DeletedAlbumIDs, *tidied.AlbumID) res.SkipEvents += f.SkipEvents
res.PlaylistEntries += f.PlaylistEntries
for _, u := range f.Likers {
likers[u] = struct{}{}
} }
if tidied.ArtistID != nil { if f.DeletedAlbumID != nil {
res.DeletedArtistIDs = append(res.DeletedArtistIDs, *tidied.ArtistID) res.DeletedAlbumIDs = append(res.DeletedAlbumIDs, *f.DeletedAlbumID)
}
if f.DeletedArtistID != nil {
res.DeletedArtistIDs = append(res.DeletedArtistIDs, *f.DeletedArtistID)
} }
res.Removed = append(res.Removed, mergedCopyOf(l)) res.Removed = append(res.Removed, mergedCopyOf(l))
changes.trackDelete(loserKey)
} }
res.Likes = len(likers) res.Likes = len(likers)
@@ -223,6 +166,98 @@ func MergeDuplicateGroup(
return res, nil return res, nil
} }
// foldResult is what one fold moved onto the survivor.
type foldResult struct {
PlayEvents int64
SkipEvents int64
PlaylistEntries int
Likers []string // users whose like now sits on the survivor
DeletedAlbumID *pgtype.UUID
DeletedArtistID *pgtype.UUID
}
// foldTrackInto moves everything the loser row carries onto the survivor, then
// deletes the loser row and tidies an album or artist it leaves empty. It is the
// row half of a merge, shared by the duplicate merge and the missing-pair pass
// (M485): neither touches a file here, and both run it inside their own
// transaction. The sync changes it owes clients go into changes, which the
// caller logs once before committing.
func foldTrackInto(
ctx context.Context, tq *dbq.Queries, survivorID, loserID pgtype.UUID, changes *mergeChanges,
) (foldResult, error) {
var f foldResult
survivorKey := syncpkg.FormatUUID(survivorID)
loserKey := syncpkg.FormatUUID(loserID)
n, err := tq.MergeRepointPlayEvents(ctx, dbq.MergeRepointPlayEventsParams{SurvivorID: survivorID, LoserID: loserID})
if err != nil {
return f, fmt.Errorf("move play events: %w", err)
}
f.PlayEvents = n
n, err = tq.MergeRepointSkipEvents(ctx, dbq.MergeRepointSkipEventsParams{SurvivorID: survivorID, LoserID: loserID})
if err != nil {
return f, fmt.Errorf("move skip events: %w", err)
}
f.SkipEvents = n
if _, err := tq.MergeRepointContextualLikes(ctx, dbq.MergeRepointContextualLikesParams{SurvivorID: survivorID, LoserID: loserID}); err != nil {
return f, fmt.Errorf("move contextual likes: %w", err)
}
if _, err := tq.MergeRepointPlaybackErrors(ctx, dbq.MergeRepointPlaybackErrorsParams{SurvivorID: survivorID, LoserID: loserID}); err != nil {
return f, fmt.Errorf("move playback errors: %w", err)
}
if _, err := tq.MergeRepointLidarrRequests(ctx, dbq.MergeRepointLidarrRequestsParams{SurvivorID: survivorID, LoserID: loserID}); err != nil {
return f, fmt.Errorf("move lidarr request matches: %w", err)
}
playlists, err := tq.MergeRepointPlaylistTracks(ctx, dbq.MergeRepointPlaylistTracksParams{SurvivorID: survivorID, LoserID: loserID})
if err != nil {
return f, fmt.Errorf("move playlist entries: %w", err)
}
f.PlaylistEntries = len(playlists)
for _, pl := range playlists {
plKey := syncpkg.FormatUUID(pl)
changes.playlistDelete(syncpkg.EncodePlaylistTrackID(plKey, loserKey))
changes.playlistUpsert(syncpkg.EncodePlaylistTrackID(plKey, survivorKey))
}
users, err := tq.MergeCopyGeneralLikes(ctx, dbq.MergeCopyGeneralLikesParams{SurvivorID: survivorID, LoserID: loserID})
if err != nil {
return f, fmt.Errorf("move likes: %w", err)
}
for _, u := range users {
userKey := syncpkg.FormatUUID(u)
f.Likers = append(f.Likers, userKey)
changes.likeDelete(syncpkg.EncodeLikeID(userKey, loserKey))
changes.likeUpsert(syncpkg.EncodeLikeID(userKey, survivorKey))
}
if _, err := tq.MergeCopyTrackTags(ctx, dbq.MergeCopyTrackTagsParams{SurvivorID: survivorID, LoserID: loserID}); err != nil {
return f, fmt.Errorf("merge tags: %w", err)
}
if _, err := tq.MergeCopyTrackSimilarity(ctx, dbq.MergeCopyTrackSimilarityParams{SurvivorID: survivorID, LoserID: loserID}); err != nil {
return f, fmt.Errorf("merge similarity: %w", err)
}
if err := tq.MergeInheritTrackMbid(ctx, dbq.MergeInheritTrackMbidParams{SurvivorID: survivorID, LoserID: loserID}); err != nil {
return f, fmt.Errorf("inherit recording mbid: %w", err)
}
// Everything the loser carried now sits on the survivor, so the CASCADE
// this delete sets off has nothing left to destroy.
deleted, err := tq.DeleteTrack(ctx, loserID)
if err != nil {
return f, fmt.Errorf("delete merged copy: %w", err)
}
tidied, err := tidyEmptiedAlbum(ctx, tq, deleted.AlbumID)
if err != nil {
return f, err
}
f.DeletedAlbumID = tidied.AlbumID
f.DeletedArtistID = tidied.ArtistID
changes.trackDelete(loserKey)
return f, nil
}
// splitSurvivor separates the copy to keep from the copies to remove. An // splitSurvivor separates the copy to keep from the copies to remove. An
// invalid survivorID takes ProposeSurvivor's choice. // invalid survivorID takes ProposeSurvivor's choice.
func splitSurvivor( func splitSurvivor(