test-go / test (push) Failing after 44s
test-web / test (push) Successful in 49s
test-go / integration (push) Failing after 2m42s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 4m8s
Rule 25: the fingerprinting knobs move out of source into a DB-backed singleton (migration 0061), edited from a card on the Duplicates page and shared live with the scanner, the backfill and the duplicate sweep through one service instance, so a save needs no restart. The length is the knob that can silently break the library: prints taken at two lengths never match. Each track_fingerprints row now records the length it was taken at, and every reader filters on the current one — the backfill treats another length as stale, the gauge counts it pending, the sweep never streams it. Equivalent to a version bump, except that setting the length back makes rows not yet redone current again. The card warns before a length change re-fingerprints the library. Off stops every decode: the scan takes only the stream hash (a demux, and what recognises a moved file) and stores nothing, dropping a changed file's stale row; the backfill idles. A save also makes a sweep due, since a new threshold or length changes what the same prints group into, and the sweep interval gains slack so an hourly interval on an hourly tick doesn't skip every other tick. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
317 lines
11 KiB
Go
317 lines
11 KiB
Go
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 := h.fingerprintCoverage(ctx)
|
|
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: cov,
|
|
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) {
|
|
// Runs whatever the sweep interval says: the interval paces the automatic
|
|
// sweep, and an operator pressing the button has already decided.
|
|
started, err := library.TryStartDuplicateSweep(
|
|
context.Background(), h.pool, h.logger.With("source", "manual"), h.fingerprintSettings.Get(),
|
|
)
|
|
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)
|
|
}
|