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."
}