Files
minstrel/web/src/routes/admin/missing-files/+page.svelte
T
bvandeusenandClaude Opus 5.5 04515a977c
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
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: a wanted list — follow artists on release-type profiles; requests and re-acquisition become wants (M502 #5520)
Minstrel keeps its own list of what it is to get, keyed by MusicBrainz
release group. A want comes from an approved request, a new release by a
followed artist, or an album whose files went missing, and is held once the
library has a full edition of it.

- Migration 0081: release_type_profiles (Standard seeded: Album, EP,
  Single, studio only), follows, follow_seen_groups and wants.
  lidarr_requests becomes requests with plain *_mbid columns; approved
  album and track requests move onto the wanted list, approved artist
  requests become follows, and the library's album artists start out
  followed. missing_reacquisitions and the retry settings go.
- internal/wanted: follows are checked against MusicBrainz discographies
  daily. The first check sets a baseline, and only releases after it are
  wanted unless the follow asked for what already exists. Profiles filter
  by primary and secondary type, so live recordings stay out unless a
  profile ticks Live.
- internal/requests (was lidarrrequests): approving puts the request on
  the wanted list, and an artist request can be approved on a chosen
  profile. A request completes when its want is held.
- The re-acquisition sweeper wants a missing album back instead of asking
  Lidarr; searching for it is the search schedule's (#5521).
- Web: an admin Wanted page (Wanted, Missing files, Held, Following), a
  follow control on artist pages, a release-type profiles card on
  Integrations, "Approve on a profile" on Requests, and want state on
  Missing files. The artist detail API returns the artist's MBID.
- Android: the request body sends artist_mbid, album_mbid and track_mbid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:46:22 -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="/admin/wanted?tab=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>