package api import ( "context" "encoding/json" "errors" "io" "net/http" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/library" ) // duplicateMemberView is one copy in a proposed duplicate group. LikeCount and // PlayCount span every user: the report is admin-only, and what a copy carries // is the fact the operator weighs when choosing which to keep. type duplicateMemberView struct { TrackID string `json:"track_id"` Title string `json:"title"` ArtistName string `json:"artist_name"` AlbumID string `json:"album_id"` AlbumTitle string `json:"album_title"` FilePath string `json:"file_path"` FileFormat string `json:"file_format"` FileSize int64 `json:"file_size"` DurationSec int32 `json:"duration_sec"` AddedAt string `json:"added_at"` LikeCount int64 `json:"like_count"` PlayCount int64 `json:"play_count"` } // duplicateGroupView is one proposal. SurvivorTrackID and SurvivorReason are // the copy the report proposes keeping and the rule that chose it // (library.ProposeSurvivor) — a default the merge (#3911) lets the operator // override. type duplicateGroupView struct { ID string `json:"id"` Tier string `json:"tier"` WorstBitErrorRate *float32 `json:"worst_bit_error_rate"` DetectedAt string `json:"detected_at"` SurvivorTrackID string `json:"survivor_track_id"` SurvivorReason string `json:"survivor_reason"` Members []duplicateMemberView `json:"members"` } // duplicateSweepView is the latest sweep. State is "never" when none has run, // which is what lets the page tell an empty report apart from a sweep that // found nothing. type duplicateSweepView struct { State string `json:"state"` StartedAt *string `json:"started_at"` FinishedAt *string `json:"finished_at"` Candidates *int32 `json:"candidates"` GroupsFound *int32 `json:"groups_found"` OversizeClusters *int32 `json:"oversize_clusters"` ErrorMessage *string `json:"error_message"` } // adminDuplicatesResponse is the paged report. Total counts groups. type adminDuplicatesResponse struct { Sweep duplicateSweepView `json:"sweep"` Fingerprints fingerprintCoverageResp `json:"fingerprints"` Total int64 `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` Groups []duplicateGroupView `json:"groups"` } // handleListDuplicates implements GET /api/admin/library/duplicates (#3912). // // Read-only. The sweep's state and the fingerprint backfill's progress travel // with the groups because an empty report means three different things — still // fingerprinting, never swept, or swept and clean — and the page has to say which. func (h *handlers) handleListDuplicates(w http.ResponseWriter, r *http.Request) { limit, offset, err := parsePaging(r.URL.Query()) if err != nil { writeAdminJSONErr(w, http.StatusBadRequest, "invalid_paging") return } ctx := r.Context() q := dbq.New(h.pool) sweep := duplicateSweepView{State: "never"} last, err := q.GetLatestDuplicateSweep(ctx) switch { case err == nil: sweep = duplicateSweepViewOf(last) case !errors.Is(err, pgx.ErrNoRows): h.logger.Error("admin: latest duplicate sweep", "err", err) writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") return } cov, err := library.FingerprintCoverage(ctx, h.pool) if err != nil { h.logger.Error("admin: fingerprint coverage", "err", err) writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") return } total, err := q.CountPendingDuplicateGroups(ctx) if err != nil { h.logger.Error("admin: count duplicate groups", "err", err) writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") return } rows, err := q.ListPendingDuplicateGroupMembers(ctx, dbq.ListPendingDuplicateGroupMembersParams{ PageLimit: int32(limit), PageOffset: int32(offset), }) if err != nil { h.logger.Error("admin: list duplicate groups", "err", err) writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") return } writeJSON(w, http.StatusOK, adminDuplicatesResponse{ Sweep: sweep, Fingerprints: fingerprintCoverageResp{ Total: cov.Total, Fingerprinted: cov.Fingerprinted, Rejected: cov.Rejected, Pending: cov.Pending, }, Total: total, Limit: limit, Offset: offset, Groups: foldDuplicateGroups(rows), }) } func duplicateSweepViewOf(s dbq.DuplicateSweep) duplicateSweepView { v := duplicateSweepView{ State: "running", Candidates: s.Candidates, GroupsFound: s.GroupsFound, OversizeClusters: s.OversizeClusters, ErrorMessage: s.ErrorMessage, } started := formatTimestamp(s.StartedAt) v.StartedAt = &started if s.FinishedAt.Valid { finished := formatTimestamp(s.FinishedAt) v.FinishedAt = &finished v.State = "finished" } return v } // foldDuplicateGroups folds the one-row-per-member query result into groups and // proposes each group's survivor. It relies on the query ordering members of a // group together, so a run-length fold is enough and the page order holds. func foldDuplicateGroups(rows []dbq.ListPendingDuplicateGroupMembersRow) []duplicateGroupView { groups := make([]duplicateGroupView, 0, 8) var candidates [][]library.SurvivorCandidate for _, row := range rows { id := uuidToString(row.GroupID) if n := len(groups); n == 0 || groups[n-1].ID != id { groups = append(groups, duplicateGroupView{ ID: id, Tier: row.Tier, WorstBitErrorRate: row.WorstBitErrorRate, DetectedAt: formatTimestamp(row.DetectedAt), }) candidates = append(candidates, nil) } n := len(groups) - 1 trackID := uuidToString(row.TrackID) groups[n].Members = append(groups[n].Members, duplicateMemberView{ TrackID: trackID, Title: row.Title, ArtistName: row.ArtistName, AlbumID: uuidToString(row.AlbumID), AlbumTitle: row.AlbumTitle, FilePath: row.FilePath, FileFormat: row.FileFormat, FileSize: row.FileSize, DurationSec: row.DurationMs / 1000, AddedAt: formatTimestamp(row.AddedAt), LikeCount: row.LikeCount, PlayCount: row.PlayCount, }) candidates[n] = append(candidates[n], library.SurvivorCandidate{ TrackID: trackID, FileFormat: row.FileFormat, FileSize: row.FileSize, AddedAt: row.AddedAt.Time, }) } for i := range groups { groups[i].SurvivorTrackID, groups[i].SurvivorReason = library.ProposeSurvivor(candidates[i]) } return groups } // handleRunDuplicateSweep implements POST /api/admin/library/duplicates/sweep: // 202 when a sweep starts, 409 sweep_in_progress when one is already running. // The sweep outlives the request, so it runs on a background context, as // handleTriggerScan's scan does. func (h *handlers) handleRunDuplicateSweep(w http.ResponseWriter, _ *http.Request) { started, err := library.TryStartDuplicateSweep( context.Background(), h.pool, h.logger.With("source", "manual"), ) if err != nil { h.logger.Error("admin: start duplicate sweep", "err", err) writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") return } if !started { writeAdminJSONErr(w, http.StatusConflict, "sweep_in_progress") return } writeJSON(w, http.StatusAccepted, map[string]bool{"started": true}) } // handleDismissDuplicateGroup implements POST // /api/admin/library/duplicates/{id}/dismiss: "these are not duplicates". The // sweep keeps the dismissal and will not propose that set of tracks again. 404 // duplicate_group_not_pending when the group was already resolved or is gone. func (h *handlers) handleDismissDuplicateGroup(w http.ResponseWriter, r *http.Request) { id, ok := parseUUID(chi.URLParam(r, "id")) if !ok { writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") return } n, err := dbq.New(h.pool).DismissDuplicateGroup(r.Context(), id) if err != nil { h.logger.Error("admin: dismiss duplicate group", "err", err) writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") return } if n == 0 { writeAdminJSONErr(w, http.StatusNotFound, "duplicate_group_not_pending") return } writeJSON(w, http.StatusOK, map[string]string{"status": "dismissed"}) } // mergeDuplicateRequest chooses the copy to keep. An empty survivor_track_id // keeps the report's proposal. type mergeDuplicateRequest struct { SurvivorTrackID string `json:"survivor_track_id"` Unmonitor bool `json:"unmonitor"` } // mergeDuplicateResponse reports what the merge removed. RemovedPaths are files // deleted from disk; the operator reads them to know exactly what went. type mergeDuplicateResponse struct { SurvivorTrackID string `json:"survivor_track_id"` RemovedPaths []string `json:"removed_paths"` LidarrUnmonitorFailed *bool `json:"lidarr_unmonitor_failed,omitempty"` } // mergeRequestBodyLimit bounds the request body. It holds one id and a flag. const mergeRequestBodyLimit = 1 << 16 // handleMergeDuplicateGroup implements POST /api/admin/library/duplicates/{id}/merge // (#3911): keep one copy, move the others' likes, plays and playlist entries onto // it, and delete the others' files and rows. // // Errors: // - 409 library_not_writable / 500 file_delete_failed when a file could not be // removed — nothing was changed (fileRemoveAPIError) // - 404 duplicate_group_not_pending when the group was already resolved // - 400 survivor_not_in_group, invalid_id, invalid_body func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Request) { admin, ok := requireUser(w, r) if !ok { return } groupID, ok := parseUUID(chi.URLParam(r, "id")) if !ok { writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") return } var body mergeDuplicateRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, mergeRequestBodyLimit)).Decode(&body); err != nil && !errors.Is(err, io.EOF) { writeAdminJSONErr(w, http.StatusBadRequest, "invalid_body") return } var survivorID pgtype.UUID // invalid: keep the proposal if body.SurvivorTrackID != "" { if survivorID, ok = parseUUID(body.SurvivorTrackID); !ok { writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") return } } res, unmonitorFailed, err := h.tracks.MergeDuplicates(r.Context(), groupID, survivorID, admin.ID, body.Unmonitor) if err != nil { if apiErr, ok := fileRemoveAPIError(err); ok { logFileRemoveFailure(h.logger, apiErr, "group_id", uuidToString(groupID)) writeErr(w, apiErr) return } switch { case errors.Is(err, library.ErrDuplicateGroupNotPending): writeAdminJSONErr(w, http.StatusNotFound, "duplicate_group_not_pending") case errors.Is(err, library.ErrSurvivorNotInGroup): writeAdminJSONErr(w, http.StatusBadRequest, "survivor_not_in_group") default: h.logger.Error("admin: merge duplicate group", "group_id", uuidToString(groupID), "err", err) writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") } return } resp := mergeDuplicateResponse{ SurvivorTrackID: uuidToString(res.Survivor.TrackID), RemovedPaths: make([]string, 0, len(res.Removed)), } for _, c := range res.Removed { resp.RemovedPaths = append(resp.RemovedPaths, c.FilePath) } if body.Unmonitor && unmonitorFailed { failed := true resp.LidarrUnmonitorFailed = &failed } writeJSON(w, http.StatusOK, resp) }