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:
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user