feat: a held album below the quality you settle for, or holding a video rip, stays wanted for a better copy, and the import replaces the old tracks with their likes and plays carried over (M502 #5524)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 27s
release / Verify release artifacts (tag releases only) (push) Skipped

- acquisition_settings.upgrade_until (default High quality lossy, Lidarr's
  cutoff, note #5527; "" is off), an "Upgrade until" row under Searching.
- The wanted worker judges held albums hourly: tier per format from size per
  second (the scanner records no bitrate), the album's worst. Below the
  cutoff it is wanted for quality; a video rip among its tracks always.
- The schedule searches upgrades weekly, after open wants; a release no
  better than the copy held is refused ("not_better").
- The import matches each file to the track it would replace (recording,
  then disc/track) and replaces it when it is a rip or the file is a better
  tier: over the same path in one rename, or filed beside it and the old
  track folded in (library.ReplaceTrack: file first, then foldTrackInto).
  Nothing better: the release is set aside.
- Collection › Upgrades (Lidarr's Cutoff Unmet); Suspect sources says when a
  clean copy was looked for instead of Lidarr's search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 12:24:59 -04:00
co-authored by Claude Opus 5.5
parent a5ad301c94
commit c54792c51e
45 changed files with 1574 additions and 99 deletions
@@ -17,6 +17,7 @@ function track(id: string): AdminSuspectTrack {
markers: [],
verdict: null,
verdict_at: null,
replacing: false,
searched_at: null
};
}
+16 -2
View File
@@ -443,7 +443,9 @@ export type AdminSuspectTrack = {
// back. null: the resolver hasn't looked yet (M498 #5439).
verdict: SuspectVerdict | null;
verdict_at: string | null;
// When Lidarr was last asked to search the album for a better copy (#5447).
// The album is on the wanted list for a clean copy, and when one was last
// searched for (#5524).
replacing: boolean;
searched_at: string | null;
};
@@ -572,7 +574,11 @@ export type ResolvedDuplicatesResponse = {
// library: in the library when the list began (#5567).
export type WantReason = 'request' | 'follow' | 'missing' | 'library';
// all: one artist's page, every album of theirs on the list (#5572).
export type WantState = 'wanted' | 'missing' | 'held' | 'all';
// upgrade: held albums wanted for a better copy (#5524).
export type WantState = 'wanted' | 'missing' | 'upgrade' | 'held' | 'all';
// Why a held album is wanted again: its copy is below the quality settled
// for, or one of its tracks is a video rip (#5524).
export type WantUpgrade = 'quality' | 'rip';
// One release group on the wanted list. held_at is set once the library
// holds it; artist_id and album_id link into the library when it has them.
@@ -592,6 +598,10 @@ export type Want = {
wanted_at: string;
held_at: string | null;
image_url: string;
// A held album wanted for a better copy, and what the copy it has is
// ("MP3, about 192 kbps") (#5524).
upgrade?: WantUpgrade;
held_quality?: string;
// The search schedule's last look, and the download running (#5521).
search?: WantSearch | null;
grab?: WantGrab | null;
@@ -720,6 +730,8 @@ export type SearchSettings = {
seed_ratio: number;
seed_days: number;
stall_hours: number;
// The tier a held album is upgraded to; "" is no quality upgrades (#5524).
upgrade_until: string;
};
export type WantCounts = {
@@ -728,6 +740,8 @@ export type WantCounts = {
wanted: number;
missing: number;
held: number;
// Held albums wanted for a better copy (#5524).
upgrades: number;
following: number;
// Downloads running, those of them waiting on a person, releases set aside.
activity: number;
+1
View File
@@ -247,6 +247,7 @@ export async function putSearchSettings(body: {
seed_ratio: number;
seed_days: number;
stall_hours: number;
upgrade_until: string;
}): Promise<SearchSettings> {
return api.put<SearchSettings>('/api/admin/acquisition/settings', body);
}
@@ -15,7 +15,8 @@
// keeps to each indexer's searches-per-hour. A finished download is
// checked and filed into one of the library's folders (#5522); it then
// seeds until the ratio or the days below, and a download nobody shares
// is given up after the hours below (#5523).
// is given up after the hours below (#5523). A held album below "Upgrade
// until" is searched for weekly and replaced by a better copy (#5524).
const client = useQueryClient();
const button =
@@ -32,6 +33,7 @@
seed_ratio: number;
seed_days: number;
stall_hours: number;
upgrade_until: string;
};
// The form is the server's settings until edited; saved holds what a save
@@ -50,7 +52,8 @@
import_root: s.import_root,
seed_ratio: s.seed_ratio,
seed_days: s.seed_days,
stall_hours: s.stall_hours
stall_hours: s.stall_hours,
upgrade_until: s.upgrade_until
};
}
@@ -76,7 +79,8 @@
import_root: form.import_root,
seed_ratio: Number(form.seed_ratio),
seed_days: Number(form.seed_days),
stall_hours: Number(form.stall_hours)
stall_hours: Number(form.stall_hours),
upgrade_until: form.upgrade_until
});
edited = null;
await client.invalidateQueries({ queryKey: qk.searchSettings() });
@@ -155,6 +159,24 @@
</SettingRow>
</div>
{/each}
<SettingRow
label="Upgrade until"
for="upgrade-until"
hint="An album you have below this is searched for weekly and replaced by a better copy. A video rip is always replaced."
>
<select
id="upgrade-until"
value={form.upgrade_until}
onchange={(e) => edit({ upgrade_until: e.currentTarget.value })}
class="rounded-md border border-border bg-background px-2 py-1.5 text-sm"
>
<option value="">Don't upgrade</option>
{#each data.tiers.filter((t) => t.tier !== 'unknown') as t (t.tier)}
<option value={t.tier}>{t.label}</option>
{/each}
</select>
<a href="/collection/upgrades" class="text-sm text-accent-fg hover:underline">Upgrades</a>
</SettingRow>
{#if data.scan_paths.length > 1}
<SettingRow label="File new music into" for="search-root" hint="As Artist/Album (Year), tagged with its MusicBrainz ids.">
<select
@@ -27,7 +27,8 @@ const settings: SearchSettings = {
scan_paths: ['/music', '/music2'],
seed_ratio: 2,
seed_days: 14,
stall_hours: 6
stall_hours: 6,
upgrade_until: 'lossy_high'
};
afterEach(() => vi.clearAllMocks());
@@ -66,7 +67,8 @@ describe('SearchSettingsCard', () => {
import_root: '',
seed_ratio: 2,
seed_days: 14,
stall_hours: 6
stall_hours: 6,
upgrade_until: 'lossy_high'
})
);
});
@@ -102,6 +104,22 @@ describe('SearchSettingsCard', () => {
);
});
test('upgrades go up to the chosen quality, or are switched off', async () => {
setup();
const until = screen.getByLabelText(/upgrade until/i) as HTMLSelectElement;
expect(until.value).toBe('lossy_high');
expect([...until.options].map((o) => o.textContent)).toEqual([
"Don't upgrade",
'Lossless',
'High quality lossy'
]);
await fireEvent.change(until, { target: { value: '' } });
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() =>
expect(putSearchSettings).toHaveBeenCalledWith(expect.objectContaining({ upgrade_until: '' }))
);
});
test('before the first pass it says so', () => {
setup({ ...settings, last_pass_at: null, last_pass: {} });
expect(screen.getByTestId('last-pass').textContent).toMatch(/hasn’t run yet/);
@@ -39,7 +39,7 @@ function setup(
total: items.length,
limit: 96,
offset: 0,
counts: { artists: items.length, wanted: 1, missing: 0, held: 16, following: 1, activity: 1, attention: 0, blocked: 0, unmatched: 0 },
counts: { artists: items.length, wanted: 1, missing: 0, held: 16, upgrades: 0, following: 1, activity: 1, attention: 0, blocked: 0, unmatched: 0 },
library
};
vi.mocked(createWantArtistsQuery).mockReturnValue(
@@ -6,7 +6,8 @@
// 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).
// MusicBrainz album matched (#5579). Upgrades, Lidarr's Cutoff Unmet,
// holds the albums held but wanted for a better copy (#5524).
let { counts }: { counts?: WantCounts } = $props();
@@ -15,6 +16,7 @@
{ href: '/collection', label: 'Catalogue', count: counts?.artists, exact: true, also: ['/collection/artist/'] },
{ href: '/collection/wanted', label: 'Wanted', count: counts?.wanted },
{ href: '/collection/missing', label: 'Missing files', count: counts?.missing },
{ href: '/collection/upgrades', label: 'Upgrades', count: counts?.upgrades },
{ href: '/collection/activity', label: 'Activity', count: counts?.activity, attention: counts?.attention },
{ href: '/collection/following', label: 'Following', count: counts?.following },
{ href: '/collection/blocked', label: 'Blocked', count: counts?.blocked },
+24 -3
View File
@@ -22,6 +22,10 @@
// runs one now. Taking a held album off leaves its files and stops it
// being fetched back if they go.
//
// Upgrades (#5524) are held albums wanted for a better copy: below the
// quality set under Acquisition, or holding a video rip. They search and
// download like an open want, and say what copy they have.
//
// The list reads like the library (#5572): artists A–Z, each with its
// albums oldest first, and each album's tracks a click away. The filter
// narrows it to an artist or album by name, on the server, so it finds
@@ -166,6 +170,15 @@
return `Imported ${n} ${relativeTime(g.imported_at)} from ${g.name}`;
}
// A want the schedule still searches for: open, or held but wanted for a
// better copy.
const searchable = (w: Want) => !w.held_at || !!w.upgrade;
function upgradeLine(w: Want): string {
if (w.upgrade === 'rip') return 'A track is a video rip; looking for a clean copy';
return w.held_quality ? `You have ${w.held_quality}; looking for better` : 'Looking for a better copy';
}
const attention = (w: Want) => w.grab?.live && (w.grab.import_state === 'review' || w.grab.import_state === 'failed');
function why(w: Want): string {
@@ -231,11 +244,16 @@
<p class="mt-3 text-text-primary">
{#if artist}None of this artist's albums are in the catalogue.
{:else if list === 'missing'}No missing albums are wanted back.
{:else if list === 'upgrade'}No album is wanted for a better copy.
{:else}Nothing is wanted.{/if}
</p>
<p class="mt-1 text-sm text-text-secondary">
{#if list === 'missing'}
An album whose files stay missing past the grace window on the Missing files page lands here.
{:else if list === 'upgrade'}
An album you have below the quality set under
<a href="/admin/acquisition" class="text-accent-fg hover:underline">Acquisition</a>, or with a video
rip among its tracks, lands here.
{:else}
A request you approve, a new release from a followed artist, or an album whose files went
missing lands here.
@@ -278,7 +296,10 @@
<div class="text-xs text-text-muted" data-testid="want-why">
{why(w)} · {w.held_at ? `held ${relativeTime(w.held_at)}` : `wanted ${relativeTime(w.wanted_at)}`}
</div>
{#if !w.held_at}
{#if w.held_at && w.upgrade}
<div class="truncate text-xs text-text-secondary" data-testid="want-upgrade">{upgradeLine(w)}</div>
{/if}
{#if searchable(w)}
<div
class="text-xs {attention(w) ? 'text-text-primary' : 'truncate text-text-secondary'}"
data-testid="want-search"
@@ -303,7 +324,7 @@
<ListMusic size={12} strokeWidth={2} /> Tracks
<ChevronDown size={12} strokeWidth={2} class={shown === w.id ? 'rotate-180' : ''} />
</button>
{#if !w.held_at}
{#if searchable(w)}
<button
type="button"
onclick={() => onSearch(w)}
@@ -339,7 +360,7 @@
<WantTracks wantId={w.id} />
</div>
{/if}
{#if open === w.id && !w.held_at}
{#if open === w.id && searchable(w)}
<div class="sm:pl-[4.25rem]">
<WantCandidates wantId={w.id} title={w.title} />
</div>
+29 -1
View File
@@ -63,7 +63,7 @@ function page(
total: items.length,
limit: 50,
offset: 0,
counts: { artists: 2, wanted: 2, missing: 1, held: 4, following: 1, activity: 1, attention: 0, blocked: 0, unmatched: 0 },
counts: { artists: 2, wanted: 2, missing: 1, held: 4, upgrades: 0, following: 1, activity: 1, attention: 0, blocked: 0, unmatched: 0 },
library
};
}
@@ -314,4 +314,32 @@ describe('WantList', () => {
expect(screen.getAllByTestId('want-row')).toHaveLength(2);
expect(screen.queryByRole('link', { name: 'Blur' })).toBeNull();
});
test('an upgrade is a held album searched for a better copy', async () => {
setup(
[
want('u1', { held_at: ago(DAY), reason: 'library', upgrade: 'quality', held_quality: 'MP3, about 192 kbps' }),
want('u2', { held_at: ago(DAY), reason: 'library', upgrade: 'rip', title: 'Humanz' })
],
'upgrade'
);
expect(createWantsQuery).toHaveBeenCalledWith('upgrade', '');
const lines = screen.getAllByTestId('want-upgrade').map((e) => e.textContent ?? '');
expect(lines[0]).toMatch(/you have mp3, about 192 kbps; looking for better/i);
expect(lines[1]).toMatch(/video rip; looking for a clean copy/i);
expect(screen.getAllByTestId('want-search')[0].textContent).toMatch(/not searched yet/i);
await fireEvent.click(screen.getByRole('button', { name: /search for humanz now/i }));
await waitFor(() => expect(searchWant).toHaveBeenCalledWith('u2'));
});
test('a held album not wanted for a better copy has no search', () => {
setup([want('h1', { held_at: ago(DAY), reason: 'library' })], 'held');
expect(screen.queryByTestId('want-upgrade')).toBeNull();
expect(screen.queryByRole('button', { name: /search for/i })).toBeNull();
});
test('with no upgrades it says what would land here', () => {
setup([], 'upgrade');
expect(screen.getByText(/no album is wanted for a better copy/i)).toBeTruthy();
});
});
+3 -2
View File
@@ -75,8 +75,9 @@
"bad_quality": "Choose at least one quality to take.",
"candidate_not_found": "That release is no longer in the list. Search again.",
"no_infohash": "That release can't be set aside: its indexer gave no infohash to know it by.",
"want_held": "The library already holds that album.",
"want_held": "The library already holds that album, and it isn't wanted for a better copy.",
"block_not_found": "That release isn't set aside any more.",
"not_running": "That download isn't running any more. Refresh the list.",
"bad_lifecycle": "Seed ratio is 0 to 100, seed days 0 to 365, and stall hours 0 to 720."
"bad_lifecycle": "Seed ratio is 0 to 100, seed days 0 to 365, and stall hours 0 to 720.",
"bad_upgrade_until": "Upgrade until has to be one of the qualities listed."
}
@@ -15,10 +15,11 @@
// folder, with junk disc numbers and a reaction video filed as a song.
//
// The duplicate resolver holds each one back from radio and the mixes, and
// merges it away where Lidarr keeps a clean copy (M498 #5439). A marker 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 that is the
// only copy of its song has its album searched in Lidarr (#5447).
// merges it away where the library has a clean copy (M498 #5439). A marker
// 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).
const queryStore = createSuspectSourcesQuery();
const query = $derived($queryStore);
@@ -101,9 +102,10 @@
such as "(Official Video)", "[Audio]" or "Visualizer". These usually came from
a video site, and can be the wrong audio: a live take, a music-video edit, or
something else entirely. Each one is kept out of radio and the mixes, though it still
plays when you choose it, and it is removed where Lidarr keeps a clean copy of the same
song. Where it is the only copy, Lidarr is asked once a week to search the album for a
better release. If one is fine, say so and it goes back in.
plays when you choose it, and it is merged away where the library has a clean copy of the
same song. Where it is the only copy, its album is searched for a clean copy once a week,
under <a href="/collection/upgrades" class="text-accent-fg hover:underline">Upgrades</a>. If one
is fine, say so and it goes back in.
</p>
</header>
@@ -183,10 +185,12 @@
<span class="text-xs text-text-secondary" data-testid="suspect-verdict">
Held back{#if t.verdict_at && !changed[t.track_id]}&nbsp;{relativeTime(t.verdict_at)}{/if}
</span>
{#if t.searched_at}
<span class="text-xs text-text-muted" data-testid="suspect-searched">
Lidarr searched for a better copy {relativeTime(t.searched_at)}
</span>
{#if t.replacing}
<a href="/collection/upgrades" class="text-xs text-text-muted hover:underline" data-testid="suspect-searched">
{t.searched_at
? `Looked for a clean copy ${relativeTime(t.searched_at)}`
: 'A clean copy is wanted'}
</a>
{/if}
<button
type="button"
@@ -30,6 +30,7 @@ function track(title: string, file: string, markers: string[], disc: number | nu
markers,
verdict: 'suspect',
verdict_at: new Date(Date.now() - 3_600_000).toISOString(),
replacing: false,
searched_at: null
};
}
@@ -138,13 +139,16 @@ describe('Suspect sources page', () => {
expect(screen.getByRole('button', { name: 'Hold back again' })).toBeInTheDocument();
});
// #5447: a rip with no clean copy has its album searched in Lidarr, and the
// row says when.
test('shows when Lidarr searched for a better copy', () => {
// #5524: a rip's album is wanted for a clean copy, and the row says when
// one was last looked for.
test('shows that a clean copy is wanted, and when it was looked for', () => {
const t = track('Strobelite', 'Gorillaz - Strobelite (Official Video).mp3', ['music video']);
t.replacing = true;
t.searched_at = new Date(Date.now() - 7_200_000).toISOString();
mocked.mockReturnValue(infinite([page(0, 1, [{ directory: HUMANZ, tracks: [t] }])]));
render(SuspectSourcesPage);
expect(screen.getByTestId('suspect-searched')).toHaveTextContent(/Lidarr searched for a better copy/);
const line = screen.getByTestId('suspect-searched');
expect(line).toHaveTextContent(/Looked for a clean copy/);
expect(line).toHaveAttribute('href', '/collection/upgrades');
});
});
+2 -1
View File
@@ -12,7 +12,7 @@ vi.mock('#lib/api/wanted.js', () => ({ createWantArtistsQuery: vi.fn() }));
import WantedLayout from './+layout.svelte';
import { createWantArtistsQuery } from '#lib/api/wanted.js';
const counts: WantCounts = { artists: 7, wanted: 5, missing: 1, held: 9, following: 2, activity: 3, attention: 0, blocked: 4, unmatched: 3 };
const counts: WantCounts = { artists: 7, wanted: 5, missing: 1, held: 9, upgrades: 6, following: 2, activity: 3, attention: 0, blocked: 4, unmatched: 3 };
const children = createRawSnippet(() => ({ render: () => `<p data-testid="tab-body">body</p>` }));
afterEach(() => vi.clearAllMocks());
@@ -43,6 +43,7 @@ describe('/collection layout', () => {
['/collection', 'Catalogue 7'],
['/collection/wanted', 'Wanted 5'],
['/collection/missing', 'Missing files 1'],
['/collection/upgrades', 'Upgrades 6'],
['/collection/activity', 'Activity 3'],
['/collection/following', 'Following 2'],
['/collection/blocked', 'Blocked 4'],
@@ -0,0 +1,8 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import WantList from '#lib/components/wanted/WantList.svelte';
</script>
<svelte:head><title>{pageTitle('Collection · Upgrades')}</title></svelte:head>
<WantList list="upgrade" />