diff --git a/internal/library/duplicate_merge.go b/internal/library/duplicate_merge.go index 1182c510..d6ee8a3b 100644 --- a/internal/library/duplicate_merge.go +++ b/internal/library/duplicate_merge.go @@ -118,82 +118,25 @@ func MergeDuplicateGroup( res := MergeResult{Tier: group.Tier, Survivor: mergedCopyOf(survivor)} likers := map[string]struct{}{} changes := mergeChanges{} - survivorKey := syncpkg.FormatUUID(survivor.ID) for _, l := range losers { - ids := struct{ survivor, loser pgtype.UUID }{survivor.ID, l.ID} - 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) + f, err := foldTrackInto(ctx, tq, survivor.ID, l.ID, &changes) if err != nil { return MergeResult{}, err } - if tidied.AlbumID != nil { - res.DeletedAlbumIDs = append(res.DeletedAlbumIDs, *tidied.AlbumID) + res.PlayEvents += f.PlayEvents + res.SkipEvents += f.SkipEvents + res.PlaylistEntries += f.PlaylistEntries + for _, u := range f.Likers { + likers[u] = struct{}{} } - if tidied.ArtistID != nil { - res.DeletedArtistIDs = append(res.DeletedArtistIDs, *tidied.ArtistID) + if f.DeletedAlbumID != nil { + 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)) - changes.trackDelete(loserKey) } res.Likes = len(likers) @@ -223,6 +166,98 @@ func MergeDuplicateGroup( 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 // invalid survivorID takes ProposeSurvivor's choice. func splitSurvivor(