feat(admin): the duplicates report — review proposed duplicate groups (M400 #3912)
test-web / test (push) Successful in 52s
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 3m31s
release / Build signed APK (releases and dev) (push) Successful in 4m32s
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
test-web / test (push) Successful in 52s
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 3m31s
release / Build signed APK (releases and dev) (push) Successful in 4m32s
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
A new admin tab, Duplicates, beside Missing files: the proposals from the duplicate sweep, with a Sweep now trigger and a Not duplicates dismissal. Nothing on it merges or deletes; the merge is #3911. Each group shows: - whether it is identical audio or the same recording, with a match percentage from the weakest link between members - every copy's format, size, duration, path, and the likes and plays it carries (every user's; this is admin-only, and it is what decides which copy to keep) - the copy proposed to keep, and the rule that chose it The survivor rule is library.ProposeSurvivor, a pure function the merge will reuse: lossless over lossy, then the larger file, then the copy in the library longest, then lowest id. Bitrate is not in it because the scanner never fills tracks.bitrate, and for one recording at one duration a larger file is the higher bitrate. m4a is not counted as lossless: it may be AAC. The reason names the rule that separated first place from second, not every rule the winner passed. An empty report has three causes, and the page says which: still fingerprinting, the sweep has never run, or it ran and found nothing. The sweep's state and the backfill's progress come back with the groups for that reason. Groups left with fewer than two members since the sweep are not shown. GET /api/admin/library/duplicates, POST .../sweep (202, or 409 sweep_in_progress), POST .../{id}/dismiss (404 duplicate_group_not_pending when already resolved). Migration 0060 indexes play_events by track_id. Its only indexes led with user_id, so each copy's play count, and the merge's repointing of play history, would scan the whole table. Web only, like Missing files: Android has no library-health admin screens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
This commit is contained in:
@@ -0,0 +1,230 @@
|
|||||||
|
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"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func dupUUID(b byte) pgtype.UUID {
|
||||||
|
var u pgtype.UUID
|
||||||
|
u.Bytes[15] = b
|
||||||
|
u.Valid = true
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func dupTS(t time.Time) pgtype.Timestamptz { return pgtype.Timestamptz{Time: t, Valid: true} }
|
||||||
|
|
||||||
|
// Rows arrive one per member, members of a group together. The fold must keep
|
||||||
|
// groups apart, keep the query's order, and propose each group's survivor from
|
||||||
|
// its own members only.
|
||||||
|
func TestFoldDuplicateGroups(t *testing.T) {
|
||||||
|
older := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
newer := older.Add(48 * time.Hour)
|
||||||
|
ber := float32(0.04)
|
||||||
|
rows := []dbq.ListPendingDuplicateGroupMembersRow{
|
||||||
|
// Group 1: identical audio, sizes tie, the older copy should be kept.
|
||||||
|
{GroupID: dupUUID(1), Tier: "exact", DetectedAt: dupTS(newer), TrackID: dupUUID(10),
|
||||||
|
Title: "WWW", FileFormat: "mp3", FileSize: 6_900_000, DurationMs: 215_400, AddedAt: dupTS(newer), PlayCount: 3},
|
||||||
|
{GroupID: dupUUID(1), Tier: "exact", DetectedAt: dupTS(newer), TrackID: dupUUID(11),
|
||||||
|
Title: "WWW", FileFormat: "mp3", FileSize: 6_900_000, DurationMs: 215_400, AddedAt: dupTS(older), LikeCount: 1},
|
||||||
|
// Group 2: the same recording, FLAC against MP3.
|
||||||
|
{GroupID: dupUUID(2), Tier: "acoustic", WorstBitErrorRate: &ber, DetectedAt: dupTS(older), TrackID: dupUUID(20),
|
||||||
|
Title: "Lovesick", FileFormat: "mp3", FileSize: 9_000_000, DurationMs: 198_000, AddedAt: dupTS(older)},
|
||||||
|
{GroupID: dupUUID(2), Tier: "acoustic", WorstBitErrorRate: &ber, DetectedAt: dupTS(older), TrackID: dupUUID(21),
|
||||||
|
Title: "Lovesick", FileFormat: "flac", FileSize: 30_000_000, DurationMs: 198_000, AddedAt: dupTS(newer)},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := foldDuplicateGroups(rows)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("folded %d groups, want 2", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
g1, g2 := got[0], got[1]
|
||||||
|
if g1.ID != uuidToString(dupUUID(1)) || len(g1.Members) != 2 || g1.WorstBitErrorRate != nil {
|
||||||
|
t.Fatalf("group 1 = %+v, want the exact pair with no score", g1)
|
||||||
|
}
|
||||||
|
if g1.SurvivorTrackID != uuidToString(dupUUID(11)) || g1.SurvivorReason != "in the library longest" {
|
||||||
|
t.Errorf("group 1 survivor = (%s, %q), want the older copy", g1.SurvivorTrackID, g1.SurvivorReason)
|
||||||
|
}
|
||||||
|
if g1.Members[0].DurationSec != 215 || g1.Members[0].PlayCount != 3 || g1.Members[1].LikeCount != 1 {
|
||||||
|
t.Errorf("group 1 members lost their facts: %+v", g1.Members)
|
||||||
|
}
|
||||||
|
|
||||||
|
if g2.Tier != "acoustic" || g2.WorstBitErrorRate == nil || *g2.WorstBitErrorRate != ber {
|
||||||
|
t.Fatalf("group 2 = %+v, want the acoustic pair with its score", g2)
|
||||||
|
}
|
||||||
|
// Chosen from group 2's own members: a survivor leaking across groups is
|
||||||
|
// exactly what a wrong fold boundary would produce.
|
||||||
|
if g2.SurvivorTrackID != uuidToString(dupUUID(21)) || g2.SurvivorReason != "lossless (flac)" {
|
||||||
|
t.Errorf("group 2 survivor = (%s, %q), want the FLAC copy", g2.SurvivorTrackID, g2.SurvivorReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -216,6 +216,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
|
|
||||||
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
|
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
|
||||||
admin.Get("/library/fingerprints", h.handleGetFingerprintCoverage)
|
admin.Get("/library/fingerprints", h.handleGetFingerprintCoverage)
|
||||||
|
// Duplicates report (#3912): proposals from the duplicate sweep, a
|
||||||
|
// trigger to sweep now, and dismissal. Nothing here merges or deletes.
|
||||||
|
admin.Get("/library/duplicates", h.handleListDuplicates)
|
||||||
|
admin.Post("/library/duplicates/sweep", h.handleRunDuplicateSweep)
|
||||||
|
admin.Post("/library/duplicates/{id}/dismiss", h.handleDismissDuplicateGroup)
|
||||||
|
|
||||||
admin.Get("/invites", h.handleListInvites)
|
admin.Get("/invites", h.handleListInvites)
|
||||||
admin.Post("/invites", h.handleCreateInvite)
|
admin.Post("/invites", h.handleCreateInvite)
|
||||||
|
|||||||
@@ -27,6 +27,23 @@ func (q *Queries) AddDuplicateGroupMember(ctx context.Context, arg AddDuplicateG
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const countPendingDuplicateGroups = `-- name: CountPendingDuplicateGroups :one
|
||||||
|
SELECT count(*)::bigint
|
||||||
|
FROM duplicate_groups g
|
||||||
|
WHERE g.status = 'pending'
|
||||||
|
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2
|
||||||
|
`
|
||||||
|
|
||||||
|
// Proposals awaiting review. A group left with one member — its other tracks
|
||||||
|
// deleted since the sweep — is no proposal at all and is not counted; the next
|
||||||
|
// sweep retires it.
|
||||||
|
func (q *Queries) CountPendingDuplicateGroups(ctx context.Context) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, countPendingDuplicateGroups)
|
||||||
|
var column_1 int64
|
||||||
|
err := row.Scan(&column_1)
|
||||||
|
return column_1, err
|
||||||
|
}
|
||||||
|
|
||||||
const deleteStalePendingDuplicateGroups = `-- name: DeleteStalePendingDuplicateGroups :execrows
|
const deleteStalePendingDuplicateGroups = `-- name: DeleteStalePendingDuplicateGroups :execrows
|
||||||
DELETE FROM duplicate_groups g
|
DELETE FROM duplicate_groups g
|
||||||
WHERE g.status = 'pending'
|
WHERE g.status = 'pending'
|
||||||
@@ -51,6 +68,22 @@ func (q *Queries) DeleteStalePendingDuplicateGroups(ctx context.Context, sweepID
|
|||||||
return result.RowsAffected(), nil
|
return result.RowsAffected(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dismissDuplicateGroup = `-- name: DismissDuplicateGroup :execrows
|
||||||
|
UPDATE duplicate_groups
|
||||||
|
SET status = 'dismissed', resolved_at = now()
|
||||||
|
WHERE id = $1 AND status = 'pending'
|
||||||
|
`
|
||||||
|
|
||||||
|
// "These are not duplicates." Only a pending group can be dismissed; zero rows
|
||||||
|
// means it was already resolved or no longer exists.
|
||||||
|
func (q *Queries) DismissDuplicateGroup(ctx context.Context, id pgtype.UUID) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, dismissDuplicateGroup, id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
const finishDuplicateSweep = `-- name: FinishDuplicateSweep :exec
|
const finishDuplicateSweep = `-- name: FinishDuplicateSweep :exec
|
||||||
UPDATE duplicate_sweeps
|
UPDATE duplicate_sweeps
|
||||||
SET finished_at = now(),
|
SET finished_at = now(),
|
||||||
@@ -270,6 +303,104 @@ func (q *Queries) ListExactDuplicateHashes(ctx context.Context, currentVersion i
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listPendingDuplicateGroupMembers = `-- name: ListPendingDuplicateGroupMembers :many
|
||||||
|
WITH page AS (
|
||||||
|
SELECT g.id, g.tier, g.worst_bit_error_rate, g.detected_at
|
||||||
|
FROM duplicate_groups g
|
||||||
|
WHERE g.status = 'pending'
|
||||||
|
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2
|
||||||
|
ORDER BY g.detected_at DESC, g.id
|
||||||
|
LIMIT $2 OFFSET $1
|
||||||
|
)
|
||||||
|
SELECT p.id AS group_id,
|
||||||
|
p.tier,
|
||||||
|
p.worst_bit_error_rate,
|
||||||
|
p.detected_at,
|
||||||
|
t.id AS track_id,
|
||||||
|
t.title,
|
||||||
|
artists.name AS artist_name,
|
||||||
|
albums.id AS album_id,
|
||||||
|
albums.title AS album_title,
|
||||||
|
t.file_path,
|
||||||
|
t.file_format,
|
||||||
|
t.file_size,
|
||||||
|
t.duration_ms,
|
||||||
|
t.added_at,
|
||||||
|
(SELECT count(*) FROM general_likes l WHERE l.track_id = t.id)::bigint AS like_count,
|
||||||
|
(SELECT count(*) FROM play_events e WHERE e.track_id = t.id)::bigint AS play_count
|
||||||
|
FROM page p
|
||||||
|
JOIN duplicate_group_members m ON m.group_id = p.id
|
||||||
|
JOIN tracks t ON t.id = m.track_id
|
||||||
|
JOIN albums ON albums.id = t.album_id
|
||||||
|
JOIN artists ON artists.id = t.artist_id
|
||||||
|
ORDER BY p.detected_at DESC, p.id, t.id
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListPendingDuplicateGroupMembersParams struct {
|
||||||
|
PageOffset int32
|
||||||
|
PageLimit int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListPendingDuplicateGroupMembersRow struct {
|
||||||
|
GroupID pgtype.UUID
|
||||||
|
Tier string
|
||||||
|
WorstBitErrorRate *float32
|
||||||
|
DetectedAt pgtype.Timestamptz
|
||||||
|
TrackID pgtype.UUID
|
||||||
|
Title string
|
||||||
|
ArtistName string
|
||||||
|
AlbumID pgtype.UUID
|
||||||
|
AlbumTitle string
|
||||||
|
FilePath string
|
||||||
|
FileFormat string
|
||||||
|
FileSize int64
|
||||||
|
DurationMs int32
|
||||||
|
AddedAt pgtype.Timestamptz
|
||||||
|
LikeCount int64
|
||||||
|
PlayCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// One page of proposals, newest first, flattened to one row per member so the
|
||||||
|
// handler folds them without a query per group. What each copy carries — likes
|
||||||
|
// and plays from every user — is here because it is what the operator weighs
|
||||||
|
// when deciding which copy to keep.
|
||||||
|
func (q *Queries) ListPendingDuplicateGroupMembers(ctx context.Context, arg ListPendingDuplicateGroupMembersParams) ([]ListPendingDuplicateGroupMembersRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listPendingDuplicateGroupMembers, arg.PageOffset, arg.PageLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListPendingDuplicateGroupMembersRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListPendingDuplicateGroupMembersRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.GroupID,
|
||||||
|
&i.Tier,
|
||||||
|
&i.WorstBitErrorRate,
|
||||||
|
&i.DetectedAt,
|
||||||
|
&i.TrackID,
|
||||||
|
&i.Title,
|
||||||
|
&i.ArtistName,
|
||||||
|
&i.AlbumID,
|
||||||
|
&i.AlbumTitle,
|
||||||
|
&i.FilePath,
|
||||||
|
&i.FileFormat,
|
||||||
|
&i.FileSize,
|
||||||
|
&i.DurationMs,
|
||||||
|
&i.AddedAt,
|
||||||
|
&i.LikeCount,
|
||||||
|
&i.PlayCount,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const startDuplicateSweep = `-- name: StartDuplicateSweep :one
|
const startDuplicateSweep = `-- name: StartDuplicateSweep :one
|
||||||
INSERT INTO duplicate_sweeps DEFAULT VALUES RETURNING id, started_at
|
INSERT INTO duplicate_sweeps DEFAULT VALUES RETURNING id, started_at
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP INDEX IF EXISTS play_events_track_idx;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- 0060_play_events_track_index.up.sql — play_events by track (Scribe #3912, #3911).
|
||||||
|
--
|
||||||
|
-- play_events is indexed by (user_id, started_at) and (user_id, track_id), both
|
||||||
|
-- led by user. Nothing reached it by track alone until the duplicates report,
|
||||||
|
-- which shows each copy's play count — a scan of the whole table per copy — and
|
||||||
|
-- the merge (#3911), which repoints a duplicate's play history onto the copy
|
||||||
|
-- being kept. Both ask "every play of this track", whoever played it.
|
||||||
|
CREATE INDEX play_events_track_idx ON play_events (track_id);
|
||||||
@@ -98,3 +98,55 @@ DELETE FROM duplicate_groups g
|
|||||||
AND (g.last_seen_sweep_id IS NULL
|
AND (g.last_seen_sweep_id IS NULL
|
||||||
OR (SELECT s.started_at FROM duplicate_sweeps s WHERE s.id = g.last_seen_sweep_id)
|
OR (SELECT s.started_at FROM duplicate_sweeps s WHERE s.id = g.last_seen_sweep_id)
|
||||||
< (SELECT s.started_at FROM duplicate_sweeps s WHERE s.id = sqlc.arg(sweep_id)));
|
< (SELECT s.started_at FROM duplicate_sweeps s WHERE s.id = sqlc.arg(sweep_id)));
|
||||||
|
|
||||||
|
-- name: CountPendingDuplicateGroups :one
|
||||||
|
-- Proposals awaiting review. A group left with one member — its other tracks
|
||||||
|
-- deleted since the sweep — is no proposal at all and is not counted; the next
|
||||||
|
-- sweep retires it.
|
||||||
|
SELECT count(*)::bigint
|
||||||
|
FROM duplicate_groups g
|
||||||
|
WHERE g.status = 'pending'
|
||||||
|
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2;
|
||||||
|
|
||||||
|
-- name: ListPendingDuplicateGroupMembers :many
|
||||||
|
-- One page of proposals, newest first, flattened to one row per member so the
|
||||||
|
-- handler folds them without a query per group. What each copy carries — likes
|
||||||
|
-- and plays from every user — is here because it is what the operator weighs
|
||||||
|
-- when deciding which copy to keep.
|
||||||
|
WITH page AS (
|
||||||
|
SELECT g.id, g.tier, g.worst_bit_error_rate, g.detected_at
|
||||||
|
FROM duplicate_groups g
|
||||||
|
WHERE g.status = 'pending'
|
||||||
|
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2
|
||||||
|
ORDER BY g.detected_at DESC, g.id
|
||||||
|
LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset)
|
||||||
|
)
|
||||||
|
SELECT p.id AS group_id,
|
||||||
|
p.tier,
|
||||||
|
p.worst_bit_error_rate,
|
||||||
|
p.detected_at,
|
||||||
|
t.id AS track_id,
|
||||||
|
t.title,
|
||||||
|
artists.name AS artist_name,
|
||||||
|
albums.id AS album_id,
|
||||||
|
albums.title AS album_title,
|
||||||
|
t.file_path,
|
||||||
|
t.file_format,
|
||||||
|
t.file_size,
|
||||||
|
t.duration_ms,
|
||||||
|
t.added_at,
|
||||||
|
(SELECT count(*) FROM general_likes l WHERE l.track_id = t.id)::bigint AS like_count,
|
||||||
|
(SELECT count(*) FROM play_events e WHERE e.track_id = t.id)::bigint AS play_count
|
||||||
|
FROM page p
|
||||||
|
JOIN duplicate_group_members m ON m.group_id = p.id
|
||||||
|
JOIN tracks t ON t.id = m.track_id
|
||||||
|
JOIN albums ON albums.id = t.album_id
|
||||||
|
JOIN artists ON artists.id = t.artist_id
|
||||||
|
ORDER BY p.detected_at DESC, p.id, t.id;
|
||||||
|
|
||||||
|
-- name: DismissDuplicateGroup :execrows
|
||||||
|
-- "These are not duplicates." Only a pending group can be dismissed; zero rows
|
||||||
|
-- means it was already resolved or no longer exists.
|
||||||
|
UPDATE duplicate_groups
|
||||||
|
SET status = 'dismissed', resolved_at = now()
|
||||||
|
WHERE id = sqlc.arg(id) AND status = 'pending';
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SurvivorCandidate is what choosing which copy to keep needs to know about one
|
||||||
|
// member of a duplicate group.
|
||||||
|
type SurvivorCandidate struct {
|
||||||
|
TrackID string
|
||||||
|
FileFormat string
|
||||||
|
FileSize int64
|
||||||
|
AddedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// losslessFormats are the scanned extensions that are lossless by definition.
|
||||||
|
// m4a is left out on purpose: it holds either ALAC or AAC, and the scanner
|
||||||
|
// records only the extension, so calling it lossless would sometimes prefer an
|
||||||
|
// AAC copy over a FLAC one.
|
||||||
|
var losslessFormats = map[string]bool{"flac": true, "wav": true}
|
||||||
|
|
||||||
|
// ProposeSurvivor picks which copy of a duplicate group to keep, and gives the
|
||||||
|
// reason in words the operator reads beside it. It is a default, not a verdict:
|
||||||
|
// the report shows it and the merge (#3911) lets the operator choose another.
|
||||||
|
//
|
||||||
|
// In order:
|
||||||
|
// 1. lossless over lossy — the one difference no later step can recover
|
||||||
|
// 2. the larger file — for one recording at one duration that is the higher
|
||||||
|
// bitrate. The scanner does not record bitrate (tracks.bitrate is never
|
||||||
|
// filled), so file size is the signal that actually exists
|
||||||
|
// 3. the copy in the library longest — the one most likely to carry the play
|
||||||
|
// history and likes, so the merge moves the least
|
||||||
|
// 4. the lowest track id, so the choice is stable between page loads
|
||||||
|
func ProposeSurvivor(cands []SurvivorCandidate) (trackID, reason string) {
|
||||||
|
if len(cands) == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
ranked := append([]SurvivorCandidate(nil), cands...)
|
||||||
|
sort.SliceStable(ranked, func(i, j int) bool { return survivorBefore(ranked[i], ranked[j]) })
|
||||||
|
best := ranked[0]
|
||||||
|
if len(ranked) == 1 {
|
||||||
|
return best.TrackID, "the only copy"
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reason names the first rule that separated the best copy from the
|
||||||
|
// runner-up — the rule that actually decided, not every rule it passed.
|
||||||
|
next := ranked[1]
|
||||||
|
switch {
|
||||||
|
case isLossless(best) != isLossless(next):
|
||||||
|
return best.TrackID, "lossless (" + strings.ToLower(best.FileFormat) + ")"
|
||||||
|
case best.FileSize != next.FileSize:
|
||||||
|
return best.TrackID, "largest file"
|
||||||
|
case !best.AddedAt.Equal(next.AddedAt):
|
||||||
|
return best.TrackID, "in the library longest"
|
||||||
|
default:
|
||||||
|
return best.TrackID, "copies are otherwise identical"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func survivorBefore(a, b SurvivorCandidate) bool {
|
||||||
|
if isLossless(a) != isLossless(b) {
|
||||||
|
return isLossless(a)
|
||||||
|
}
|
||||||
|
if a.FileSize != b.FileSize {
|
||||||
|
return a.FileSize > b.FileSize
|
||||||
|
}
|
||||||
|
if !a.AddedAt.Equal(b.AddedAt) {
|
||||||
|
return a.AddedAt.Before(b.AddedAt)
|
||||||
|
}
|
||||||
|
return a.TrackID < b.TrackID
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLossless(c SurvivorCandidate) bool {
|
||||||
|
return losslessFormats[strings.ToLower(c.FileFormat)]
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProposeSurvivor(t *testing.T) {
|
||||||
|
older := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
newer := older.Add(24 * time.Hour)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cands []SurvivorCandidate
|
||||||
|
wantID string
|
||||||
|
wantReason string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// Lossless wins even against a much larger lossy file, and even
|
||||||
|
// when the lossy copy has been in the library longer.
|
||||||
|
name: "lossless beats larger and older",
|
||||||
|
cands: []SurvivorCandidate{
|
||||||
|
{TrackID: "mp3", FileFormat: "mp3", FileSize: 90_000_000, AddedAt: older},
|
||||||
|
{TrackID: "flac", FileFormat: "FLAC", FileSize: 30_000_000, AddedAt: newer},
|
||||||
|
},
|
||||||
|
wantID: "flac", wantReason: "lossless (flac)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// m4a may be AAC; it must not outrank an mp3 just for being m4a.
|
||||||
|
name: "m4a is not treated as lossless",
|
||||||
|
cands: []SurvivorCandidate{
|
||||||
|
{TrackID: "m4a", FileFormat: "m4a", FileSize: 5_000_000, AddedAt: older},
|
||||||
|
{TrackID: "mp3", FileFormat: "mp3", FileSize: 9_000_000, AddedAt: newer},
|
||||||
|
},
|
||||||
|
wantID: "mp3", wantReason: "largest file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "larger file wins among lossy copies",
|
||||||
|
cands: []SurvivorCandidate{
|
||||||
|
{TrackID: "128k", FileFormat: "mp3", FileSize: 3_400_000, AddedAt: older},
|
||||||
|
{TrackID: "320k", FileFormat: "mp3", FileSize: 8_600_000, AddedAt: newer},
|
||||||
|
},
|
||||||
|
wantID: "320k", wantReason: "largest file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The #3885 pair: identical audio, sizes equal but for the tags.
|
||||||
|
name: "the longest-standing copy wins when size ties",
|
||||||
|
cands: []SurvivorCandidate{
|
||||||
|
{TrackID: "www-02", FileFormat: "mp3", FileSize: 6_900_000, AddedAt: newer},
|
||||||
|
{TrackID: "www-01", FileFormat: "mp3", FileSize: 6_900_000, AddedAt: older},
|
||||||
|
},
|
||||||
|
wantID: "www-01", wantReason: "in the library longest",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a full tie falls back to the lowest id, stably",
|
||||||
|
cands: []SurvivorCandidate{
|
||||||
|
{TrackID: "b", FileFormat: "mp3", FileSize: 1, AddedAt: older},
|
||||||
|
{TrackID: "a", FileFormat: "mp3", FileSize: 1, AddedAt: older},
|
||||||
|
},
|
||||||
|
wantID: "a", wantReason: "copies are otherwise identical",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one copy",
|
||||||
|
cands: []SurvivorCandidate{{TrackID: "only", FileFormat: "mp3", FileSize: 1, AddedAt: older}},
|
||||||
|
wantID: "only", wantReason: "the only copy",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
id, reason := ProposeSurvivor(tc.cands)
|
||||||
|
if id != tc.wantID || reason != tc.wantReason {
|
||||||
|
t.Fatalf("ProposeSurvivor = (%q, %q), want (%q, %q)", id, reason, tc.wantID, tc.wantReason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reason must name the rule that decided. Across three copies that is the
|
||||||
|
// comparison between first and second place, not the first rule any pair
|
||||||
|
// differs on: here the lossy copy differs from the others by format, but the
|
||||||
|
// two FLACs are separated by size.
|
||||||
|
func TestProposeSurvivor_ReasonIsTheDecidingRule(t *testing.T) {
|
||||||
|
at := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
id, reason := ProposeSurvivor([]SurvivorCandidate{
|
||||||
|
{TrackID: "mp3", FileFormat: "mp3", FileSize: 99_000_000, AddedAt: at},
|
||||||
|
{TrackID: "flac-small", FileFormat: "flac", FileSize: 20_000_000, AddedAt: at},
|
||||||
|
{TrackID: "flac-big", FileFormat: "flac", FileSize: 40_000_000, AddedAt: at},
|
||||||
|
})
|
||||||
|
if id != "flac-big" || reason != "largest file" {
|
||||||
|
t.Fatalf("got (%q, %q), want (flac-big, largest file)", id, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { dismissDuplicateGroup, listDuplicates, runDuplicateSweep } from './admin';
|
||||||
|
|
||||||
|
vi.mock('./client', () => ({
|
||||||
|
api: { get: vi.fn(), post: vi.fn() }
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
describe('admin duplicates API', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it('listDuplicates GETs the paged report', async () => {
|
||||||
|
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ groups: [] });
|
||||||
|
await listDuplicates(25, 25);
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/admin/library/duplicates?limit=25&offset=25');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runDuplicateSweep POSTs the trigger', async () => {
|
||||||
|
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ started: true });
|
||||||
|
await runDuplicateSweep();
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/api/admin/library/duplicates/sweep', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismissDuplicateGroup POSTs to the group', async () => {
|
||||||
|
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
|
||||||
|
await dismissDuplicateGroup('g/1');
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/api/admin/library/duplicates/g%2F1/dismiss', {});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { qk } from './queries';
|
|||||||
import type {
|
import type {
|
||||||
ActionResult,
|
ActionResult,
|
||||||
AdminMissingResponse,
|
AdminMissingResponse,
|
||||||
|
AdminDuplicatesResponse,
|
||||||
AdminPlaybackError,
|
AdminPlaybackError,
|
||||||
AdminQuarantineRow,
|
AdminQuarantineRow,
|
||||||
LidarrConfig,
|
LidarrConfig,
|
||||||
@@ -695,6 +696,37 @@ export async function updateNetworkSettings(hops: number): Promise<NetworkSettin
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Duplicates report (#3912) -------------------------------------------------
|
||||||
|
|
||||||
|
export async function listDuplicates(
|
||||||
|
offset: number = 0,
|
||||||
|
limit: number = 25
|
||||||
|
): Promise<AdminDuplicatesResponse> {
|
||||||
|
return api.get<AdminDuplicatesResponse>(
|
||||||
|
`/api/admin/library/duplicates?limit=${limit}&offset=${offset}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Takes a plain offset, like createMissingFilesQuery, so a $derived caller
|
||||||
|
// re-creates the query on paging. Polls while a sweep might be running: the
|
||||||
|
// page is where the operator waits for one to finish.
|
||||||
|
export function createDuplicatesQuery(offset: number = 0, limit: number = 25) {
|
||||||
|
return createQuery({
|
||||||
|
queryKey: qk.adminDuplicates(offset),
|
||||||
|
queryFn: () => listDuplicates(offset, limit),
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchInterval: 15_000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runDuplicateSweep(): Promise<{ started: boolean }> {
|
||||||
|
return api.post<{ started: boolean }>('/api/admin/library/duplicates/sweep', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dismissDuplicateGroup(id: string): Promise<void> {
|
||||||
|
await api.post(`/api/admin/library/duplicates/${encodeURIComponent(id)}/dismiss`, {});
|
||||||
|
}
|
||||||
|
|
||||||
// Missing files (#2527) -----------------------------------------------------
|
// Missing files (#2527) -----------------------------------------------------
|
||||||
|
|
||||||
export async function listMissingFiles(
|
export async function listMissingFiles(
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export const qk = {
|
|||||||
['adminDiagnostics', f] as const,
|
['adminDiagnostics', f] as const,
|
||||||
adminMissingFiles: (offset?: number) =>
|
adminMissingFiles: (offset?: number) =>
|
||||||
['adminMissingFiles', { offset: offset ?? 0 }] as const,
|
['adminMissingFiles', { offset: offset ?? 0 }] as const,
|
||||||
|
adminDuplicates: (offset?: number) =>
|
||||||
|
['adminDuplicates', { offset: offset ?? 0 }] as const,
|
||||||
adminDiagnosticDevices: (userId?: string) =>
|
adminDiagnosticDevices: (userId?: string) =>
|
||||||
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
|
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
|
||||||
smtpConfig: () => ['smtpConfig'] as const,
|
smtpConfig: () => ['smtpConfig'] as const,
|
||||||
|
|||||||
@@ -418,3 +418,53 @@ export type AdminMissingResponse = {
|
|||||||
offset: number;
|
offset: number;
|
||||||
groups: AdminMissingGroup[];
|
groups: AdminMissingGroup[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Duplicates report (#3912) -------------------------------------------------
|
||||||
|
|
||||||
|
// One copy in a proposed duplicate group. like_count and play_count cover every
|
||||||
|
// user: they are what the operator weighs when choosing which copy to keep.
|
||||||
|
export type AdminDuplicateMember = {
|
||||||
|
track_id: string;
|
||||||
|
title: string;
|
||||||
|
artist_name: string;
|
||||||
|
album_id: string;
|
||||||
|
album_title: string;
|
||||||
|
file_path: string;
|
||||||
|
file_format: string;
|
||||||
|
file_size: number;
|
||||||
|
duration_sec: number;
|
||||||
|
added_at: string;
|
||||||
|
like_count: number;
|
||||||
|
play_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// exact: identical encoded audio. acoustic: the same recording, differently
|
||||||
|
// encoded; worst_bit_error_rate is the weakest link between any two members.
|
||||||
|
export type AdminDuplicateGroup = {
|
||||||
|
id: string;
|
||||||
|
tier: 'exact' | 'acoustic';
|
||||||
|
worst_bit_error_rate: number | null;
|
||||||
|
detected_at: string;
|
||||||
|
survivor_track_id: string;
|
||||||
|
survivor_reason: string;
|
||||||
|
members: AdminDuplicateMember[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminDuplicateSweep = {
|
||||||
|
state: 'never' | 'running' | 'finished';
|
||||||
|
started_at: string | null;
|
||||||
|
finished_at: string | null;
|
||||||
|
candidates: number | null;
|
||||||
|
groups_found: number | null;
|
||||||
|
oversize_clusters: number | null;
|
||||||
|
error_message: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminDuplicatesResponse = {
|
||||||
|
sweep: AdminDuplicateSweep;
|
||||||
|
fingerprints: { total: number; fingerprinted: number; rejected: number; pending: number };
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
groups: AdminDuplicateGroup[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
{ href: '/admin/requests', label: 'Requests' },
|
{ href: '/admin/requests', label: 'Requests' },
|
||||||
{ href: '/admin/quarantine', label: 'Quarantine' },
|
{ href: '/admin/quarantine', label: 'Quarantine' },
|
||||||
{ href: '/admin/missing-files', label: 'Missing files' },
|
{ href: '/admin/missing-files', label: 'Missing files' },
|
||||||
|
{ href: '/admin/duplicates', label: 'Duplicates' },
|
||||||
{ href: '/admin/playback-errors', label: 'Playback errors' },
|
{ href: '/admin/playback-errors', label: 'Playback errors' },
|
||||||
{ href: '/admin/diagnostics', label: 'Diagnostics' },
|
{ href: '/admin/diagnostics', label: 'Diagnostics' },
|
||||||
{ href: '/admin/tuning', label: 'Tuning' },
|
{ href: '/admin/tuning', label: 'Tuning' },
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ describe('AdminTabs', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders all nine tabs in order', () => {
|
test('renders all ten tabs in order', () => {
|
||||||
state.pageUrl = new URL('http://localhost/admin');
|
state.pageUrl = new URL('http://localhost/admin');
|
||||||
render(AdminTabs);
|
render(AdminTabs);
|
||||||
const links = screen.getAllByRole('link');
|
const links = screen.getAllByRole('link');
|
||||||
@@ -64,6 +64,9 @@ describe('AdminTabs', () => {
|
|||||||
// Missing files sits with Quarantine and Playback errors: the three
|
// Missing files sits with Quarantine and Playback errors: the three
|
||||||
// surfaces that show tracks needing an operator's attention.
|
// surfaces that show tracks needing an operator's attention.
|
||||||
'Missing files',
|
'Missing files',
|
||||||
|
// Duplicates follows Missing files: both are library-health reports on
|
||||||
|
// what the library holds, rather than a queue of user reports.
|
||||||
|
'Duplicates',
|
||||||
'Playback errors',
|
'Playback errors',
|
||||||
'Diagnostics',
|
'Diagnostics',
|
||||||
'Tuning',
|
'Tuning',
|
||||||
|
|||||||
@@ -42,6 +42,8 @@
|
|||||||
"track_not_found": "That track no longer exists.",
|
"track_not_found": "That track no longer exists.",
|
||||||
"library_not_writable": "The music library isn't writable by the server.",
|
"library_not_writable": "The music library isn't writable by the server.",
|
||||||
"file_delete_failed": "The file couldn't be deleted.",
|
"file_delete_failed": "The file couldn't be deleted.",
|
||||||
|
"sweep_in_progress": "A duplicate sweep is already running.",
|
||||||
|
"duplicate_group_not_pending": "That group has already been resolved.",
|
||||||
"album_not_found": "That album no longer exists.",
|
"album_not_found": "That album no longer exists.",
|
||||||
"artist_not_found": "That artist no longer exists.",
|
"artist_not_found": "That artist no longer exists.",
|
||||||
"playlist_not_found": "That playlist no longer exists.",
|
"playlist_not_found": "That playlist no longer exists.",
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { pageTitle } from '$lib/branding';
|
||||||
|
import { Copy } from 'lucide-svelte';
|
||||||
|
import {
|
||||||
|
createDuplicatesQuery,
|
||||||
|
runDuplicateSweep,
|
||||||
|
dismissDuplicateGroup
|
||||||
|
} from '$lib/api/admin';
|
||||||
|
import { errMessage } from '$lib/api/errors';
|
||||||
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
|
import { relativeTime } from '$lib/utils/relativeTime';
|
||||||
|
import type { AdminDuplicateGroup, AdminDuplicateMember } from '$lib/api/types';
|
||||||
|
|
||||||
|
// Tracks the duplicate sweep believes hold one recording (#3912). A group is a
|
||||||
|
// proposal: nothing here deletes or merges. Dismissing one says "these are not
|
||||||
|
// duplicates", and the sweep will not propose that set again.
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
let offset = $state(0);
|
||||||
|
let sweeping = $state(false);
|
||||||
|
let dismissing = $state<string | null>(null);
|
||||||
|
|
||||||
|
const queryStore = $derived(createDuplicatesQuery(offset, PAGE_SIZE));
|
||||||
|
const query = $derived($queryStore);
|
||||||
|
const data = $derived(query.data);
|
||||||
|
const groups = $derived((data?.groups ?? []) as AdminDuplicateGroup[]);
|
||||||
|
const total = $derived(data?.total ?? 0);
|
||||||
|
const hasMore = $derived(offset + groups.length < total);
|
||||||
|
const sweep = $derived(data?.sweep);
|
||||||
|
const prints = $derived(data?.fingerprints);
|
||||||
|
|
||||||
|
async function onRunSweep() {
|
||||||
|
sweeping = true;
|
||||||
|
try {
|
||||||
|
await runDuplicateSweep();
|
||||||
|
pushToast('Duplicate sweep started.');
|
||||||
|
query.refetch();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
pushToast(errMessage(e), 'error');
|
||||||
|
} finally {
|
||||||
|
sweeping = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDismiss(group: AdminDuplicateGroup) {
|
||||||
|
dismissing = group.id;
|
||||||
|
try {
|
||||||
|
await dismissDuplicateGroup(group.id);
|
||||||
|
pushToast('Marked as not duplicates.');
|
||||||
|
query.refetch();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
pushToast(errMessage(e), 'error');
|
||||||
|
} finally {
|
||||||
|
dismissing = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Identical audio" and "same recording" are different claims, and an
|
||||||
|
// operator deciding whether to merge needs to know which one they are
|
||||||
|
// looking at before anything else.
|
||||||
|
function tierLabel(g: AdminDuplicateGroup): string {
|
||||||
|
if (g.tier === 'exact') return 'Identical audio';
|
||||||
|
const match = Math.round((1 - (g.worst_bit_error_rate ?? 0)) * 100);
|
||||||
|
return `Same recording · ${match}% match`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sizeLabel(bytes: number): string {
|
||||||
|
return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationLabel(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countLabel(n: number, one: string, many: string): string {
|
||||||
|
return n === 1 ? `1 ${one}` : `${n} ${many}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the copy carries is the fact that decides which to keep.
|
||||||
|
function historyLabel(m: AdminDuplicateMember): string {
|
||||||
|
if (m.like_count === 0 && m.play_count === 0) return 'no likes or plays';
|
||||||
|
return `${countLabel(m.like_count, 'like', 'likes')} · ${countLabel(m.play_count, 'play', 'plays')}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>{pageTitle('Admin · Duplicates')}</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<header class="space-y-1">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h2 class="font-display text-2xl font-medium text-text-primary">Duplicates</h2>
|
||||||
|
{#if total > 0}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent"
|
||||||
|
data-testid="duplicates-count-pill"
|
||||||
|
>
|
||||||
|
{total}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onRunSweep}
|
||||||
|
disabled={sweeping || sweep?.state === 'running'}
|
||||||
|
class="flex h-8 items-center gap-1 rounded-md bg-action-primary px-4 text-sm text-action-fg hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{sweeping ? 'Starting…' : 'Sweep now'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-text-secondary">
|
||||||
|
Tracks that hold the same recording more than once. Review each group; nothing is
|
||||||
|
merged or removed from here.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if sweep}
|
||||||
|
<p class="text-sm text-text-secondary" data-testid="sweep-status">
|
||||||
|
{#if sweep.state === 'never'}
|
||||||
|
The duplicate sweep hasn't run yet.
|
||||||
|
{:else if sweep.state === 'running'}
|
||||||
|
Sweeping now — started {relativeTime(sweep.started_at ?? '')}.
|
||||||
|
{:else}
|
||||||
|
Last swept {relativeTime(sweep.finished_at ?? '')}, comparing
|
||||||
|
{(sweep.candidates ?? 0).toLocaleString()} tracks.
|
||||||
|
{#if sweep.error_message}
|
||||||
|
<span class="text-oxblood">It stopped early: {sweep.error_message}</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if prints && prints.pending > 0}
|
||||||
|
{prints.pending.toLocaleString()} tracks are still waiting for a fingerprint and join
|
||||||
|
the comparison once they have one.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if query.isPending}
|
||||||
|
<p class="text-text-secondary">Loading duplicates…</p>
|
||||||
|
{:else if query.isError}
|
||||||
|
<p class="text-error">Couldn't load the duplicates report.</p>
|
||||||
|
{:else if groups.length === 0}
|
||||||
|
<!-- Three states all show zero groups and mean different things. Telling
|
||||||
|
them apart is what stops an empty page reading as a broken feature. -->
|
||||||
|
<div class="rounded-lg border border-border bg-surface p-6 text-center" data-testid="empty-state">
|
||||||
|
<Copy size={28} strokeWidth={1} class="mx-auto text-text-muted" />
|
||||||
|
{#if prints && prints.total > 0 && prints.fingerprinted === 0}
|
||||||
|
<p class="mt-3 text-text-primary">Nothing to compare yet.</p>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
The library is still being fingerprinted — {prints.pending.toLocaleString()} tracks to go.
|
||||||
|
Duplicates appear here as the sweep finds them.
|
||||||
|
</p>
|
||||||
|
{:else if sweep?.state === 'never'}
|
||||||
|
<p class="mt-3 text-text-primary">The sweep hasn't run yet.</p>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
It runs on its own whenever new fingerprints arrive, or now if you start it.
|
||||||
|
</p>
|
||||||
|
{:else if sweep?.state === 'running'}
|
||||||
|
<p class="mt-3 text-text-primary">Sweeping…</p>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">Anything it finds will appear here.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-3 text-text-primary">No duplicates found.</p>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
A group appears when two tracks hold identical audio, or the same recording in a
|
||||||
|
different encoding. Groups you dismiss don't come back.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-4">
|
||||||
|
{#each groups as group (group.id)}
|
||||||
|
<li class="overflow-hidden rounded-lg border border-border bg-surface" data-testid="duplicate-group">
|
||||||
|
<div class="flex items-center justify-between gap-4 border-b border-border px-4 py-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3 class="text-sm text-text-primary" data-testid="duplicate-tier">{tierLabel(group)}</h3>
|
||||||
|
<p class="text-xs text-text-secondary">Found {relativeTime(group.detected_at)}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => onDismiss(group)}
|
||||||
|
disabled={dismissing === group.id}
|
||||||
|
class="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Not duplicates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="divide-y divide-border">
|
||||||
|
{#each group.members as m (m.track_id)}
|
||||||
|
{@const keep = m.track_id === group.survivor_track_id}
|
||||||
|
<li class="flex items-start gap-3 px-4 py-3" data-testid="duplicate-member">
|
||||||
|
<div class="min-w-0 flex-1 space-y-0.5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="truncate text-sm text-text-primary">{m.title}</span>
|
||||||
|
{#if keep}
|
||||||
|
<!-- The proposed copy to keep, with the rule that chose it.
|
||||||
|
A default the merge will let the operator override. -->
|
||||||
|
<span
|
||||||
|
class="shrink-0 rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent"
|
||||||
|
data-testid="survivor-badge"
|
||||||
|
title="Proposed to keep: {group.survivor_reason}"
|
||||||
|
>
|
||||||
|
Keep · {group.survivor_reason}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="truncate text-xs text-text-secondary">
|
||||||
|
{m.artist_name} · {m.album_title}
|
||||||
|
</div>
|
||||||
|
<div class="truncate font-mono text-xs text-text-muted" title={m.file_path}>
|
||||||
|
{m.file_path}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0 space-y-0.5 text-right text-xs text-text-muted">
|
||||||
|
<div>{m.file_format.toUpperCase()} · {sizeLabel(m.file_size)} · {durationLabel(m.duration_sec)}</div>
|
||||||
|
<div data-testid="member-history">{historyLabel(m)}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if hasMore || offset > 0}
|
||||||
|
<nav class="flex items-center justify-between" aria-label="Duplicates pages">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
disabled={offset === 0}
|
||||||
|
onclick={() => (offset = Math.max(0, offset - PAGE_SIZE))}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-text-secondary">
|
||||||
|
{offset + 1}–{offset + groups.length} of {total}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
disabled={!hasMore}
|
||||||
|
onclick={() => (offset += PAGE_SIZE)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/svelte';
|
||||||
|
import { mockQuery } from '../../../test-utils/query';
|
||||||
|
import type { AdminDuplicatesResponse } from '$lib/api/types';
|
||||||
|
|
||||||
|
vi.mock('$lib/api/admin', () => ({
|
||||||
|
createDuplicatesQuery: vi.fn(),
|
||||||
|
runDuplicateSweep: vi.fn().mockResolvedValue({ started: true }),
|
||||||
|
dismissDuplicateGroup: vi.fn().mockResolvedValue(undefined)
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AdminDuplicatesPage from './+page.svelte';
|
||||||
|
import { createDuplicatesQuery, dismissDuplicateGroup, runDuplicateSweep } from '$lib/api/admin';
|
||||||
|
|
||||||
|
const HOUR = 3_600_000;
|
||||||
|
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
|
||||||
|
|
||||||
|
const finishedSweep = {
|
||||||
|
state: 'finished' as const,
|
||||||
|
started_at: ago(2 * HOUR),
|
||||||
|
finished_at: ago(HOUR),
|
||||||
|
candidates: 1200,
|
||||||
|
groups_found: 1,
|
||||||
|
oversize_clusters: 0,
|
||||||
|
error_message: null
|
||||||
|
};
|
||||||
|
const allFingerprinted = { total: 1200, fingerprinted: 1200, rejected: 0, pending: 0 };
|
||||||
|
|
||||||
|
function member(id: string, extra: Partial<AdminDuplicatesResponse['groups'][number]['members'][number]> = {}) {
|
||||||
|
return {
|
||||||
|
track_id: id,
|
||||||
|
title: 'WWW',
|
||||||
|
artist_name: 'Moe Shop',
|
||||||
|
album_id: 'al-1',
|
||||||
|
album_title: 'WWW',
|
||||||
|
file_path: `/music/Moe Shop/WWW (2020)/${id}.mp3`,
|
||||||
|
file_format: 'mp3',
|
||||||
|
file_size: 6_900_000,
|
||||||
|
duration_sec: 215,
|
||||||
|
added_at: ago(500 * HOUR),
|
||||||
|
like_count: 0,
|
||||||
|
play_count: 0,
|
||||||
|
...extra
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function response(over: Partial<AdminDuplicatesResponse> = {}): AdminDuplicatesResponse {
|
||||||
|
return {
|
||||||
|
sweep: finishedSweep,
|
||||||
|
fingerprints: allFingerprinted,
|
||||||
|
total: 1,
|
||||||
|
limit: 25,
|
||||||
|
offset: 0,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 'g-1',
|
||||||
|
tier: 'exact',
|
||||||
|
worst_bit_error_rate: null,
|
||||||
|
detected_at: ago(HOUR),
|
||||||
|
survivor_track_id: 'www-01',
|
||||||
|
survivor_reason: 'in the library longest',
|
||||||
|
members: [member('www-01', { like_count: 2, play_count: 14 }), member('www-02')]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
...over
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWith(data: AdminDuplicatesResponse | undefined) {
|
||||||
|
vi.mocked(createDuplicatesQuery).mockReturnValue(
|
||||||
|
mockQuery({ data }) as ReturnType<typeof createDuplicatesQuery>
|
||||||
|
);
|
||||||
|
return render(AdminDuplicatesPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(el: HTMLElement): string {
|
||||||
|
return (el.textContent ?? '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
describe('admin duplicates', () => {
|
||||||
|
test('shows each group with its members, what they carry, and the copy to keep', () => {
|
||||||
|
renderWith(response());
|
||||||
|
expect(screen.getAllByTestId('duplicate-group')).toHaveLength(1);
|
||||||
|
expect(screen.getAllByTestId('duplicate-member')).toHaveLength(2);
|
||||||
|
expect(text(screen.getByTestId('duplicate-tier'))).toBe('Identical audio');
|
||||||
|
expect(text(screen.getByTestId('survivor-badge'))).toBe('Keep · in the library longest');
|
||||||
|
const history = screen.getAllByTestId('member-history').map(text);
|
||||||
|
expect(history).toEqual(['2 likes · 14 plays', 'no likes or plays']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The badge must sit on the proposed survivor, not merely appear somewhere.
|
||||||
|
test('the keep badge is on the survivor row', () => {
|
||||||
|
renderWith(response());
|
||||||
|
const rows = screen.getAllByTestId('duplicate-member');
|
||||||
|
expect(rows[0].querySelector('[data-testid="survivor-badge"]')).not.toBeNull();
|
||||||
|
expect(rows[1].querySelector('[data-testid="survivor-badge"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an acoustic group reads as a match percentage', () => {
|
||||||
|
const r = response();
|
||||||
|
r.groups[0] = { ...r.groups[0], tier: 'acoustic', worst_bit_error_rate: 0.04 };
|
||||||
|
renderWith(r);
|
||||||
|
expect(text(screen.getByTestId('duplicate-tier'))).toBe('Same recording · 96% match');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Three states all show zero groups. Each must say which it is.
|
||||||
|
test('empty while still fingerprinting says so', () => {
|
||||||
|
renderWith(
|
||||||
|
response({
|
||||||
|
groups: [],
|
||||||
|
total: 0,
|
||||||
|
fingerprints: { total: 1200, fingerprinted: 0, rejected: 0, pending: 1200 }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(text(screen.getByTestId('empty-state'))).toContain('still being fingerprinted');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty before any sweep says the sweep has not run', () => {
|
||||||
|
renderWith(
|
||||||
|
response({
|
||||||
|
groups: [],
|
||||||
|
total: 0,
|
||||||
|
sweep: { ...finishedSweep, state: 'never', started_at: null, finished_at: null, candidates: null }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(text(screen.getByTestId('empty-state'))).toContain("hasn't run yet");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty after a sweep says no duplicates were found', () => {
|
||||||
|
renderWith(response({ groups: [], total: 0 }));
|
||||||
|
expect(text(screen.getByTestId('empty-state'))).toContain('No duplicates found');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Not duplicates dismisses that group', async () => {
|
||||||
|
renderWith(response());
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'Not duplicates' }));
|
||||||
|
expect(dismissDuplicateGroup).toHaveBeenCalledWith('g-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Sweep now starts a sweep', async () => {
|
||||||
|
renderWith(response());
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'Sweep now' }));
|
||||||
|
expect(runDuplicateSweep).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user