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

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:
2026-09-11 17:11:11 -04:00
co-authored by Claude Opus 5
parent 6379b6c31d
commit ff493a8c7d
18 changed files with 1179 additions and 1 deletions
+30
View File
@@ -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', {});
});
});
+32
View File
@@ -4,6 +4,7 @@ import { qk } from './queries';
import type {
ActionResult,
AdminMissingResponse,
AdminDuplicatesResponse,
AdminPlaybackError,
AdminQuarantineRow,
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) -----------------------------------------------------
export async function listMissingFiles(
+2
View File
@@ -56,6 +56,8 @@ export const qk = {
['adminDiagnostics', f] as const,
adminMissingFiles: (offset?: number) =>
['adminMissingFiles', { offset: offset ?? 0 }] as const,
adminDuplicates: (offset?: number) =>
['adminDuplicates', { offset: offset ?? 0 }] as const,
adminDiagnosticDevices: (userId?: string) =>
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
smtpConfig: () => ['smtpConfig'] as const,
+50
View File
@@ -418,3 +418,53 @@ export type AdminMissingResponse = {
offset: number;
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[];
};