feat: Minstrel no longer talks to Lidarr: its connection, Delete via Lidarr and the unmonitor options are gone, and the track reports are plain quarantine (M502 #5525)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 17s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m9s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped

Migration 0091 drops lidarr_config and renames the quarantine tables, types,
indexes and the history column off the Lidarr name. The Integrations Lidarr
card, the Reports "Delete via Lidarr" action, the merge and remove-track
unmonitor options, the Lidarr audit actions and error codes go, on the
server, web and Android. The README describes acquisition as it is now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 13:48:20 -04:00
co-authored by Claude Opus 5.5
parent c54792c51e
commit 3a16732883
170 changed files with 994 additions and 6242 deletions
+2 -4
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest';
import { errorCounts, mergeHandled, mergeReports } from './reports';
import type { AdminPlaybackError, AdminQuarantineRow, LidarrQuarantineActionRow } from '#lib/api/types.js';
import type { AdminPlaybackError, AdminQuarantineRow, QuarantineActionRow } from '#lib/api/types.js';
const flagged = (track: string, at: string): AdminQuarantineRow => ({
track_id: track,
@@ -8,7 +8,6 @@ const flagged = (track: string, at: string): AdminQuarantineRow => ({
artist_name: 'A',
album_title: 'Al',
album_id: 'al',
lidarr_album_mbid: 'rg',
report_count: 1,
latest_at: at,
reason_counts: { bad_rip: 1 },
@@ -40,7 +39,6 @@ describe('mergeReports', () => {
expect(rows[0].reports).toHaveLength(1);
expect(rows[0].errors.map((e) => e.id)).toEqual(['e1']);
expect(rows[0].latest).toBe('2026-10-09T00:00:00Z');
expect(rows[0].lidarr_album_mbid).toBe('rg');
expect(rows[2].reports).toHaveLength(0);
expect(rows[2].file_path).toBe('/music/3.flac');
});
@@ -53,7 +51,7 @@ describe('mergeReports', () => {
describe('mergeHandled', () => {
test('cleared reports, deletions and resolved errors read as one history, newest first', () => {
const action: LidarrQuarantineActionRow = {
const action: QuarantineActionRow = {
id: 'x', track_id: '1', track_title: 'T1', artist_name: 'A', action: 'deleted_file',
affected_users: 1, created_at: '2026-10-02T00:00:00Z'
};
+7 -10
View File
@@ -2,8 +2,8 @@ import type {
AdminPlaybackError,
AdminQuarantineReport,
AdminQuarantineRow,
LidarrQuarantineActionRow,
LidarrQuarantineReason,
QuarantineActionRow,
QuarantineReason,
PlaybackErrorKind
} from '#lib/api/types.js';
@@ -19,14 +19,13 @@ export type ReportRow = {
album: string;
album_id: string;
file_path: string | null;
lidarr_album_mbid: string | null;
reports: AdminQuarantineReport[];
reason_counts: Partial<Record<LidarrQuarantineReason, number>>;
reason_counts: Partial<Record<QuarantineReason, number>>;
errors: AdminPlaybackError[];
latest: string;
};
export const REASON_LABELS: Record<LidarrQuarantineReason, string> = {
export const REASON_LABELS: Record<QuarantineReason, string> = {
bad_rip: 'Bad rip',
wrong_file: 'Wrong file',
wrong_tags: 'Wrong tags',
@@ -52,7 +51,6 @@ export function mergeReports(flagged: AdminQuarantineRow[], errors: AdminPlaybac
album: q.album_title,
album_id: q.album_id,
file_path: null,
lidarr_album_mbid: q.lidarr_album_mbid ?? null,
reports: q.reports,
reason_counts: q.reason_counts,
errors: [],
@@ -69,7 +67,6 @@ export function mergeReports(flagged: AdminQuarantineRow[], errors: AdminPlaybac
album: e.album_title,
album_id: e.album_id,
file_path: null,
lidarr_album_mbid: null,
reports: [],
reason_counts: {},
errors: [],
@@ -94,10 +91,10 @@ export function errorCounts(row: ReportRow): [PlaybackErrorKind, number][] {
// The Handled view: what was done about past reports, newest first.
export type HandledRow = { key: string; title: string; artist: string; what: string; at: string };
const ACTION_LABELS: Record<LidarrQuarantineActionRow['action'], string> = {
const ACTION_LABELS: Record<QuarantineActionRow['action'], string> = {
resolved: 'Reports cleared',
deleted_file: 'File deleted',
deleted_via_lidarr: 'Deleted through Lidarr'
deleted_album: 'Album deleted'
};
const RESOLUTION_LABELS: Record<string, string> = {
@@ -108,7 +105,7 @@ const RESOLUTION_LABELS: Record<string, string> = {
requested: 'Re-requested after a playback error'
};
export function mergeHandled(actions: LidarrQuarantineActionRow[], resolved: AdminPlaybackError[]): HandledRow[] {
export function mergeHandled(actions: QuarantineActionRow[], resolved: AdminPlaybackError[]): HandledRow[] {
const rows: HandledRow[] = [
...actions.map((a) => ({
key: `a:${a.id}`,
+3 -121
View File
@@ -10,18 +10,12 @@ vi.mock('./client', () => ({
}));
import {
getLidarrConfig,
putLidarrConfig,
testLidarrConnection,
listQualityProfiles,
listRootFolders,
listAdminRequests,
approveRequest,
rejectRequest,
listAdminQuarantine,
resolveQuarantine,
deleteQuarantineFile,
deleteQuarantineViaLidarr,
listQuarantineActions
} from './admin';
import { api } from './client';
@@ -29,22 +23,10 @@ import { qk } from './queries';
import type {
ActionResult,
AdminQuarantineRow,
LidarrConfig,
LidarrQualityProfile,
LidarrQuarantineActionRow,
MusicRequest,
LidarrRootFolder
QuarantineActionRow,
MusicRequest
} from './types';
const baseConfig: LidarrConfig = {
enabled: true,
base_url: 'http://lidarr.lan:8686',
api_key: '***',
default_quality_profile_id: 1,
default_metadata_profile_id: 1,
default_root_folder_path: '/music'
};
const baseRow: MusicRequest = {
id: 'r1',
user_id: 'u1',
@@ -73,83 +55,6 @@ beforeEach(() => {
vi.clearAllMocks();
});
describe('getLidarrConfig', () => {
test('GETs /api/admin/lidarr/config', async () => {
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseConfig);
const out = await getLidarrConfig();
expect(api.get).toHaveBeenCalledWith('/api/admin/lidarr/config');
expect(out).toBe(baseConfig);
});
});
describe('putLidarrConfig', () => {
test('PUTs /api/admin/lidarr/config with the full config body', async () => {
const next: LidarrConfig = { ...baseConfig, api_key: 'plain-key' };
(api.put as ReturnType<typeof vi.fn>).mockResolvedValueOnce(next);
const out = await putLidarrConfig(next);
expect(api.put).toHaveBeenCalledWith('/api/admin/lidarr/config', next);
expect(out).toBe(next);
});
});
describe('testLidarrConnection', () => {
test('POSTs /api/admin/lidarr/test with empty body when no overrides', async () => {
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
version: '2.0.0'
});
const out = await testLidarrConnection();
expect(api.post).toHaveBeenCalledWith('/api/admin/lidarr/test', {});
expect(out).toEqual({ ok: true, version: '2.0.0' });
});
test('forwards override base_url + api_key body', async () => {
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
version: '2.0.0'
});
await testLidarrConnection({
base_url: 'http://other:8686',
api_key: 'try-this'
});
expect(api.post).toHaveBeenCalledWith('/api/admin/lidarr/test', {
base_url: 'http://other:8686',
api_key: 'try-this'
});
});
test('returns ok:false branch without throwing', async () => {
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
error: 'auth failed'
});
const out = await testLidarrConnection();
expect(out).toEqual({ ok: false, error: 'auth failed' });
});
});
describe('listQualityProfiles', () => {
test('GETs /api/admin/lidarr/quality-profiles', async () => {
const profiles: LidarrQualityProfile[] = [{ id: 1, name: 'Lossless' }];
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(profiles);
const out = await listQualityProfiles();
expect(api.get).toHaveBeenCalledWith('/api/admin/lidarr/quality-profiles');
expect(out).toBe(profiles);
});
});
describe('listRootFolders', () => {
test('GETs /api/admin/lidarr/root-folders', async () => {
const folders: LidarrRootFolder[] = [
{ path: '/music', accessible: true, free_space: 100 }
];
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(folders);
const out = await listRootFolders();
expect(api.get).toHaveBeenCalledWith('/api/admin/lidarr/root-folders');
expect(out).toBe(folders);
});
});
describe('listAdminRequests', () => {
test('no args -> /api/admin/requests with no query string', async () => {
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce([baseRow]);
@@ -209,15 +114,6 @@ describe('rejectRequest', () => {
});
describe('qk admin keys', () => {
test('lidarrConfig key', () => {
expect(qk.lidarrConfig()).toEqual(['lidarrConfig']);
});
test('lidarrQualityProfiles key', () => {
expect(qk.lidarrQualityProfiles()).toEqual(['lidarrQualityProfiles']);
});
test('lidarrRootFolders key', () => {
expect(qk.lidarrRootFolders()).toEqual(['lidarrRootFolders']);
});
test('adminRequests key uses "all" when status omitted', () => {
// listAdminRequests sends no status param when called without one (returns
// every status), so the cache key must distinguish that from any specific
@@ -238,7 +134,6 @@ const baseQuarantineRow: AdminQuarantineRow = {
artist_name: 'Aphex Twin',
album_title: 'Drukqs',
album_id: 'alb1',
lidarr_album_mbid: null,
report_count: 1,
latest_at: '2026-01-01T00:00:00Z',
reason_counts: { bad_rip: 1 },
@@ -250,7 +145,7 @@ const baseAction: ActionResult = {
affected_users: 1
};
const baseActionRow: LidarrQuarantineActionRow = {
const baseActionRow: QuarantineActionRow = {
id: 'a1',
track_id: 't1',
track_title: 'Avril 14th',
@@ -258,7 +153,6 @@ const baseActionRow: LidarrQuarantineActionRow = {
album_title: 'Drukqs',
action: 'resolved',
admin_id: 'admin1',
lidarr_album_mbid: null,
affected_users: 1,
created_at: '2026-01-01T00:00:00Z'
};
@@ -293,18 +187,6 @@ describe('deleteQuarantineFile', () => {
});
});
describe('deleteQuarantineViaLidarr', () => {
test('POSTs /api/admin/quarantine/:trackID/delete-via-lidarr with empty body', async () => {
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseAction);
const out = await deleteQuarantineViaLidarr('t1');
expect(api.post).toHaveBeenCalledWith(
'/api/admin/quarantine/t1/delete-via-lidarr',
{}
);
expect(out).toBe(baseAction);
});
});
describe('listQuarantineActions', () => {
test('defaults to limit=50 when no argument', async () => {
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce([baseActionRow]);
+6 -82
View File
@@ -13,14 +13,9 @@ import type {
AdminPlaybackError,
AdminQuarantineRow,
AdminSummary,
LidarrConfig,
LidarrMetadataProfile,
LidarrQualityProfile,
LidarrQuarantineActionRow,
QuarantineActionRow,
MusicRequest,
RequestStatus,
LidarrRootFolder,
LidarrTestResult,
PlaybackErrorResolution
} from './types';
@@ -41,37 +36,6 @@ export function createAdminSummaryQuery() {
});
}
// Admin Lidarr config -----------------------------------------------------
export async function getLidarrConfig(): Promise<LidarrConfig> {
return api.get<LidarrConfig>('/api/admin/lidarr/config');
}
export async function putLidarrConfig(cfg: LidarrConfig): Promise<LidarrConfig> {
return api.put<LidarrConfig>('/api/admin/lidarr/config', cfg);
}
// testLidarrConnection always returns 200 with a discriminated-union body —
// callers branch on `result.ok`. We do NOT throw on `ok:false`; the SPA wants
// to render either branch (e.g. "connected to Lidarr X.Y.Z" vs "auth failed").
export async function testLidarrConnection(
body: { base_url?: string; api_key?: string } = {}
): Promise<LidarrTestResult> {
return api.post<LidarrTestResult>('/api/admin/lidarr/test', body);
}
export async function listQualityProfiles(): Promise<LidarrQualityProfile[]> {
return api.get<LidarrQualityProfile[]>('/api/admin/lidarr/quality-profiles');
}
export async function listMetadataProfiles(): Promise<LidarrMetadataProfile[]> {
return api.get<LidarrMetadataProfile[]>('/api/admin/lidarr/metadata-profiles');
}
export async function listRootFolders(): Promise<LidarrRootFolder[]> {
return api.get<LidarrRootFolder[]>('/api/admin/lidarr/root-folders');
}
// Admin request queue -----------------------------------------------------
export async function listAdminRequests(
@@ -106,42 +70,6 @@ export async function rejectRequest(
// Query factories ---------------------------------------------------------
export function createLidarrConfigQuery() {
return createQuery({
queryKey: qk.lidarrConfig(),
queryFn: getLidarrConfig,
staleTime: 60_000
});
}
// `enabled` is passed in by the caller — typically derived from
// LidarrConfig.enabled — so the query only fires once Lidarr is configured.
// Keeping it as a prop (vs. reading config inside this factory) preserves
// purity and lets the caller choose its own gating logic.
export function createQualityProfilesQuery(enabled: boolean = true) {
return createQuery({
queryKey: qk.lidarrQualityProfiles(),
queryFn: listQualityProfiles,
enabled
});
}
export function createMetadataProfilesQuery(enabled: boolean = true) {
return createQuery({
queryKey: qk.lidarrMetadataProfiles(),
queryFn: listMetadataProfiles,
enabled
});
}
export function createRootFoldersQuery(enabled: boolean = true) {
return createQuery({
queryKey: qk.lidarrRootFolders(),
queryFn: listRootFolders,
enabled
});
}
export function createAdminRequestsQuery(status?: RequestStatus) {
return createQuery({
queryKey: qk.adminRequests(status),
@@ -175,14 +103,10 @@ export async function deleteQuarantineFile(trackID: string): Promise<ActionResul
return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/delete-file`, {});
}
export async function deleteQuarantineViaLidarr(trackID: string): Promise<ActionResult> {
return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/delete-via-lidarr`, {});
}
export async function listQuarantineActions(
limit: number = 50
): Promise<LidarrQuarantineActionRow[]> {
return api.get<LidarrQuarantineActionRow[]>(
): Promise<QuarantineActionRow[]> {
return api.get<QuarantineActionRow[]>(
`/api/admin/quarantine/actions?limit=${limit}`
);
}
@@ -376,8 +300,8 @@ export type FingerprintSettings = {
acoustic_max_bit_error_rate: number;
backfill_concurrency: number;
sweep_interval_hours: number;
// Lets the duplicate resolver act on its own (M498): merge copies Lidarr
// doesn't need and change a Lidarr release that lists songs twice.
// Lets the duplicate resolver act on its own (M498): merge copies no
// release needs.
auto_resolve: boolean;
};
@@ -916,7 +840,7 @@ export async function runDuplicateSweep(): Promise<{ started: boolean }> {
// the group.
export async function mergeDuplicateGroup(
id: string,
body: { survivor_track_id: string; unmonitor: boolean }
body: { survivor_track_id: string }
): Promise<MergeDuplicateResult> {
return api.post<MergeDuplicateResult>(
`/api/admin/library/duplicates/${encodeURIComponent(id)}/merge`,
+4 -4
View File
@@ -14,11 +14,11 @@ import { flagTrack, unflagTrack, listMyQuarantine } from './quarantine';
import { api, apiFetch } from './client';
import { qk } from './queries';
import type {
LidarrQuarantineRow,
LidarrQuarantineMineRow
QuarantineRow,
QuarantineMineRow
} from './types';
const mockRow: LidarrQuarantineRow = {
const mockRow: QuarantineRow = {
user_id: 'u1',
track_id: 't1',
reason: 'bad_rip',
@@ -26,7 +26,7 @@ const mockRow: LidarrQuarantineRow = {
created_at: '2026-01-01T00:00:00Z'
};
const mockMineRow: LidarrQuarantineMineRow = {
const mockMineRow: QuarantineMineRow = {
track_id: 't1',
reason: 'bad_rip',
notes: null,
+8 -8
View File
@@ -2,21 +2,21 @@ import { createQuery } from '@tanstack/svelte-query';
import { api, apiFetch } from './client';
import { qk } from './queries';
import type {
LidarrQuarantineRow,
LidarrQuarantineMineRow,
LidarrQuarantineReason
QuarantineRow,
QuarantineMineRow,
QuarantineReason
} from './types';
export type FlagParams = {
track_id: string;
reason: LidarrQuarantineReason;
reason: QuarantineReason;
notes?: string;
};
export async function flagTrack(params: FlagParams): Promise<LidarrQuarantineRow> {
export async function flagTrack(params: FlagParams): Promise<QuarantineRow> {
const body: FlagParams = { track_id: params.track_id, reason: params.reason };
if (params.notes && params.notes.length > 0) body.notes = params.notes;
return api.post<LidarrQuarantineRow>('/api/quarantine', body);
return api.post<QuarantineRow>('/api/quarantine', body);
}
// Server returns 204 (no body) for DELETE; apiFetch handles it.
@@ -24,8 +24,8 @@ export async function unflagTrack(trackID: string): Promise<void> {
await apiFetch(`/api/quarantine/${trackID}`, { method: 'DELETE' });
}
export async function listMyQuarantine(): Promise<LidarrQuarantineMineRow[]> {
return api.get<LidarrQuarantineMineRow[]>('/api/quarantine/mine');
export async function listMyQuarantine(): Promise<QuarantineMineRow[]> {
return api.get<QuarantineMineRow[]>('/api/quarantine/mine');
}
export function createMyQuarantineQuery() {
+1 -5
View File
@@ -29,14 +29,10 @@ export const qk = {
likedAlbums: () => ['likedAlbums'] as const,
likedArtists: () => ['likedArtists'] as const,
history: () => ['history'] as const,
// Lidarr / requests / admin.
// Requests / admin.
catalogueSearch: (q: string, kind: string) =>
['catalogueSearch', { q, kind }] as const,
myRequests: () => ['myRequests'] as const,
lidarrConfig: () => ['lidarrConfig'] as const,
lidarrQualityProfiles: () => ['lidarrQualityProfiles'] as const,
lidarrMetadataProfiles: () => ['lidarrMetadataProfiles'] as const,
lidarrRootFolders: () => ['lidarrRootFolders'] as const,
adminRequests: (status?: string) =>
['adminRequests', { status: status ?? 'all' }] as const,
// Wanted list (M502 #5520). Wants sit under ['adminWants'] so one prefix
+1 -1
View File
@@ -9,7 +9,7 @@ import type { MusicRequest, RequestKind } from './types';
const POLL_INTERVAL_MS = 12_000;
/** True if any row is mid-ingest (status === 'approved'). The status flips
to 'completed' / 'rejected' when Lidarr finishes, so this predicate
to 'completed' / 'rejected' when the music arrives, so this predicate
lets the page poll exactly while there's something interesting to watch. */
export function hasInFlightRequest(rows: readonly MusicRequest[] | undefined): boolean {
return rows?.some((r) => r.status === 'approved') ?? false;
+30 -80
View File
@@ -188,73 +188,32 @@ export type MusicRequest = {
updated_at: string;
// Live ingest counters. Server fills these from the matched entity's
// children. Useful for in-flight requests so the operator can see
// "5 albums · 47 tracks" while Lidarr is still working on the rest.
// "5 albums · 47 tracks" while the rest is still being fetched.
imported_album_count: number;
imported_track_count: number;
};
export type LidarrConfig = {
enabled: boolean;
// api_key is "" when unset, "***" when masked on GET, plain text on PUT.
base_url: string;
api_key: string;
default_quality_profile_id: number;
default_metadata_profile_id: number;
default_root_folder_path: string;
};
// Quarantine ------------------------------------------------------------
export type LidarrQualityProfile = {
id: number;
name: string;
};
export type LidarrMetadataProfile = {
id: number;
name: string;
};
export type LidarrRootFolder = {
path: string;
accessible: boolean;
free_space: number;
};
// testLidarrConnection always returns 200; callers branch on `.ok`.
// On success the response carries the live profile + folder lists so the
// integrations page can pre-fill its dropdowns on first-time setup
// without a separate round-trip.
export type LidarrTestResult =
| {
ok: true;
version: string;
quality_profiles?: { id: number; name: string }[];
metadata_profiles?: { id: number; name: string }[];
root_folders?: { path: string; accessible: boolean; free_space: number }[];
list_errors?: Record<string, string>;
}
| { ok: false; error: string };
// Lidarr quarantine ------------------------------------------------------
export type LidarrQuarantineReason =
export type QuarantineReason =
| 'bad_rip'
| 'wrong_file'
| 'wrong_tags'
| 'duplicate'
| 'other';
export type LidarrQuarantineRow = {
export type QuarantineRow = {
user_id: string;
track_id: string;
reason: LidarrQuarantineReason;
reason: QuarantineReason;
notes?: string | null;
created_at: string;
};
// What GET /api/quarantine/mine returns per row
export type LidarrQuarantineMineRow = {
export type QuarantineMineRow = {
track_id: string;
reason: LidarrQuarantineReason;
reason: QuarantineReason;
notes?: string | null;
created_at: string;
track_title: string;
@@ -273,17 +232,16 @@ export type AdminQuarantineRow = {
artist_name: string;
album_title: string;
album_id: string;
lidarr_album_mbid?: string | null;
report_count: number;
latest_at: string;
reason_counts: Partial<Record<LidarrQuarantineReason, number>>;
reason_counts: Partial<Record<QuarantineReason, number>>;
reports: AdminQuarantineReport[];
};
export type AdminQuarantineReport = {
user_id: string;
username: string;
reason: LidarrQuarantineReason;
reason: QuarantineReason;
notes?: string | null;
created_at: string;
};
@@ -315,17 +273,19 @@ export type AdminPlaybackError = {
album_id: string;
};
export type LidarrQuarantineAction = 'resolved' | 'deleted_file' | 'deleted_via_lidarr';
// deleted_album is history: an album removed whole, before Minstrel filed its
// own music (M502).
export type QuarantineAction = 'resolved' | 'deleted_file' | 'deleted_album';
export type LidarrQuarantineActionRow = {
export type QuarantineActionRow = {
id: string;
track_id: string;
track_title: string;
artist_name: string;
album_title?: string | null;
action: LidarrQuarantineAction;
action: QuarantineAction;
admin_id?: string | null;
lidarr_album_mbid?: string | null;
album_mbid?: string | null;
affected_users: number;
created_at: string;
};
@@ -333,7 +293,6 @@ export type LidarrQuarantineActionRow = {
export type ActionResult = {
action_id: string;
affected_users: number;
deleted_track_count?: number;
};
export type SeedContribution = {
@@ -349,7 +308,7 @@ export type ArtistSuggestion = {
name: string;
score: number;
attribution: SeedContribution[]; // up to 3 entries, ordered by contribution DESC
image_url?: string; // resolved on-demand from Lidarr; absent → card placeholder
image_url?: string; // absent → card placeholder
// Tags this candidate shares with your taste profile, strongest first (max 3,
// #2377). Absent for most candidates — tag coverage for artists you don't own
// is partial by nature (#2376) — so the card falls back to seed attribution.
@@ -514,12 +473,10 @@ export type AdminDuplicateSweep = {
};
// What a merge did (#3911). removed_paths are the files that were deleted from
// disk; lidarr_unmonitor_failed appears only when unmonitoring was asked for and
// failed.
// disk.
export type MergeDuplicateResult = {
survivor_track_id: string;
removed_paths: string[];
lidarr_unmonitor_failed?: boolean;
};
export type AdminDuplicatesResponse = {
@@ -543,24 +500,17 @@ export type AdminDuplicatesResponse = {
// The report's group tabs. 'resolved' is the third tab, served separately.
export type DuplicatesView = 'review' | 'cross_release';
// One thing the resolver did on its own (M498), from the audit log.
export type ResolvedDuplicateActivity =
| {
id: string;
action: 'duplicate_merge';
created_at: string;
details: {
survivor_path?: string;
survivor_reason?: string;
removed?: { track_id: string; file_path: string }[];
};
}
| {
id: string;
action: 'lidarr_release_change';
created_at: string;
details: { album_title?: string; artist_name?: string; from_release?: string; to_release?: string };
};
// One merge the resolver made on its own (M498), from the audit log.
export type ResolvedDuplicateActivity = {
id: string;
action: 'duplicate_merge';
created_at: string;
details: {
survivor_path?: string;
survivor_reason?: string;
removed?: { track_id: string; file_path: string }[];
};
};
export type ResolvedDuplicatesResponse = {
total: number;
@@ -856,8 +806,8 @@ export type LibraryListing = { listed_at: string | null; unlisted: number };
// artist heads the list when it is narrowed to one artist's page.
export type WantsPage = Page<Want> & { counts: WantCounts; library: LibraryListing; artist?: WantArtist };
// One artist in the Collection's catalogue (#5572), like Lidarr's artist
// index: how many of their albums on the list are held, of how many.
// One artist in the Collection's catalogue (#5572): how many of their albums
// on the list are held, of how many.
export type WantArtist = {
artist_mbid: string;
artist_name: string;
@@ -13,7 +13,7 @@
// Where Minstrel looks for music and what downloads it (M502 #5518),
// Fabled Librarian's model: two lists of connections the operator adds and
// configures. Nothing is seeded or copied from Lidarr. A FabledExchange is
// configures. Nothing is seeded or copied. A FabledExchange is
// one entry under download clients, with a switch for each of its jobs.
let services = $state<AcquisitionService[] | null>(null);
@@ -129,7 +129,7 @@ describe('DiscoverResultCard', () => {
// --- snooze affordance (#2375) ---
test('snooze button appears only when onSnooze is supplied', () => {
// The Lidarr search results reuse this card and have nothing to park, so
// The catalogue search results reuse this card and have nothing to park, so
// the affordance must not appear unconditionally.
render(DiscoverResultCard, {
props: { kind: 'artist', title: 'Outsider', state: 'requestable' }
+3 -3
View File
@@ -5,7 +5,7 @@
import { flagTrack } from '#lib/api/quarantine.js';
import { errCode } from '#lib/api/errors.js';
import { qk } from '#lib/api/queries.js';
import type { TrackRef, LidarrQuarantineReason } from '#lib/api/types.js';
import type { TrackRef, QuarantineReason } from '#lib/api/types.js';
let {
track,
@@ -15,11 +15,11 @@
}: {
track: TrackRef;
onClose: () => void;
initialReason?: LidarrQuarantineReason;
initialReason?: QuarantineReason;
initialNotes?: string;
} = $props();
let reason: LidarrQuarantineReason = $state(untrack(() => initialReason ?? 'bad_rip'));
let reason: QuarantineReason = $state(untrack(() => initialReason ?? 'bad_rip'));
let notes = $state(untrack(() => initialNotes ?? ''));
let submitting = $state(false);
let error = $state<string | null>(null);
+1 -1
View File
@@ -53,7 +53,7 @@
// still matches all sub-routes by prefix so the active state covers
// every tab.
// Collection (M502) is an admin's: the catalogue of what the library holds
// and wants, and what Minstrel is fetching for it, shaped like Lidarr's.
// and wants, and what Minstrel is fetching for it.
const navItems = $derived([
{ href: '/', label: 'Home', icon: House, matchPrefix: '/' },
{ href: '/library/artists', label: 'Library', icon: LibraryBig, matchPrefix: '/library' },
@@ -1,327 +0,0 @@
<script lang="ts">
import { tick } from 'svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createLidarrConfigQuery,
createQualityProfilesQuery,
createMetadataProfilesQuery,
createRootFoldersQuery,
putLidarrConfig,
testLidarrConnection
} from '#lib/api/admin.js';
import { qk } from '#lib/api/queries.js';
import { errCode } from '#lib/api/errors.js';
import { copyForCode } from '#lib/api/error-copy.js';
import Modal from '#lib/components/Modal.svelte';
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
import type { LidarrConfig, LidarrTestResult } from '#lib/api/types.js';
// The Lidarr connection, on Admin › Acquisition (#5607). Optional: wants
// are Minstrel's own, and a connected Lidarr is still asked to search for
// a cleaner copy of a suspect album, to remove an album through it, and to
// stop monitoring a track you remove.
//
// The saved key comes back masked as "***" and is never put in the field.
// The field starts empty, and an empty key on save keeps the saved one.
const client = useQueryClient();
const configStore = createLidarrConfigQuery();
const config = $derived($configStore);
const button =
'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';
const field = 'w-64 max-w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm';
// Form state, filled once when the config first loads, so a refetch
// doesn't throw away what is being typed.
let baseUrl = $state('');
let apiKeyInput = $state('');
let qualityId = $state<number>(0);
let metadataId = $state<number>(0);
let rootPath = $state<string>('');
let initialized = false;
// Lists from a test. A fresh install has no saved connection to read the
// lists through, so the test's own lists fill the choices until a save.
let testQualityProfiles = $state<{ id: number; name: string }[]>([]);
let testMetadataProfiles = $state<{ id: number; name: string }[]>([]);
let testRootFolders = $state<{ path: string; accessible: boolean; free_space: number }[]>([]);
let testListErrors = $state<Record<string, string>>({});
$effect(() => {
const c = config.data;
if (c && !initialized) {
baseUrl = c.base_url;
qualityId = c.default_quality_profile_id;
metadataId = c.default_metadata_profile_id;
rootPath = c.default_root_folder_path;
initialized = true;
}
});
const profilesEnabled = $derived(!!config.data?.enabled);
const profilesStore = $derived(createQualityProfilesQuery(profilesEnabled));
const metadataProfilesStore = $derived(createMetadataProfilesQuery(profilesEnabled));
const foldersStore = $derived(createRootFoldersQuery(profilesEnabled));
const qualityList = $derived(testQualityProfiles.length ? testQualityProfiles : ($profilesStore.data ?? []));
const metadataList = $derived(
testMetadataProfiles.length ? testMetadataProfiles : ($metadataProfilesStore.data ?? [])
);
const folderList = $derived(testRootFolders.length ? testRootFolders : ($foldersStore.data ?? []));
// Each choice defaults to the first on offer until one is picked.
$effect(() => {
if (qualityId === 0 && qualityList.length > 0) qualityId = qualityList[0].id;
});
$effect(() => {
if (metadataId === 0 && metadataList.length > 0) metadataId = metadataList[0].id;
});
$effect(() => {
if (rootPath === '' && folderList.length > 0) rootPath = folderList[0].path;
});
// What a failure code means, said to the operator.
const SAID: Record<string, string> = {
lidarr_unreachable: "Couldn't reach Lidarr at that address.",
lidarr_auth_failed: 'Lidarr turned the API key down.',
lidarr_lookup_failed: "Lidarr answered, but not in a way Minstrel understands.",
internal_error: 'Something went wrong on the server. Its log says what.',
no_defaults: 'Lidarr offered no quality profile, metadata profile or root folder to use.'
};
const said = (code: string | undefined) => (code && SAID[code]) || copyForCode(code);
const LIST_NAMES: Record<string, string> = {
quality_profiles: 'quality profiles',
metadata_profiles: 'metadata profiles',
root_folders: 'root folders'
};
let testResult: LidarrTestResult | null = $state(null);
let saveError: string | null = $state(null);
let isSaving = $state(false);
let isTesting = $state(false);
function takeLists(t: Extract<LidarrTestResult, { ok: true }>) {
testQualityProfiles = t.quality_profiles ?? [];
testMetadataProfiles = t.metadata_profiles ?? [];
testRootFolders = t.root_folders ?? [];
testListErrors = t.list_errors ?? {};
}
async function invalidate() {
await Promise.all([
client.invalidateQueries({ queryKey: qk.lidarrConfig() }),
client.invalidateQueries({ queryKey: qk.lidarrQualityProfiles() }),
client.invalidateQueries({ queryKey: qk.lidarrMetadataProfiles() }),
client.invalidateQueries({ queryKey: qk.lidarrRootFolders() })
]);
}
async function onSave() {
isSaving = true;
saveError = null;
try {
// Test first: it confirms the connection and brings the lists the
// defaults are taken from.
const test = await testLidarrConnection({ base_url: baseUrl, api_key: apiKeyInput });
testResult = test;
if (!test.ok) {
saveError = said(test.error);
return;
}
takeLists(test);
await tick();
if (qualityId === 0 || metadataId === 0 || rootPath === '') {
saveError = said('no_defaults');
return;
}
const cfg: LidarrConfig = {
enabled: true,
base_url: baseUrl,
api_key: apiKeyInput,
default_quality_profile_id: qualityId,
default_metadata_profile_id: metadataId,
default_root_folder_path: rootPath
};
await putLidarrConfig(cfg);
// A new address is a different server, so its lists are read again.
await invalidate();
apiKeyInput = '';
} catch (e) {
saveError = said(errCode(e));
} finally {
isSaving = false;
}
}
async function onTest() {
isTesting = true;
testResult = null;
try {
const result = await testLidarrConnection({ base_url: baseUrl, api_key: apiKeyInput });
testResult = result;
if (result.ok) takeLists(result);
} catch (e) {
testResult = { ok: false, error: errCode(e) };
} finally {
isTesting = false;
}
}
// Disconnecting asks for DISCONNECT typed out, so a slip doesn't clear a
// connection the operator relies on.
let modalOpen = $state(false);
let disconnectInput = $state('');
let disconnectError = $state<string | null>(null);
const canDisconnect = $derived(disconnectInput.trim() === 'DISCONNECT');
async function onConfirmDisconnect() {
if (!canDisconnect) return;
disconnectError = null;
try {
await putLidarrConfig({
enabled: false,
base_url: '',
api_key: '',
default_quality_profile_id: 0,
default_metadata_profile_id: 0,
default_root_folder_path: ''
});
await invalidate();
modalOpen = false;
disconnectInput = '';
} catch (e) {
disconnectError = said(errCode(e));
}
}
function cancelDisconnect() {
modalOpen = false;
disconnectInput = '';
disconnectError = null;
}
</script>
{#snippet listError(key: string)}
{#if testListErrors[key]}
<p class="w-full text-xs text-error-fg">Couldn't read Lidarr's {LIST_NAMES[key]}.</p>
{/if}
{/snippet}
<SettingsGroup
title="Lidarr"
id="lidarr"
hint="Optional. Asked for a cleaner copy of a suspect album, and to remove albums."
>
<SettingRow label="Status">
<span class="text-sm text-text-secondary" data-testid="lidarr-state">
{#if testResult?.ok}
Connected · Lidarr {testResult.version}
{:else if config.data?.enabled}
Connected
{:else}
Not connected
{/if}
</span>
</SettingRow>
<SettingRow label="Address" for="lidarr-url">
<input id="lidarr-url" type="text" bind:value={baseUrl} placeholder="http://lidarr.lan:8686" class={field} />
</SettingRow>
<SettingRow label="API key" for="lidarr-key" hint="In Lidarr under Settings › General.">
<input
id="lidarr-key"
type="password"
bind:value={apiKeyInput}
placeholder={config.data?.api_key === '***' ? 'Key saved · leave empty to keep it' : 'Paste the API key'}
class={field}
/>
</SettingRow>
<SettingRow label="Quality profile" for="lidarr-quality">
<select
id="lidarr-quality"
bind:value={qualityId}
disabled={qualityList.length === 0}
class="{field} disabled:opacity-50"
>
{#each qualityList as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
{@render listError('quality_profiles')}
</SettingRow>
<SettingRow label="Metadata profile" for="lidarr-metadata">
<select
id="lidarr-metadata"
bind:value={metadataId}
disabled={metadataList.length === 0}
class="{field} disabled:opacity-50"
>
{#each metadataList as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
{@render listError('metadata_profiles')}
</SettingRow>
<SettingRow label="Root folder" for="lidarr-root">
<select id="lidarr-root" bind:value={rootPath} disabled={folderList.length === 0} class="{field} disabled:opacity-50">
{#each folderList as f (f.path)}
<option value={f.path}>{f.path}{f.accessible ? '' : ' (Lidarr can’t reach it)'}</option>
{/each}
</select>
{@render listError('root_folders')}
</SettingRow>
<div class="flex flex-wrap items-center gap-2 px-4 py-3">
<button type="button" onclick={onTest} disabled={isTesting} class={button}>
{isTesting ? 'Testing…' : 'Test'}
</button>
<button
type="button"
onclick={onSave}
disabled={isSaving}
class="rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
>
{isSaving ? 'Saving…' : 'Save'}
</button>
{#if config.data?.enabled}
<button type="button" onclick={() => (modalOpen = true)} class="ml-auto {button}">Disconnect</button>
{/if}
{#if testResult && !testResult.ok && !saveError}
<p class="w-full text-sm text-error-fg" data-testid="lidarr-test-error">{said(testResult.error)}</p>
{/if}
{#if saveError}
<p class="w-full text-sm text-error-fg" data-testid="lidarr-save-error">Not saved: {saveError}</p>
{/if}
</div>
</SettingsGroup>
<Modal title="Disconnect Lidarr?" open={modalOpen} onClose={cancelDisconnect}>
<p class="-mt-2 text-text-secondary">
This clears the saved connection. Type
<span class="font-mono text-text-primary">DISCONNECT</span> to go ahead.
</p>
<input
type="text"
bind:value={disconnectInput}
placeholder="DISCONNECT"
aria-label="Type DISCONNECT to confirm"
class="mt-3 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent"
/>
{#if disconnectError}
<p class="mt-2 text-sm text-error-fg">Not disconnected: {disconnectError}</p>
{/if}
<div class="mt-5 flex justify-end gap-2">
<button type="button" onclick={cancelDisconnect} class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg">
Cancel
</button>
<button
type="button"
onclick={onConfirmDisconnect}
disabled={!canDisconnect}
class="rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
>
Disconnect
</button>
</div>
</Modal>
@@ -1,188 +0,0 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { LidarrConfig } from '#lib/api/types.js';
const invalidateQueries = vi.fn();
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, useQueryClient: () => ({ invalidateQueries }) };
});
vi.mock('#lib/api/admin.js', () => ({
createLidarrConfigQuery: vi.fn(),
createQualityProfilesQuery: vi.fn(),
createMetadataProfilesQuery: vi.fn(),
createRootFoldersQuery: vi.fn(),
putLidarrConfig: vi.fn(),
testLidarrConnection: vi.fn()
}));
import LidarrSettings from './LidarrSettings.svelte';
import {
createLidarrConfigQuery,
createQualityProfilesQuery,
createMetadataProfilesQuery,
createRootFoldersQuery,
putLidarrConfig,
testLidarrConnection
} from '#lib/api/admin.js';
const connected: LidarrConfig = {
enabled: true,
base_url: 'http://lidarr.local',
api_key: '***',
default_quality_profile_id: 1,
default_metadata_profile_id: 1,
default_root_folder_path: '/music'
};
const unset: LidarrConfig = {
enabled: false,
base_url: '',
api_key: '',
default_quality_profile_id: 0,
default_metadata_profile_id: 0,
default_root_folder_path: ''
};
const testOk = {
ok: true as const,
version: '2.0.5',
quality_profiles: [
{ id: 1, name: 'FLAC' },
{ id: 2, name: 'MP3-320' }
],
metadata_profiles: [{ id: 1, name: 'Standard' }],
root_folders: [{ path: '/music', accessible: true, free_space: 12345 }]
};
afterEach(() => {
vi.clearAllMocks();
invalidateQueries.mockReset();
});
function setup(config: LidarrConfig = connected, lists = true) {
vi.mocked(createLidarrConfigQuery).mockReturnValue(mockQuery({ data: config }) as never);
vi.mocked(createQualityProfilesQuery).mockReturnValue(
mockQuery({ data: lists ? [{ id: 1, name: 'Standard' }, { id: 2, name: 'Lossless' }] : [] }) as never
);
vi.mocked(createMetadataProfilesQuery).mockReturnValue(
mockQuery({ data: lists ? [{ id: 1, name: 'Standard' }, { id: 2, name: 'Singles only' }] : [] }) as never
);
vi.mocked(createRootFoldersQuery).mockReturnValue(
mockQuery({ data: lists ? [{ path: '/music', accessible: true, free_space: 0 }] : [] }) as never
);
return render(LidarrSettings);
}
function modalConfirm(): HTMLElement {
return within(screen.getByRole('dialog')).getByRole('button', { name: /^disconnect$/i });
}
describe('LidarrSettings', () => {
test('connected, it says so; not connected, it says that and offers no Disconnect', () => {
const { unmount } = setup();
expect(screen.getByTestId('lidarr-state').textContent?.trim()).toBe('Connected');
expect(screen.getByRole('button', { name: /^disconnect$/i })).toBeInTheDocument();
unmount();
setup(unset);
expect(screen.getByTestId('lidarr-state').textContent?.trim()).toBe('Not connected');
expect(screen.queryByRole('button', { name: /^disconnect$/i })).toBeNull();
});
test('Save tests first, then saves with an empty key, which keeps the saved one', async () => {
setup();
vi.mocked(testLidarrConnection).mockResolvedValueOnce(testOk);
vi.mocked(putLidarrConfig).mockResolvedValueOnce(connected);
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() =>
expect(putLidarrConfig).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true, base_url: 'http://lidarr.local', api_key: '', default_root_folder_path: '/music' })
)
);
});
test('a typed key is sent', async () => {
setup();
await fireEvent.input(screen.getByLabelText(/api key/i), { target: { value: 'newkey' } });
vi.mocked(testLidarrConnection).mockResolvedValueOnce(testOk);
vi.mocked(putLidarrConfig).mockResolvedValueOnce(connected);
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(putLidarrConfig).toHaveBeenCalledWith(expect.objectContaining({ api_key: 'newkey' })));
});
test('Test shows the Lidarr version when it connects', async () => {
setup();
vi.mocked(testLidarrConnection).mockResolvedValueOnce({ ok: true, version: '2.0.0' });
await fireEvent.click(screen.getByRole('button', { name: /^test$/i }));
await waitFor(() => expect(screen.getByTestId('lidarr-state').textContent).toMatch(/lidarr 2\.0\.0/i));
});
test('a failed test says what went wrong in words, not its code', async () => {
setup();
vi.mocked(testLidarrConnection).mockResolvedValueOnce({ ok: false, error: 'lidarr_unreachable' });
await fireEvent.click(screen.getByRole('button', { name: /^test$/i }));
const said = await screen.findByTestId('lidarr-test-error');
expect(said.textContent).toMatch(/couldn't reach lidarr/i);
expect(said.textContent).not.toMatch(/lidarr_unreachable/);
});
test('a save whose test fails is not saved, and says why', async () => {
setup(unset, false);
vi.mocked(testLidarrConnection).mockResolvedValueOnce({ ok: false, error: 'lidarr_auth_failed' });
await fireEvent.input(screen.getByLabelText(/address/i), { target: { value: 'http://wrong.local' } });
await fireEvent.input(screen.getByLabelText(/api key/i), { target: { value: 'k' } });
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(screen.getByTestId('lidarr-save-error').textContent).toMatch(/turned the api key down/i));
expect(putLidarrConfig).not.toHaveBeenCalled();
});
test('first-time setup fills the defaults from the test, keeping a choice made after it', async () => {
setup(unset, false);
vi.mocked(testLidarrConnection).mockResolvedValue(testOk);
vi.mocked(putLidarrConfig).mockResolvedValueOnce(connected);
await fireEvent.input(screen.getByLabelText(/address/i), { target: { value: 'http://lidarr.local' } });
await fireEvent.input(screen.getByLabelText(/api key/i), { target: { value: 'secret-key' } });
await fireEvent.click(screen.getByRole('button', { name: /^test$/i }));
await waitFor(() => expect(testLidarrConnection).toHaveBeenCalledTimes(1));
await fireEvent.change(screen.getByLabelText(/quality profile/i), { target: { value: '2' } });
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() =>
expect(putLidarrConfig).toHaveBeenCalledWith({
enabled: true,
base_url: 'http://lidarr.local',
api_key: 'secret-key',
default_quality_profile_id: 2,
default_metadata_profile_id: 1,
default_root_folder_path: '/music'
})
);
});
test('Disconnect needs DISCONNECT typed; cancelling changes nothing', async () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /^disconnect$/i }));
expect((modalConfirm() as HTMLButtonElement).disabled).toBe(true);
await fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(putLidarrConfig).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
test('Disconnect, confirmed, clears the connection', async () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /^disconnect$/i }));
await fireEvent.input(screen.getByPlaceholderText('DISCONNECT'), { target: { value: 'DISCONNECT' } });
vi.mocked(putLidarrConfig).mockResolvedValueOnce(unset);
await fireEvent.click(modalConfirm());
expect(putLidarrConfig).toHaveBeenCalledWith(expect.objectContaining({ enabled: false, base_url: '', api_key: '' }));
});
test('the profile and folder choices come from Lidarr', () => {
setup();
expect(screen.getAllByText('Standard').length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('Lossless')).toBeInTheDocument();
expect(screen.getByText('Singles only')).toBeInTheDocument();
expect(screen.getByText('/music')).toBeInTheDocument();
});
});
@@ -2,9 +2,9 @@
import Cover from '#lib/components/Cover.svelte';
import type { WantArtist } from '#lib/api/types.js';
// One artist in the Collection's catalogue (#5572), like a Lidarr artist
// poster: the picture, a bar of how many of their albums on the list are
// held, and whether Minstrel follows them for new releases.
// One artist in the Collection's catalogue (#5572), as a poster: the
// picture, a bar of how many of their albums on the list are held, and
// whether Minstrel follows them for new releases.
let { artist: a }: { artist: WantArtist } = $props();
@@ -7,8 +7,7 @@
import CatalogueArtistCard from './CatalogueArtistCard.svelte';
import type { WantArtist } from '#lib/api/types.js';
// The Collection's catalogue (#5572), its main page, shaped like Lidarr's
// artist index so a person coming from Lidarr finds their way: every
// The Collection's catalogue (#5572), its main page, an artist index: every
// artist on the list A–Z, each card showing how many of their albums are
// held. An artist opens on their albums. The library's albums were put on
// the list as held once (#5567), each matched to MusicBrainz (#5569); the
@@ -2,11 +2,10 @@
import SectionRail from '#lib/components/SectionRail.svelte';
import type { WantCounts } from '#lib/api/types.js';
// The Collection's sections, down the left like Lidarr's (#5572): the
// catalogue of artists first, then the sections that show state. Activity
// carries a second, accented count when downloads are waiting on a person.
// Unmatched, like Lidarr's Unmapped Files, holds the library albums no
// MusicBrainz album matched (#5579). Upgrades, Lidarr's Cutoff Unmet,
// The Collection's sections, down the left (#5572): the catalogue of
// artists first, then the sections that show state. Activity carries a
// second, accented count when downloads are waiting on a person. Unmatched
// holds the library albums no MusicBrainz album matched (#5579). Upgrades
// holds the albums held but wanted for a better copy (#5524).
let { counts }: { counts?: WantCounts } = $props();
@@ -8,12 +8,12 @@
import UnmatchedMatch from './UnmatchedMatch.svelte';
import type { UnmatchedAlbum } from '#lib/api/types.js';
// The Collection's Unmatched section (#5579), like Lidarr's Unmapped
// Files: the library albums no MusicBrainz album matched by artist, title
// and tracks. Each can be matched by hand, to a search result or a pasted
// link, and goes on the list as held; or left off, as a local band's album
// MusicBrainz will never have. Until then an album isn't fetched back if
// its files go missing.
// The Collection's Unmatched section (#5579): the library albums no
// MusicBrainz album matched by artist, title and tracks. Each can be
// matched by hand, to a search result or a pasted link, and goes on the
// list as held; or left off, as a local band's album MusicBrainz will
// never have. Until then an album isn't fetched back if its files go
// missing.
const client = useQueryClient();
const unmatchedStore = createUnmatchedQuery();
-8
View File
@@ -34,14 +34,6 @@
"system_playlist_readonly": "System playlists can't be edited directly.",
"connection_refused": "Couldn't reach the server. Check the URL and try again.",
"musicbrainz_unreachable": "MusicBrainz can't be reached right now, so search is unavailable. Try again in a minute.",
"lidarr_unreachable": "Lidarr is unreachable right now. Try again, or check Admin › Acquisition.",
"lidarr_disabled": "Lidarr integration is not enabled.",
"lidarr_auth_failed": "Lidarr authentication failed.",
"lidarr_defaults_incomplete": "Lidarr is missing a default quality profile or root folder. Set them in Admin › Acquisition.",
"lidarr_server_error": "Lidarr returned an error. Check Lidarr's logs for the cause.",
"lidarr_rejected": "Lidarr rejected the request. Check the server logs for the field-level reason.",
"lidarr_album_lookup_failed": "Lidarr doesn't recognize this album. Try Resolve or Delete file instead.",
"album_mbid_missing": "This track has no Lidarr album to remove.",
"request_not_pending": "This request is no longer pending.",
"request_not_found": "That request no longer exists.",
"track_not_found": "That track no longer exists.",
@@ -3,12 +3,11 @@
import AcquisitionCard from '#lib/components/AcquisitionCard.svelte';
import SearchSettingsCard from '#lib/components/SearchSettingsCard.svelte';
import ReacquisitionSettingsCard from '#lib/components/ReacquisitionSettingsCard.svelte';
import LidarrSettings from '#lib/components/admin/LidarrSettings.svelte';
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
// Admin › Acquisition (#5607): how Minstrel gets music. Where it searches
// and what downloads, how it searches and what it takes, what happens to
// a file that goes missing, and the optional Lidarr connection.
// and what downloads, how it searches and what it takes, and what happens
// to a file that goes missing.
</script>
<svelte:head><title>{pageTitle('Admin · Acquisition')}</title></svelte:head>
@@ -22,6 +21,5 @@
{/snippet}
{#snippet side()}
<div id="reacquisition"><ReacquisitionSettingsCard /></div>
<LidarrSettings />
{/snippet}
</SettingsColumns>
+13 -40
View File
@@ -43,10 +43,9 @@
let sweeping = $state(false);
let dismissing = $state<string | null>(null);
// Per group: which copy to keep (defaults to the proposed survivor), whether to
// unmonitor the removed copies in Lidarr, and the two-click confirm.
// Per group: which copy to keep (defaults to the proposed survivor) and the
// two-click confirm.
let keepChoice = $state<Record<string, string>>({});
let unmonitorChoice = $state<Record<string, boolean>>({});
let confirmingMerge = $state<string | null>(null);
let merging = $state<string | null>(null);
@@ -116,14 +115,8 @@
confirmingMerge = null;
merging = group.id;
try {
const result = await mergeDuplicateGroup(group.id, {
survivor_track_id: keeperOf(group),
unmonitor: unmonitorChoice[group.id] ?? false
});
const result = await mergeDuplicateGroup(group.id, { survivor_track_id: keeperOf(group) });
pushToast(`Merged. Removed ${fileCountLabel(result.removed_paths.length)}.`);
if (result.lidarr_unmonitor_failed) {
pushToast("Merged, but Lidarr couldn't be told to stop monitoring the removed copies.", 'error');
}
query.refetch();
} catch (e: unknown) {
pushToast(errMessage(e), 'error');
@@ -261,30 +254,18 @@
{:else}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each activity as item (item.id)}
{@const removed = item.details.removed ?? []}
<li class="space-y-1 px-4 py-3 text-sm" data-testid="resolved-item">
{#if item.action === 'duplicate_merge'}
{@const removed = item.details.removed ?? []}
<p class="text-text-primary">
Kept <span class="font-mono text-xs">{basename(item.details.survivor_path ?? '')}</span>
{#if item.details.survivor_reason}
<span class="text-text-secondary">({item.details.survivor_reason})</span>
{/if}
and removed {fileCountLabel(removed.length)}.
</p>
{#each removed as r (r.track_id)}
<p class="truncate font-mono text-xs text-text-muted" title={r.file_path}>{r.file_path}</p>
{/each}
{:else}
<p class="text-text-primary">
Lidarr now monitors {item.details.to_release ?? 'another release'} of
{item.details.album_title ?? 'an album'}{item.details.artist_name
? ` by ${item.details.artist_name}`
: ''}, which lists each song once.
</p>
{#if item.details.from_release}
<p class="text-xs text-text-secondary">Was {item.details.from_release}.</p>
<p class="text-text-primary">
Kept <span class="font-mono text-xs">{basename(item.details.survivor_path ?? '')}</span>
{#if item.details.survivor_reason}
<span class="text-text-secondary">({item.details.survivor_reason})</span>
{/if}
{/if}
and removed {fileCountLabel(removed.length)}.
</p>
{#each removed as r (r.track_id)}
<p class="truncate font-mono text-xs text-text-muted" title={r.file_path}>{r.file_path}</p>
{/each}
<p class="text-xs text-text-muted">{relativeTime(item.created_at)}</p>
</li>
{/each}
@@ -412,14 +393,6 @@
The copy marked Keep stays. The other {fileCountLabel(group.members.length - 1)} will be
deleted from disk, and their likes, plays and playlist entries move to the copy kept.
</p>
<label class="flex items-center gap-2 text-text-secondary">
<input
type="checkbox"
checked={unmonitorChoice[group.id] ?? false}
onchange={(e) => (unmonitorChoice[group.id] = e.currentTarget.checked)}
/>
Tell Lidarr to stop monitoring the removed copies, so it doesn't download them again
</label>
<button
type="button"
class="text-xs text-text-secondary underline hover:text-text-primary"
@@ -282,10 +282,7 @@ describe('admin duplicates', () => {
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
});
expect(mergeDuplicateGroup).toHaveBeenCalledWith('g-1', { survivor_track_id: 'www-01' });
});
test('choosing another copy to keep sends that copy', async () => {
@@ -296,10 +293,7 @@ describe('admin duplicates', () => {
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
});
expect(mergeDuplicateGroup).toHaveBeenCalledWith('g-1', { survivor_track_id: 'www-02' });
});
test('Cancel disarms the merge', async () => {
@@ -331,9 +325,7 @@ describe('admin duplicates', () => {
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 () => {
test('Resolved automatically lists the merges the resolver made', async () => {
renderWith(response(), [
{
id: 'a-1',
@@ -344,23 +336,11 @@ describe('admin duplicates', () => {
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();
});
});
@@ -378,7 +358,7 @@ describe('admin duplicates', () => {
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 });
expect(mergeDuplicateGroup).toHaveBeenCalledWith('g-1', { survivor_track_id: 'www-02' });
});
});
});
+5 -77
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import { Music2, CheckCircle2, Trash2, Cloud, Play, Copy, ChevronRight } from 'lucide-svelte';
import { Music2, CheckCircle2, Trash2, Play, Copy, ChevronRight } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import RowActionsMenu, { type RowAction } from '#lib/components/RowActionsMenu.svelte';
import TabStrip from '#lib/components/TabStrip.svelte';
@@ -11,8 +11,7 @@
createQuarantineActionsQuery,
resolveQuarantine,
resolvePlaybackError,
deleteQuarantineFile,
deleteQuarantineViaLidarr
deleteQuarantineFile
} from '#lib/api/admin.js';
import { qk } from '#lib/api/queries.js';
import { errMessage } from '#lib/api/errors.js';
@@ -28,7 +27,7 @@
mergeReports,
type ReportRow
} from '#lib/admin/reports.js';
import type { LidarrQuarantineReason } from '#lib/api/types.js';
import type { QuarantineReason } from '#lib/api/types.js';
// Admin › Reports (#5606): every broken track in one queue, whether a
// listener flagged it or it failed to play on a client, one row per track
@@ -128,30 +127,6 @@
await refresh();
}
let deleteLidarrId = $state<string | null>(null);
let deleteLidarrInput = $state('');
let deleteLidarrError = $state<string | null>(null);
const deleteLidarrRow = $derived(deleteLidarrId ? (rows.find((r) => r.track_id === deleteLidarrId) ?? null) : null);
function openDeleteLidarr(r: ReportRow) {
deleteLidarrId = r.track_id;
deleteLidarrInput = '';
deleteLidarrError = null;
}
async function confirmDeleteLidarr(r: ReportRow) {
if (deleteLidarrInput.trim() !== 'DELETE') return;
try {
await deleteQuarantineViaLidarr(r.track_id);
deleteLidarrId = null;
pushToast(`Asked Lidarr to remove "${r.album}"`);
await refresh();
} catch (e) {
// The modal stays open with the failure beside the album named.
deleteLidarrError = errMessage(e);
}
}
function actionsFor(r: ReportRow): { primary: RowAction; secondary: RowAction[] } {
return {
primary: { icon: CheckCircle2, label: 'Resolve', ariaLabel: `Resolve ${r.title}`, onclick: () => onResolve(r) },
@@ -164,19 +139,7 @@
ariaLabel: `Delete file for ${r.title}`,
danger: true,
onclick: () => (deleteFileId = r.track_id)
},
// A local-only track has no Lidarr album to remove.
...(r.lidarr_album_mbid
? [
{
icon: Cloud,
label: 'Delete via Lidarr',
ariaLabel: `Delete via Lidarr for ${r.title}`,
danger: true,
onclick: () => openDeleteLidarr(r)
}
]
: [])
}
]
};
}
@@ -234,7 +197,7 @@
</div>
<div class="flex flex-wrap gap-1.5" data-testid="report-kinds">
{#each Object.entries(r.reason_counts) as [reason, n] (reason)}
<span class="pill">{n}× {REASON_LABELS[reason as LidarrQuarantineReason] ?? reason}</span>
<span class="pill">{n}× {REASON_LABELS[reason as QuarantineReason] ?? reason}</span>
{/each}
{#each errorCounts(r) as [kind, n] (kind)}
<span class="pill pill-error">{n}× {ERROR_LABELS[kind] ?? kind}</span>
@@ -321,41 +284,6 @@
{/if}
</Modal>
<Modal title="Delete via Lidarr?" open={deleteLidarrRow !== null} onClose={() => (deleteLidarrId = null)}>
{#if deleteLidarrRow}
<p class="-mt-2 text-sm text-text-secondary">
Lidarr removes the whole album <em class="font-medium text-text-primary">{deleteLidarrRow.album}</em> by
{deleteLidarrRow.artist}, files included, and won't import it again. Type <strong>DELETE</strong> to confirm.
</p>
<input
type="text"
bind:value={deleteLidarrInput}
placeholder="DELETE"
aria-label="Type DELETE to confirm"
class="mt-3 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent"
/>
{#if deleteLidarrError}
<p class="mt-3 text-sm text-error-fg" data-testid="delete-lidarr-error">{deleteLidarrError}</p>
{/if}
<div class="mt-5 flex justify-end gap-2">
<button
type="button"
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
onclick={() => (deleteLidarrId = null)}>Cancel</button
>
<button
type="button"
aria-label="Confirm delete via Lidarr"
disabled={deleteLidarrInput.trim() !== 'DELETE'}
class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => confirmDeleteLidarr(deleteLidarrRow!)}
>
<Cloud size={14} strokeWidth={2} /> Delete via Lidarr
</button>
</div>
{/if}
</Modal>
<style>
.pill {
display: inline-flex;
+3 -7
View File
@@ -9,8 +9,7 @@ vi.mock('#lib/api/admin.js', () => ({
createQuarantineActionsQuery: vi.fn(),
resolveQuarantine: vi.fn().mockResolvedValue({}),
resolvePlaybackError: vi.fn().mockResolvedValue({}),
deleteQuarantineFile: vi.fn().mockResolvedValue({}),
deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({})
deleteQuarantineFile: vi.fn().mockResolvedValue({})
}));
vi.mock('#lib/player/store.svelte.js', () => ({ playRadio: vi.fn() }));
@@ -32,7 +31,6 @@ const flagged: AdminQuarantineRow = {
artist_name: 'Boards of Canada',
album_title: 'Geogaddi',
album_id: 'al1',
lidarr_album_mbid: 'rg1',
report_count: 1,
latest_at: hoursAgo(5),
reason_counts: { bad_rip: 1 },
@@ -99,13 +97,11 @@ describe('/admin/reports', () => {
expect(resolvePlaybackError).not.toHaveBeenCalled();
});
test('Delete via Lidarr is offered only for an album Lidarr holds, and on a phone it is in the row menu', async () => {
test('on a phone the row's other actions are in its menu', async () => {
setup([flagged], [failure('e2', 't2', 'Intro', 2)]);
expect(screen.getByRole('button', { name: /delete via lidarr for roygbiv/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /delete via lidarr for intro/i })).toBeNull();
const roygbiv = screen.getAllByTestId('report-row').find((r) => r.textContent?.includes('Roygbiv'))!;
await fireEvent.click(within(roygbiv).getByRole('button', { name: /more actions/i }));
expect(within(screen.getByRole('menu')).getByText('Delete via Lidarr')).toBeInTheDocument();
expect(within(screen.getByRole('menu')).getByText('Delete file')).toBeInTheDocument();
});
test('details show who flagged it, who it failed for, and the file', async () => {
@@ -19,7 +19,7 @@
// is a reason to look, not proof, so each row says what was done and offers
// the undo: "This one is fine" lets the track back for good. A rip's album
// stays wanted for a clean copy, searched for weekly and filed over the rip
// when it comes (#5524, which took over #5447's Lidarr search).
// when it comes (#5524).
const queryStore = createSuspectSourcesQuery();
const query = $derived($queryStore);
+1 -1
View File
@@ -41,7 +41,7 @@
);
// Fetch every track for the artist (the server already honours per-user
// lidarr_quarantine) and start the queue, in the server's order for Play
// quarantine) and start the queue, in the server's order for Play
// and shuffled for Shuffle. Bypassed when the artist has nothing to play.
let isStartingPlay = $state(false);
+2 -3
View File
@@ -5,9 +5,8 @@
let { children } = $props<{ children: import('svelte').Snippet }>();
// The Collection (M502, #5572): what the library holds and wants, and what
// Minstrel is fetching for it. Web-only and admin-only. Shaped like Lidarr
// so a person moving from it finds their way: the catalogue of artists is
// the main section, and the sections down the left show state. The
// Minstrel is fetching for it. Web-only and admin-only. The catalogue of
// artists is the main section, and the sections down the left show state. The
// catalogue's first page carries every section's count; the Catalogue
// shares it, so this costs no extra request there.
const artistsStore = createWantArtistsQuery();
+3 -3
View File
@@ -4,7 +4,7 @@
import { useQueryClient } from '@tanstack/svelte-query';
import { createMyQuarantineQuery, unflagTrack } from '#lib/api/quarantine.js';
import { qk } from '#lib/api/queries.js';
import type { LidarrQuarantineMineRow, LidarrQuarantineReason } from '#lib/api/types.js';
import type { QuarantineMineRow, QuarantineReason } from '#lib/api/types.js';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import { coverUrl } from '#lib/media/covers.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
@@ -12,9 +12,9 @@
const client = useQueryClient();
const queryStore = createMyQuarantineQuery();
const query = $derived($queryStore);
const rows = $derived((query.data ?? []) as LidarrQuarantineMineRow[]);
const rows = $derived((query.data ?? []) as QuarantineMineRow[]);
const REASON_LABELS: Record<LidarrQuarantineReason, string> = {
const REASON_LABELS: Record<QuarantineReason, string> = {
bad_rip: 'Bad rip',
wrong_file: 'Wrong file',
wrong_tags: 'Wrong tags',
+2 -2
View File
@@ -9,9 +9,9 @@ vi.mock('#lib/api/quarantine.js', () => ({
import HiddenPage from './+page.svelte';
import { createMyQuarantineQuery, unflagTrack } from '#lib/api/quarantine.js';
import type { LidarrQuarantineMineRow } from '#lib/api/types.js';
import type { QuarantineMineRow } from '#lib/api/types.js';
const baseRow: LidarrQuarantineMineRow = {
const baseRow: QuarantineMineRow = {
track_id: 't1',
reason: 'bad_rip',
notes: 'crackles',