package api import ( "context" "errors" "net/http" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "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"}) }