feat: duplicates resolve themselves where Lidarr says it is safe (M498)
release / govulncheck (push) Successful in 45s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 4m25s
release / android (push) Successful in 6m17s
release / Build signed APK (releases and dev) (push) Successful in 5m57s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m54s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 45s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 4m25s
release / android (push) Successful in 6m17s
release / Build signed APK (releases and dev) (push) Successful in 5m57s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m54s
release / Verify release artifacts (tag releases only) (push) Skipped
The duplicate sweep proposed 4,197 groups and every one waited for the operator. Most are safe to settle, and Lidarr defines what safe means: it maps one file to each track of the release it monitors and downloads any mapped file that disappears. Deleting a mapped copy opens exactly the hole the operator saw Lidarr fill. Classify (#5435) - Migration 0075: duplicate_groups.class (same_release, cross_release, mismatch, review), resolve_note, resolved_automatically; duplicate_group_members.lidarr_state (tracked, unmapped); fingerprint_settings.auto_resolve; notification kind duplicates_resolved with both kind CHECKs swapped (rule 36). - library.ClassifyDuplicateGroup, with MatchTitleKey dropping featuring credits, remaster notes and video-rip markers, and keeping live, demo, remix and instrumental. The rip markers move from api to library. Choose the copy to keep (#5436) - ProposeSurvivor ranks the copy Lidarr maps first, then tag fit (a clash-free track number, no rip marker in the name, an MBID), then the quality rules. File size picked the wrong Humanz copy in 6 of 21 groups. Act (#5437) - An hourly resolver pass reads Lidarr's unmapped files, matched by the last three path components, and records each copy's state. - Same album, with at most one copy mapped: merged into the mapped copy. The merge is guarded, so a mapped copy can never be removed (MergeDuplicateGroupGuarded, ErrCopyTrackedByLidarr). - Same album, every copy mapped: the monitored release lists the song twice (Humanz's 14x12" box set). The pass moves Lidarr to the release that lists each song once and best covers what is on disk. It never picks one covering less, and is capped at 10 albums per pass. - Fixed point (lesson #4183): the chosen release no longer repeats. - The album is left alone for 24h while Lidarr rescans, so "every copy unmapped" mid-rescan is never read as licence to merge. - Both actions are audited with no actor and summarised to admins. The operator can switch them off in the Fingerprinting card (rule 25). - Manual merges use the same guard: 409 copy_tracked_by_lidarr, or 503 lidarr_unavailable when Lidarr cannot say. Web - Duplicates gets tabs: Needs review, Across releases, Resolved automatically. Each loads as you scroll (rule 172), replacing the pager. - Each copy says whether Lidarr uses it. - The resolver's note shows on each group. - The merge confirm blocks, before sending, a merge that would remove the copy Lidarr uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
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.js';
|
||||
import { readable } from 'svelte/store';
|
||||
import type {
|
||||
AdminDuplicatesResponse,
|
||||
ResolvedDuplicateActivity,
|
||||
ResolvedDuplicatesResponse
|
||||
} from '#lib/api/types.js';
|
||||
|
||||
vi.mock('#lib/api/admin.js', () => ({
|
||||
createDuplicatesQuery: vi.fn(),
|
||||
createResolvedDuplicatesQuery: vi.fn(),
|
||||
runDuplicateSweep: vi.fn().mockResolvedValue({ started: true }),
|
||||
dismissDuplicateGroup: vi.fn().mockResolvedValue(undefined),
|
||||
mergeDuplicateGroup: vi.fn().mockResolvedValue({
|
||||
@@ -18,7 +23,8 @@ vi.mock('#lib/api/admin.js', () => ({
|
||||
chromaprint_length_sec: 120,
|
||||
acoustic_max_bit_error_rate: 0.15,
|
||||
backfill_concurrency: 2,
|
||||
sweep_interval_hours: 1
|
||||
sweep_interval_hours: 1,
|
||||
auto_resolve: true
|
||||
}),
|
||||
updateFingerprintSettings: vi.fn()
|
||||
}));
|
||||
@@ -26,6 +32,7 @@ vi.mock('#lib/api/admin.js', () => ({
|
||||
import AdminDuplicatesPage from './+page.svelte';
|
||||
import {
|
||||
createDuplicatesQuery,
|
||||
createResolvedDuplicatesQuery,
|
||||
dismissDuplicateGroup,
|
||||
mergeDuplicateGroup,
|
||||
runDuplicateSweep
|
||||
@@ -59,6 +66,9 @@ function member(id: string, extra: Partial<AdminDuplicatesResponse['groups'][num
|
||||
added_at: ago(500 * HOUR),
|
||||
like_count: 0,
|
||||
play_count: 0,
|
||||
lidarr_state: null,
|
||||
disc_number: null,
|
||||
track_number: null,
|
||||
...extra
|
||||
};
|
||||
}
|
||||
@@ -67,6 +77,8 @@ function response(over: Partial<AdminDuplicatesResponse> = {}): AdminDuplicatesR
|
||||
return {
|
||||
sweep: finishedSweep,
|
||||
fingerprints: allFingerprinted,
|
||||
view: 'review',
|
||||
counts: { review: 1, cross_release: 4, resolved: 2 },
|
||||
total: 1,
|
||||
limit: 25,
|
||||
offset: 0,
|
||||
@@ -78,6 +90,8 @@ function response(over: Partial<AdminDuplicatesResponse> = {}): AdminDuplicatesR
|
||||
detected_at: ago(HOUR),
|
||||
survivor_track_id: 'www-01',
|
||||
survivor_reason: 'in the library longest',
|
||||
class: 'review',
|
||||
resolve_note: null,
|
||||
members: [member('www-01', { like_count: 2, play_count: 14 }), member('www-02')]
|
||||
}
|
||||
],
|
||||
@@ -85,9 +99,30 @@ function response(over: Partial<AdminDuplicatesResponse> = {}): AdminDuplicatesR
|
||||
};
|
||||
}
|
||||
|
||||
function renderWith(data: AdminDuplicatesResponse | undefined) {
|
||||
// The page reads only these fields of an infinite query.
|
||||
function infinite<T>(pages: T[]) {
|
||||
return readable({
|
||||
data: { pages },
|
||||
isPending: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
isFetchNextPageError: false,
|
||||
fetchNextPage: () => {},
|
||||
refetch: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
function resolvedPage(items: ResolvedDuplicateActivity[]): ResolvedDuplicatesResponse {
|
||||
return { total: items.length, limit: 25, offset: 0, items };
|
||||
}
|
||||
|
||||
function renderWith(data: AdminDuplicatesResponse, resolved: ResolvedDuplicateActivity[] = []) {
|
||||
vi.mocked(createDuplicatesQuery).mockReturnValue(
|
||||
mockQuery({ data }) as ReturnType<typeof createDuplicatesQuery>
|
||||
infinite([data]) as unknown as ReturnType<typeof createDuplicatesQuery>
|
||||
);
|
||||
vi.mocked(createResolvedDuplicatesQuery).mockReturnValue(
|
||||
infinite([resolvedPage(resolved)]) as unknown as ReturnType<typeof createResolvedDuplicatesQuery>
|
||||
);
|
||||
return render(AdminDuplicatesPage);
|
||||
}
|
||||
@@ -223,9 +258,9 @@ describe('admin duplicates', () => {
|
||||
expect(text(screen.getByTestId('empty-state'))).toContain("hasn't run yet");
|
||||
});
|
||||
|
||||
test('empty after a sweep says no duplicates were found', () => {
|
||||
test('empty after a sweep says nothing needs review', () => {
|
||||
renderWith(response({ groups: [], total: 0 }));
|
||||
expect(text(screen.getByTestId('empty-state'))).toContain('No duplicates found');
|
||||
expect(text(screen.getByTestId('empty-state'))).toContain('Nothing needs review');
|
||||
});
|
||||
|
||||
test('Not duplicates dismisses that group', async () => {
|
||||
@@ -275,4 +310,86 @@ describe('admin duplicates', () => {
|
||||
expect(screen.queryByTestId('merge-confirm')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Merge…' })).toBeTruthy();
|
||||
});
|
||||
|
||||
// M498: the resolver's three outcomes are three tabs, each with its size.
|
||||
describe('tabs', () => {
|
||||
test('each tab shows its count, and Across releases asks for that view', async () => {
|
||||
renderWith(response());
|
||||
expect(screen.getByRole('tab', { name: /needs review/i })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getAllByTestId('tab-count').map(text)).toEqual(['1', '4', '2']);
|
||||
expect(createDuplicatesQuery).toHaveBeenLastCalledWith('review');
|
||||
|
||||
await fireEvent.click(screen.getByRole('tab', { name: /across releases/i }));
|
||||
expect(createDuplicatesQuery).toHaveBeenLastCalledWith('cross_release');
|
||||
});
|
||||
|
||||
test('Resolved automatically lists merges and release changes', async () => {
|
||||
renderWith(response(), [
|
||||
{
|
||||
id: 'a-1',
|
||||
action: 'duplicate_merge',
|
||||
created_at: ago(HOUR),
|
||||
details: {
|
||||
survivor_path: '/music/Gorillaz/Humanz (2017)/03 - Saturnz Barz.mp3',
|
||||
survivor_reason: 'the copy Lidarr tracks',
|
||||
removed: [{ track_id: 't-2', file_path: '/music/Gorillaz/Humanz (2017)/03 - Saturnz Barz (Official Video).mp3' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'a-2',
|
||||
action: 'lidarr_release_change',
|
||||
created_at: ago(2 * HOUR),
|
||||
details: {
|
||||
album_title: 'Humanz',
|
||||
artist_name: 'Gorillaz',
|
||||
from_release: '14x12" Vinyl, Digital Media (68 tracks)',
|
||||
to_release: 'Digital Media (40 tracks)'
|
||||
}
|
||||
}
|
||||
]);
|
||||
await fireEvent.click(screen.getByRole('tab', { name: /resolved automatically/i }));
|
||||
const items = screen.getAllByTestId('resolved-item').map(text);
|
||||
expect(items[0]).toContain('Kept 03 - Saturnz Barz.mp3 (the copy Lidarr tracks) and removed 1 file');
|
||||
expect(items[1]).toContain('Lidarr now monitors Digital Media (40 tracks) of Humanz by Gorillaz');
|
||||
expect(screen.queryByTestId('duplicate-group')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Lidarr's view of each copy", () => {
|
||||
function tracked() {
|
||||
const r = response();
|
||||
r.groups[0] = {
|
||||
...r.groups[0],
|
||||
resolve_note: 'Lidarr tracks every copy as its own track.',
|
||||
members: [member('www-01', { lidarr_state: 'tracked' }), member('www-02', { lidarr_state: 'unmapped' })]
|
||||
};
|
||||
return r;
|
||||
}
|
||||
|
||||
test('each copy says whether Lidarr uses it, and the resolver note shows', () => {
|
||||
renderWith(tracked());
|
||||
expect(screen.getAllByTestId('lidarr-state').map(text)).toEqual([
|
||||
'Lidarr uses this',
|
||||
"Lidarr doesn't use this"
|
||||
]);
|
||||
expect(text(screen.getByTestId('resolve-note'))).toBe('Lidarr tracks every copy as its own track.');
|
||||
});
|
||||
|
||||
// Keeping the unmapped copy would remove the one Lidarr uses. The server
|
||||
// refuses that; the confirm says so first and offers no button to press.
|
||||
test('a merge that would remove the copy Lidarr uses is blocked before it is sent', async () => {
|
||||
renderWith(tracked());
|
||||
await fireEvent.click(screen.getAllByRole('radio')[1]);
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Merge…' }));
|
||||
expect(text(screen.getByTestId('merge-blocked'))).toContain('Lidarr uses a copy this would remove');
|
||||
const confirm = screen.getByRole('button', { name: 'Remove 1 file and merge' }) as HTMLButtonElement;
|
||||
expect(confirm.disabled).toBe(true);
|
||||
|
||||
// Choosing the copy Lidarr uses clears it.
|
||||
await fireEvent.click(screen.getAllByRole('radio')[0]);
|
||||
expect(screen.queryByTestId('merge-blocked')).toBeNull();
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Remove 1 file and merge' }));
|
||||
expect(mergeDuplicateGroup).toHaveBeenCalledWith('g-1', { survivor_track_id: 'www-01', unmonitor: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user