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
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>
201 lines
8.3 KiB
Svelte
201 lines
8.3 KiB
Svelte
<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>
|