Files
minstrel/web/src/routes/admin/missing-files/+page.svelte
T
bvandeusenandClaude Opus 5.5 fdee77eaec
release / web (push) Successful in 1m22s
release / govulncheck (push) Successful in 38s
release / go (push) Successful in 1m48s
release / integration (push) Successful in 5m1s
release / android (push) Successful in 5m41s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m17s
release / Verify release artifacts (tag releases only) (push) Skipped
fix(web): text on a tint of its own hue uses the house -fg tokens (#3150)
A hue painted as text on a color-mix tint of itself sits close to the
surface under it. On Minstrel's surfaces the raw accent on its 15% tint
measures 1.97:1 at worst (dark mode, hover surface), against AA's 4.5.

- tokens.json gains colors.fg: the five FabledSword -fg formulas (accent
  45%, success 45%, warning, error and info 50%), each mixed toward
  parchment so one declaration serves both modes. Success is Minstrel's
  moss. tokens-to-css emits them in :root.
- Tailwind exposes them as text-accent-fg, text-warning-fg, text-error-fg
  and text-info-fg.
- 23 sites swapped: 14 Tailwind class strings (PlayerBar and the admin
  count pills) and 9 CSS rules (StatusPill's four tones and five accent
  chips). Worst case after: accent-fg 5.03, error-fg 4.75, warning-fg
  4.92, success-fg 4.85.
- scripts/check-tint-contrast.js finds the pair in either spelling. Its
  test scans src in the web Vitest lane and fails on any new site, with
  fixture cases showing it can fail.

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

201 lines
8.3 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';
import { FolderX, Music2 } from 'lucide-svelte';
import { createMissingFilesQuery } from '$lib/api/admin';
import { relativeTime, timeUntil } from '$lib/utils/relativeTime';
import { coverUrl } from '$lib/media/covers';
import ReacquisitionSettingsCard from '$lib/components/ReacquisitionSettingsCard.svelte';
import type { AdminMissingGroup } from '$lib/api/types';
// 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);
// "once" / "twice" reads far better than "1 times" in the sentence these
// land in, and the count is almost always small.
function attemptLabel(n: number): string {
if (n === 1) return 'once';
if (n === 2) return 'twice';
return `${n} times`;
}
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">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>
<!-- What the sweeper has done about this folder. Without it the only
way to tell "not tried yet" from "asked twice, nothing came
back" is to go and read the Requests queue. -->
{#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.gave_up_at}
<span class="text-action-destructive">Gave up</span>
after {attemptLabel(r.attempts)} — last tried
{relativeTime(r.last_attempt_at ?? r.gave_up_at)}. It'll be tried again
if the files come back and go missing later.
{:else if r.last_attempt_at}
Asked Lidarr {attemptLabel(r.attempts)}, last
{relativeTime(r.last_attempt_at)}{#if r.next_attempt_at}, next
{timeUntil(r.next_attempt_at)}{/if}.
{/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 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 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>