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
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:
@@ -17,6 +17,7 @@ function track(id: string): AdminSuspectTrack {
|
||||
markers: [],
|
||||
verdict: null,
|
||||
verdict_at: null,
|
||||
replacing: false,
|
||||
searched_at: null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]} {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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" />
|
||||
Reference in New Issue
Block a user