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
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>
190 lines
7.9 KiB
Svelte
190 lines
7.9 KiB
Svelte
<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>
|