release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m31s
release / go (push) Successful in 1m49s
release / integration (push) Successful in 5m20s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / android (push) Successful in 6m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
- Playback errors: Delete file stops resolving the error row the track deletion already took with it (ON DELETE CASCADE), which 404'd into "Delete failed". - Overview: a queue that fails to load says so with Try again, and its tile shows a dash, not 0. Delete via Lidarr shows only for an album Lidarr holds while Lidarr is connected. An approve refreshes the Collection. - Quarantine: Delete via Lidarr is in the row menu, so a phone reaches it; "1 report", not "1 reports". - Duplicates: the Keep badge follows the copy picked. - Integrations: provider save failures and load errors are said, not swallowed or shown as "No providers registered"; the Lidarr test catches a failed call; SMTP fills once and Save stays off until it has loaded. Lidarr's blurb says what it is still used for. - Requests: release profiles point at Collection › Following. - Library: an empty library sends an admin to Admin to run a scan, and tells anyone else the admin scans it, instead of opening Settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
394 lines
15 KiB
TypeScript
394 lines
15 KiB
TypeScript
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
import { fireEvent, render, screen } from '@testing-library/svelte';
|
|
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({
|
|
survivor_track_id: 'www-01',
|
|
removed_paths: ['/music/Moe Shop/WWW (2020)/www-02.mp3']
|
|
}),
|
|
// The page embeds FingerprintSettingsCard, which loads its own settings from
|
|
// this module; the card has its own suite.
|
|
getFingerprintSettings: vi.fn().mockResolvedValue({
|
|
enabled: true,
|
|
chromaprint_length_sec: 120,
|
|
acoustic_max_bit_error_rate: 0.15,
|
|
backfill_concurrency: 2,
|
|
sweep_interval_hours: 1,
|
|
auto_resolve: true
|
|
}),
|
|
updateFingerprintSettings: vi.fn()
|
|
}));
|
|
|
|
import AdminDuplicatesPage from './+page.svelte';
|
|
import {
|
|
createDuplicatesQuery,
|
|
createResolvedDuplicatesQuery,
|
|
dismissDuplicateGroup,
|
|
mergeDuplicateGroup,
|
|
runDuplicateSweep
|
|
} from '#lib/api/admin.js';
|
|
|
|
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, enabled: true };
|
|
|
|
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,
|
|
disc_number: null,
|
|
track_number: null,
|
|
...extra
|
|
};
|
|
}
|
|
|
|
function response(over: Partial<AdminDuplicatesResponse> = {}): AdminDuplicatesResponse {
|
|
return {
|
|
sweep: finishedSweep,
|
|
fingerprints: allFingerprinted,
|
|
view: 'review',
|
|
counts: { review: 1, cross_release: 4, resolved: 2 },
|
|
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',
|
|
class: 'review',
|
|
resolve_note: null,
|
|
members: [member('www-01', { like_count: 2, play_count: 14 }), member('www-02')]
|
|
}
|
|
],
|
|
...over
|
|
};
|
|
}
|
|
|
|
// 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(
|
|
infinite([data]) as unknown as ReturnType<typeof createDuplicatesQuery>
|
|
);
|
|
vi.mocked(createResolvedDuplicatesQuery).mockReturnValue(
|
|
infinite([resolvedPage(resolved)]) as unknown as ReturnType<typeof createResolvedDuplicatesQuery>
|
|
);
|
|
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('picking another copy moves the keep badge to it', async () => {
|
|
renderWith(response());
|
|
const rows = screen.getAllByTestId('duplicate-member');
|
|
await fireEvent.click(rows[1].querySelector('input[type="radio"]') as HTMLInputElement);
|
|
const after = screen.getAllByTestId('duplicate-member');
|
|
expect(after[0].querySelector('[data-testid="survivor-badge"]')).toBeNull();
|
|
expect(text(after[1].querySelector('[data-testid="survivor-badge"]') as HTMLElement)).toBe('Keep · your pick');
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
// #3885: identical audio titled "WWW" and "WWW (instrumental)". One file
|
|
// claimed to be the vocal and wasn't, and nothing said the vocal was missing.
|
|
describe('identical audio under different titles', () => {
|
|
function www() {
|
|
const r = response();
|
|
r.groups[0] = {
|
|
...r.groups[0],
|
|
members: [member('www-01'), member('www-02', { title: 'WWW (instrumental)' })]
|
|
};
|
|
return r;
|
|
}
|
|
|
|
test('says the copies are titled differently and names both titles', () => {
|
|
renderWith(www());
|
|
const notice = text(screen.getByTestId('title-mismatch'));
|
|
expect(notice).toContain('titled “WWW” and “WWW (instrumental)”');
|
|
expect(notice).toContain("isn't in your library");
|
|
});
|
|
|
|
test('three different titles are all named', () => {
|
|
const r = www();
|
|
r.groups[0].members.push(member('www-03', { title: 'WWW (Radio Edit)' }));
|
|
renderWith(r);
|
|
expect(text(screen.getByTestId('title-mismatch'))).toContain(
|
|
'“WWW”, “WWW (instrumental)” and “WWW (Radio Edit)”'
|
|
);
|
|
});
|
|
|
|
test('titles differing only in case or spacing are the same title', () => {
|
|
const r = response();
|
|
r.groups[0] = {
|
|
...r.groups[0],
|
|
members: [
|
|
member('www-01', { title: 'WWW (Instrumental)' }),
|
|
member('www-02', { title: ' www (instrumental) ' })
|
|
]
|
|
};
|
|
renderWith(r);
|
|
expect(screen.queryByTestId('title-mismatch')).toBeNull();
|
|
});
|
|
|
|
test('matching titles raise nothing', () => {
|
|
renderWith(response());
|
|
expect(screen.queryByTestId('title-mismatch')).toBeNull();
|
|
});
|
|
|
|
// Across encodings a "Remastered" suffix is routine, not a mislabel.
|
|
test('an acoustic group with different titles raises nothing', () => {
|
|
const r = www();
|
|
r.groups[0] = { ...r.groups[0], tier: 'acoustic', worst_bit_error_rate: 0.04 };
|
|
renderWith(r);
|
|
expect(screen.queryByTestId('title-mismatch')).toBeNull();
|
|
});
|
|
});
|
|
|
|
// 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, enabled: true }
|
|
})
|
|
);
|
|
expect(text(screen.getByTestId('empty-state'))).toContain('still being fingerprinted');
|
|
});
|
|
|
|
// Switched off, the backlog never shrinks. "Still being fingerprinted" would
|
|
// promise work nothing is doing (#3913).
|
|
test('with fingerprinting off the page says so instead of promising progress', () => {
|
|
renderWith(
|
|
response({
|
|
groups: [],
|
|
total: 0,
|
|
fingerprints: { total: 1200, fingerprinted: 0, rejected: 0, pending: 1200, enabled: false }
|
|
})
|
|
);
|
|
const empty = text(screen.getByTestId('empty-state'));
|
|
expect(empty).toContain('Fingerprinting is off');
|
|
expect(empty).not.toContain('still being fingerprinted');
|
|
expect(text(screen.getByTestId('sweep-status'))).toContain('Fingerprinting is off');
|
|
});
|
|
|
|
test('the fingerprinting settings are on this page', async () => {
|
|
renderWith(response());
|
|
expect(await screen.findByRole('spinbutton', { name: /seconds of audio/i })).toBeTruthy();
|
|
});
|
|
|
|
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 nothing needs review', () => {
|
|
renderWith(response({ groups: [], total: 0 }));
|
|
expect(text(screen.getByTestId('empty-state'))).toContain('Nothing needs review');
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
// A merge deletes files. The first click must only arm it.
|
|
test('Merge needs a second click, and keeps the proposed copy by default', async () => {
|
|
renderWith(response());
|
|
await fireEvent.click(screen.getByRole('button', { name: 'Merge…' }));
|
|
expect(mergeDuplicateGroup).not.toHaveBeenCalled();
|
|
expect(text(screen.getByTestId('merge-confirm'))).toContain('The other 1 file will be deleted from disk');
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: 'Remove 1 file and merge' }));
|
|
expect(mergeDuplicateGroup).toHaveBeenCalledWith('g-1', {
|
|
survivor_track_id: 'www-01',
|
|
unmonitor: false
|
|
});
|
|
});
|
|
|
|
test('choosing another copy to keep sends that copy', async () => {
|
|
renderWith(response());
|
|
const radios = screen.getAllByRole('radio');
|
|
expect((radios[0] as HTMLInputElement).checked).toBe(true);
|
|
await fireEvent.click(radios[1]);
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: 'Merge…' }));
|
|
await fireEvent.click(screen.getByRole('button', { name: 'Remove 1 file and merge' }));
|
|
expect(mergeDuplicateGroup).toHaveBeenCalledWith('g-1', {
|
|
survivor_track_id: 'www-02',
|
|
unmonitor: false
|
|
});
|
|
});
|
|
|
|
test('Cancel disarms the merge', async () => {
|
|
renderWith(response());
|
|
await fireEvent.click(screen.getByRole('button', { name: 'Merge…' }));
|
|
await fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
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');
|
|
});
|
|
|
|
// Copies across releases are linked as one song; dismissing one says they
|
|
// are not, which is what the button names.
|
|
test('Across releases dismisses as Not the same song', async () => {
|
|
renderWith(response());
|
|
await fireEvent.click(screen.getByRole('tab', { name: /across releases/i }));
|
|
await fireEvent.click(screen.getByRole('button', { name: 'Not the same song' }));
|
|
expect(dismissDuplicateGroup).toHaveBeenCalledWith('g-1');
|
|
});
|
|
|
|
// Release changes stopped with Lidarr (M502), but the ones made before stay
|
|
// in the history.
|
|
test('Resolved automatically lists merges and past 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 that is not a video rip',
|
|
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 that is not a video rip) 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("the resolver's note", () => {
|
|
test('shows above the group it explains', () => {
|
|
const r = response();
|
|
r.groups[0] = { ...r.groups[0], resolve_note: 'Identical audio filed under different titles.' };
|
|
renderWith(r);
|
|
expect(text(screen.getByTestId('resolve-note'))).toBe('Identical audio filed under different titles.');
|
|
});
|
|
|
|
test('a merge confirms and sends the copy chosen to keep', async () => {
|
|
renderWith(response());
|
|
await fireEvent.click(screen.getAllByRole('radio')[1]);
|
|
await fireEvent.click(screen.getByRole('button', { name: 'Merge…' }));
|
|
await fireEvent.click(screen.getByRole('button', { name: 'Remove 1 file and merge' }));
|
|
expect(mergeDuplicateGroup).toHaveBeenCalledWith('g-1', { survivor_track_id: 'www-02', unmonitor: false });
|
|
});
|
|
});
|
|
});
|