Files
minstrel/web/src/routes/admin/missing-files/+page.svelte
T
bvandeusenandClaude Opus 5.5 ebbc49b902
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 15s
release / go (push) Failing after 1m16s
release / integration (push) Failing after 4m49s
release / android (push) Successful in 4m59s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
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: import with verification, and a Wanted view of its own (M502 #5522)
A finished download is checked against MusicBrainz before anything is
filed: the files are paired with an edition's tracks by position or path,
and AcoustID, tag ids, lengths and titles say whether it is the album.
What is the album is copied into the library as
Artist/Album (Year)/Artist - Album - NN - Title, tagged with Picard's
MusicBrainz ids, and scanned; the download keeps seeding. What is not is
refused: set aside, removed from the client, and the album searched for
again. A download the check cannot settle waits for a person, who imports
it anyway or refuses it. Tracks the library already holds are skipped.

The wanted list leaves Admin for a top-level Wanted view, admin-only and
web-only, with tabs for Wanted, Activity (downloads and their imports,
the ones needing a person first), Missing files, Held, Following (with the
release-type profiles, moved off Integrations) and Blocked. The search
settings gain "File new music into" when the library has several folders.

The search pass logs why each failed search failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:36:21 -04:00

190 lines
7.9 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import { FolderX, Music2 } from 'lucide-svelte';
import { createMissingFilesQuery } from '#lib/api/admin.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import { coverUrl } from '#lib/media/covers.js';
import ReacquisitionSettingsCard from '#lib/components/ReacquisitionSettingsCard.svelte';
import type { AdminMissingGroup } from '#lib/api/types.js';
// Files the scan looked for and could not find. Read-only on purpose:
// a missing file keeps its track row, its play history and its likes,
// because the file may come back — the scanner clears the mark by
// itself, and adopts the row if it returns under a new name (#2528).
// Nothing on this page deletes anything.
const PAGE_SIZE = 50;
let offset = $state(0);
// $derived so paging re-creates the query with a new key — same shape the
// playback-errors page uses for its tab state.
const queryStore = $derived(createMissingFilesQuery(offset, PAGE_SIZE));
const query = $derived($queryStore);
const groups = $derived((query.data?.groups ?? []) as AdminMissingGroup[]);
const total = $derived(query.data?.total ?? 0);
const shown = $derived(groups.reduce((n, g) => n + g.tracks.length, 0));
const hasMore = $derived(offset + shown < total);
function trackCountLabel(n: number): string {
return n === 1 ? '1 track' : `${n} tracks`;
}
function durationLabel(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
</script>
<svelte:head><title>{pageTitle('Admin · Missing files')}</title></svelte:head>
<div class="space-y-6">
<header class="space-y-1">
<div class="flex items-center gap-2">
<h2 class="font-display text-2xl font-medium text-text-primary">Missing files</h2>
{#if total > 0}
<span
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
data-testid="missing-count-pill"
>
{total}
</span>
{/if}
</div>
<p class="text-text-secondary">
Tracks whose audio file the last scan couldn't find. They keep their play
history and likes, and stop being offered anywhere until the file returns.
</p>
</header>
<!-- The policy sits above the list it governs: an operator looking at
missing files is exactly the person deciding what should happen to
them, and burying this under Integrations would separate the two. -->
<ReacquisitionSettingsCard />
{#if query.isPending}
<p class="text-text-secondary">Checking what's missing…</p>
{:else if query.isError}
<p class="text-error-fg">Couldn't load the missing-files list.</p>
{:else if groups.length === 0}
<!-- Empty state explains the feature, not just the emptiness: an operator
who has never seen this page shouldn't have to guess what would put a
row here or when it would appear. -->
<div class="rounded-lg border border-border bg-surface p-6 text-center">
<FolderX size={28} strokeWidth={1} class="mx-auto text-text-muted" />
<p class="mt-3 text-text-primary">Every track's file is where it should be.</p>
<p class="mt-1 text-sm text-text-secondary">
A track lands here when a library scan can't find its file — moved outside
Minstrel, deleted, or on a drive that didn't mount. It leaves on its own
when the file comes back.
</p>
</div>
{:else}
<ul class="space-y-4">
{#each groups as group (group.directory)}
<li class="overflow-hidden rounded-lg border border-border bg-surface">
<!-- The folder is the unit of decision. Three reorganised albums are
three things to think about, not forty. -->
<div class="flex items-baseline justify-between gap-4 border-b border-border px-4 py-3">
<h3 class="truncate font-mono text-sm text-text-primary" title={group.directory}>
{group.directory}
</h3>
<span class="shrink-0 text-xs text-text-secondary">
{trackCountLabel(group.tracks.length)} · gone {relativeTime(group.missing_since)}
</span>
</div>
<!-- Where this folder's album stands on the wanted list. Without it
the only way to tell "not wanted yet" from "wanted, nothing has
come" is to go and read the wanted list. -->
{#if group.reacquisition}
{@const r = group.reacquisition}
<p
class="border-b border-border px-4 py-2 text-xs text-text-secondary"
data-testid="reacquisition-state"
>
{#if r.held_at}
Held again {relativeTime(r.held_at)}. The rows here clear on the next scan.
{:else if r.wanted_at}
On the <a href="/wanted/missing" class="text-accent-fg hover:underline"
>wanted list</a
> since {relativeTime(r.wanted_at)}.
{/if}
</p>
{/if}
<ul class="divide-y divide-border">
{#each group.tracks as t (t.track_id)}
<li class="flex items-center gap-3 px-4 py-2" data-testid="missing-track-row">
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-surface-hover"
aria-hidden="true"
>
{#if t.album_id}
<img
src={coverUrl(t.album_id)}
alt=""
class="h-full w-full rounded object-cover"
loading="lazy"
/>
{:else}
<Music2 size={18} strokeWidth={1} class="text-text-muted" />
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm text-text-primary">{t.title}</div>
<div class="truncate text-xs text-text-secondary">
{t.artist_name} · {t.album_title}
</div>
</div>
<!-- Last played is the deciding fact: a file gone six months
that nobody ever played is not the same problem as one
played two hundred times last week. -->
<span class="shrink-0 text-xs text-text-muted">
{#if t.last_played_at}
last played {relativeTime(t.last_played_at)}
{:else}
never played
{/if}
</span>
<span class="shrink-0 text-xs text-text-muted">{durationLabel(t.duration_sec)}</span>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
{#if hasMore || offset > 0}
<nav class="flex items-center justify-between" aria-label="Missing files pages">
<button
type="button"
class="rounded border border-border px-3 py-1.5 text-sm text-text-primary
hover:bg-surface-hover focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-40"
disabled={offset === 0}
onclick={() => (offset = Math.max(0, offset - PAGE_SIZE))}
>
Previous
</button>
<span class="text-xs text-text-secondary">
{offset + 1}–{offset + shown} of {total}
</span>
<button
type="button"
class="rounded border border-border px-3 py-1.5 text-sm text-text-primary
hover:bg-surface-hover focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-40"
disabled={!hasMore}
onclick={() => (offset += PAGE_SIZE)}
>
Next
</button>
</nav>
{/if}
{/if}
</div>