Files
minstrel/web/src/lib/components/wanted/UnmatchedMatch.svelte
T
bvandeusenandClaude Opus 5.5 a8786abace
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m17s
release / go (push) Successful in 1m34s
release / integration (push) Failing after 5m34s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m44s
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
feat: Collection › Unmatched lists the library albums no MusicBrainz album matched, each matched by hand from a search or a link, or left off (M502 #5579)
Like Lidarr's Unmapped Files. A local band's album, which MusicBrainz will
never have, is left off: never matched again and not counted. A re-tagged
album is matched again on the next listing. The Collection's verdict now
says when library albums need matching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:12:30 -04:00

107 lines
3.9 KiB
Svelte

<script lang="ts">
import { Search } from 'lucide-svelte';
import { createCatalogueSearchQuery } from '#lib/api/catalogue.js';
import type { CatalogueSearchResult, UnmatchedAlbum } from '#lib/api/types.js';
// Naming the MusicBrainz album an unmatched album is (#5579): a search of
// MusicBrainz's albums, started from the album's own title and artist, or
// a link pasted from musicbrainz.org. MusicBrainz takes a request a second,
// so the search runs when it's asked for, not on each keystroke.
let {
album,
busy = false,
onMatch
}: { album: UnmatchedAlbum; busy?: boolean; onMatch: (ref: string) => void } = $props();
// svelte-ignore state_referenced_locally
let text = $state(`${album.title} ${album.artist}`.trim());
// svelte-ignore state_referenced_locally
let asked = $state(`${album.title} ${album.artist}`.trim());
let link = $state('');
const searchStore = $derived(createCatalogueSearchQuery(asked, 'album'));
const search = $derived($searchStore);
const results = $derived((search.data ?? []) as CatalogueSearchResult[]);
</script>
<div class="mt-3 space-y-3 rounded-md border border-border bg-bg p-3" data-testid="unmatched-match">
<form
class="flex gap-2"
onsubmit={(e) => {
e.preventDefault();
asked = text.trim();
}}
>
<input
type="search"
bind:value={text}
aria-label="Search MusicBrainz albums"
class="min-w-0 flex-1 rounded-md border border-border bg-surface px-2.5 py-1.5 text-sm text-text-primary placeholder:text-text-muted"
/>
<button
type="submit"
class="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary"
>
<Search size={14} strokeWidth={1.75} aria-hidden="true" /> Search
</button>
</form>
{#if search.isPending && asked}
<p class="text-xs text-text-secondary">Asking MusicBrainz…</p>
{:else if search.isError}
<p class="text-xs text-error-fg">Couldn't reach MusicBrainz. A pasted link works the same.</p>
{:else if asked && results.length === 0}
<p class="text-xs text-text-secondary">MusicBrainz has no album like that.</p>
{:else if results.length > 0}
<ul class="divide-y divide-border">
{#each results as r (r.mbid)}
<li class="flex items-center gap-3 py-2" data-testid="unmatched-result">
<div class="min-w-0 flex-1">
<div class="truncate text-sm text-text-primary">{r.name}</div>
<div class="truncate text-xs text-text-secondary">{r.secondary_text}</div>
</div>
<a
href="https://musicbrainz.org/release-group/{r.mbid}"
target="_blank"
rel="noreferrer"
class="shrink-0 text-xs text-text-secondary hover:text-text-primary hover:underline">MusicBrainz</a
>
<button
type="button"
disabled={busy}
onclick={() => onMatch(r.mbid)}
aria-label="Match to {r.name}"
class="shrink-0 rounded-md border border-border px-2.5 py-1 text-xs text-text-primary hover:bg-surface-hover disabled:opacity-50"
>
This one
</button>
</li>
{/each}
</ul>
{/if}
<form
class="flex gap-2 border-t border-border pt-3"
onsubmit={(e) => {
e.preventDefault();
if (link.trim()) onMatch(link.trim());
}}
>
<input
type="text"
bind:value={link}
placeholder="Or paste a MusicBrainz album link"
aria-label="MusicBrainz album link"
class="min-w-0 flex-1 rounded-md border border-border bg-surface px-2.5 py-1.5 text-sm text-text-primary placeholder:text-text-muted"
/>
<button
type="submit"
disabled={busy || !link.trim()}
class="shrink-0 rounded-md border border-border px-2.5 py-1 text-xs text-text-primary hover:bg-surface-hover disabled:opacity-50"
>
Match
</button>
</form>
</div>