docs: web comments say what the app does, not how it came to be (#6277)

Rewrites the web app's comments to rule 32. Task, milestone, rule and
spec ids are gone; regression stories are restated as the trap they
guard (public auth routes, the menu's stopPropagation, universal loads
under adapter-static, metric deltas with their margin). Flutter and
Tauri mentions now name the Android app or a native shell, operator
quotes and dates are dropped, and one library's counts no longer
appear outside test fixtures. Verified with a TypeScript-scanner token
comparison (template literals and regex literals re-scanned): only
comment text changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 12:42:12 -04:00
co-authored by Claude Opus 5.5
parent 6028d78844
commit 10d50dccbd
164 changed files with 602 additions and 624 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ var srcAttrRe = regexp.MustCompile(`(?i)\bsrc\s*=`)
// page carries: the theme bootstrap in app.html, the branding global the Vite
// plugin injects, and SvelteKit's start-up block. Their hashes are taken from
// the page AFTER the branding template has run, so they match the bytes the
// browser actually receives, whatever the operator's app name. Any script
// browser actually receives, whatever the app is named. Any script
// that differs, including one injected through an XSS, is refused.
//
// The rest:
+1 -1
View File
@@ -49,7 +49,7 @@ func TestContentSecurityPolicy_HashesInlineScriptsOnly(t *testing.T) {
}
// The hash must be of the page as served, after the branding template has
// substituted the operator's app name, or a renamed instance would refuse
// substituted the app's name, or a renamed instance would refuse
// its own bootstrap script.
func TestContentSecurityPolicy_TracksTemplatedContent(t *testing.T) {
a := contentSecurityPolicy([]byte(`<script>window.__MINSTREL__ = { appName: "A" };</script>`))
+2 -2
View File
@@ -1,4 +1,4 @@
// Finds a hue painted as TEXT on a tint of itself (#3150). The tint sits close
// Finds a hue painted as TEXT on a tint of itself. The tint sits close
// to the surface under it, so the raw hue on it measures 1.6–2.4:1 against
// AA's 4.5. The fix is the hue's -fg partner (tokens.json colors.fg), mixed
// toward parchment: accent-fg on an accent tint measures 5.03.
@@ -67,7 +67,7 @@ export function findCss(source) {
// Hues that fail AA as text even on a plain surface, so the raw hue is never
// text at all. Dark mode, against 4.5:
// error 2.64 on slate, 3.23 on iron → error-fg 5.30 at worst
// accent 2.21 on slate, 3.04 on the page → accent-fg 5.62 at worst (#5318)
// accent 2.21 on slate, 3.04 on the page → accent-fg 5.62 at worst
// Accent icons (the liked heart, active shuffle/repeat) follow too: as
// graphics they only need 3:1, and the raw accent misses even that on iron.
// A border or outline in the hue is a graphic and stays allowed.
+5 -5
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
// Reads ../src/lib/styles/tokens.json and emits tokens.generated.css.
// Single source of truth for FabledSword tokens shared with the Flutter
// client. Keep output deterministic — Tailwind reads tokens.json directly,
// the .css emission is for raw CSS consumers and theme switching.
// Reads ../src/lib/styles/tokens.json and emits tokens.generated.css. Single
// source of truth for the FabledSword tokens, which the Android app mirrors in
// FabledSwordTokens.kt. Keep output deterministic — Tailwind reads tokens.json
// directly, the .css emission is for raw CSS consumers and theme switching.
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
@@ -19,7 +19,7 @@ export function emit(tokens) {
for (const [name, value] of Object.entries(tokens.colors.flat)) {
lines.push(` --fs-${name}: ${value};`);
}
// The house -fg tokens (FabledSword, 2026-08-27; #3150): a hue as TEXT on a
// The house -fg tokens: a hue as TEXT on a
// tint of itself, mixed toward parchment. Parchment inverts in light mode,
// so one declaration lightens the text on dark surfaces and darkens it on
// light ones. Declared on :root, the element data-theme is set on, so the
+2 -3
View File
@@ -1,11 +1,10 @@
import { plural } from '#lib/utils/plural.js';
import type { AdminQueue, AdminSummary } from '#lib/api/types.js';
// The queues that wait on an admin (#5605), in the order the Overview lists
// The queues that wait on an admin, in the order the Overview lists
// them. The rails and the Overview read the same list, so a queue that
// needs you is counted the same in every place. Requests live under Admin;
// the library's health queues, Missing files among them, under Collection
// (M504).
// the library's health queues, Missing files among them, under Collection.
export type QueueKey = keyof AdminSummary;
export type QueueDef = {
+1 -1
View File
@@ -7,7 +7,7 @@ import type {
PlaybackErrorKind
} from '#lib/api/types.js';
// Admin › Reports (#5606): a broken track is one thing to decide about,
// Admin › Reports: a broken track is one thing to decide about,
// whether a listener flagged it or it failed to play, so the two sources
// fold into one row per track. The rail's count (the summary's `reports`)
// counts the same way, by track.
+33 -33
View File
@@ -21,7 +21,7 @@ import type {
PlaybackErrorResolution
} from './types';
// Admin summary (#5605) ---------------------------------------------------
// Admin summary ---------------------------------------------------
export async function getAdminSummary(): Promise<AdminSummary> {
return api.get<AdminSummary>('/api/admin/summary');
@@ -38,7 +38,7 @@ export function createAdminSummaryQuery() {
});
}
// Admin status (M504): who is listening, downloads, the library and disk,
// Admin status: who is listening, downloads, the library and disk,
// and the server, for the Overview below its verdict. Polled while open.
export async function getAdminStatus(): Promise<AdminStatus> {
@@ -68,7 +68,7 @@ export async function listAdminRequests(
);
}
// Approving puts the request on the wanted list (M502 #5520). An artist
// Approving puts the request on the wanted list. An artist
// request is followed on profileId, or the default profile when omitted.
export async function approveRequest(id: string, profileId?: string): Promise<MusicRequest> {
return api.post<MusicRequest>(
@@ -93,7 +93,7 @@ export function createAdminRequestsQuery(status?: RequestStatus) {
queryFn: () => listAdminRequests(status),
// Only the 'approved' tab needs polling — that's where in-flight
// ingests live. Other tabs (pending/rejected/completed) are static
// until the operator acts on them.
// until an admin acts on them.
refetchInterval: (query) => {
if (status !== 'approved') return false;
const rows = query.state.data as MusicRequest[] | undefined;
@@ -280,14 +280,14 @@ export function createCoverageQuery() {
});
}
// Fingerprint backfill (#3908) --------------------------------------------
// Fingerprint backfill --------------------------------------------
export type FingerprintCoverage = {
total: number;
fingerprinted: number;
rejected: number;
pending: number;
// False when the operator has switched fingerprinting off (#3913): pending
// False when an admin has switched fingerprinting off: pending
// then never shrinks, and nothing should read as progress.
enabled: boolean;
};
@@ -307,7 +307,7 @@ export function createFingerprintCoverageQuery() {
});
}
// Fingerprinting settings (#3913) ------------------------------------------
// Fingerprinting settings ------------------------------------------
export type FingerprintSettings = {
enabled: boolean;
@@ -317,7 +317,7 @@ export type FingerprintSettings = {
acoustic_max_bit_error_rate: number;
backfill_concurrency: number;
sweep_interval_hours: number;
// Lets the duplicate resolver act on its own (M498): merge copies no
// Lets the duplicate resolver act on its own: merge copies no
// release needs.
auto_resolve: boolean;
};
@@ -332,7 +332,7 @@ export async function updateFingerprintSettings(
return api.put<FingerprintSettings>('/api/admin/library/fingerprint-settings', s);
}
// Loudness analysis (#4995) -------------------------------------------------
// Loudness analysis -------------------------------------------------
export type LoudnessCoverage = {
total: number;
@@ -342,7 +342,7 @@ export type LoudnessCoverage = {
silent: number;
unreadable: number;
pending: number;
// False when the operator has switched analysis off: pending then never
// False when an admin has switched analysis off: pending then never
// shrinks, and nothing should read as progress.
enabled: boolean;
};
@@ -354,7 +354,7 @@ export async function getLoudnessCoverage(): Promise<LoudnessCoverage> {
export type LoudnessSettings = {
enabled: boolean;
backfill_concurrency: number;
/** Disk for the leveled copies rendered for Sonos/UPnP speakers (#5001). */
/** Disk for the leveled copies rendered for Sonos/UPnP speakers. */
leveled_cache_mb: number;
};
@@ -366,7 +366,7 @@ export async function updateLoudnessSettings(s: LoudnessSettings): Promise<Loudn
return api.put<LoudnessSettings>('/api/admin/library/loudness-settings', s);
}
// The library's folders (#6129) -----------------------------------------------
// The library's folders -----------------------------------------------
export type LibraryFolder = {
path: string;
@@ -395,7 +395,7 @@ export async function updateLibraryFolders(folders: LibraryFolderChange[]): Prom
return api.put<LibraryFolders>('/api/admin/library/folders', { folders });
}
// AcoustID recording-id lookup (M401) ---------------------------------------
// AcoustID recording-id lookup ---------------------------------------
export type AcoustIDSettings = {
enabled: boolean;
@@ -529,7 +529,7 @@ export function createCoverProvidersQuery() {
// Tag-enrichment providers -------------------------------------------------
// Parallel to the cover-art providers surface, over /api/admin/tag-sources.
// Independent settings so a new folksonomy source is added without touching
// art config (#1490).
// art config.
export type TagProviderCapability = 'track_tags';
@@ -656,7 +656,7 @@ export async function updateUserAutoApprove(id: string, autoApprove: boolean): P
return api.put<AdminUser>(`/api/admin/users/${id}/auto-approve`, { auto_approve: autoApprove });
}
// Flip an account's diagnostics/debug-reporting opt-in (M9). Admin-set;
// Flip an account's diagnostics/debug-reporting opt-in. Admin-set;
// the client obeys it (with a local per-device OFF switch).
export async function updateUserDebugMode(id: string, enabled: boolean): Promise<AdminUser> {
return api.put<AdminUser>(`/api/admin/users/${id}/debug-mode`, { enabled });
@@ -709,7 +709,7 @@ export function createSMTPConfigQuery() {
});
}
// Device diagnostics (M9) ---------------------------------------------------
// Device diagnostics ---------------------------------------------------
// Coarse event category. The finer event sub-type lives inside `payload`.
export type DiagnosticKind =
@@ -773,7 +773,7 @@ export function createAdminDiagnosticsQuery(f: DiagnosticsFilter) {
return createQuery({
queryKey: qk.adminDiagnostics(f as Record<string, string | number | undefined>),
queryFn: () => listAdminDiagnostics(f),
// The operator enables debug then watches events stream in; a short
// An admin enables debug then watches events stream in; a short
// poll keeps the timeline live without manual refresh.
refetchInterval: 10_000,
staleTime: 5_000
@@ -788,7 +788,7 @@ export function createDiagnosticDevicesQuery(userId?: string) {
});
}
// Trusted-proxy depth (#2453) ---------------------------------------------
// Trusted-proxy depth ---------------------------------------------
// detected_client_ip / forwarded_chain / remote_addr describe THIS request
// under the current setting, so the admin card can be verified rather than
@@ -823,7 +823,7 @@ export async function updatePublicUrl(publicUrl: string): Promise<NetworkSetting
});
}
// Duplicates report (#3912) -------------------------------------------------
// Duplicates report -------------------------------------------------
export const DUPLICATES_PAGE_SIZE = 25;
@@ -842,8 +842,8 @@ export function duplicatesNextOffset(last: AdminDuplicatesResponse): number | un
return last.groups.length === 0 || loaded >= last.total ? undefined : loaded;
}
// Loads as the page scrolls (rule 172), one tab at a time. Polls so a sweep
// or resolver pass finishing shows up while the operator is on the page.
// Loads as the page scrolls, one tab at a time. Polls so a sweep
// or resolver pass finishing shows up while an admin is on the page.
export function createDuplicatesQuery(view: DuplicatesView = 'review') {
return createInfiniteQuery({
queryKey: qk.adminDuplicates(view),
@@ -866,7 +866,7 @@ export function resolvedDuplicatesNextOffset(last: ResolvedDuplicatesResponse):
return last.items.length === 0 || loaded >= last.total ? undefined : loaded;
}
// What the resolver did by itself (M498), newest first.
// What the resolver did by itself, newest first.
export function createResolvedDuplicatesQuery() {
return createInfiniteQuery({
queryKey: qk.adminResolvedDuplicates(),
@@ -881,9 +881,9 @@ export async function runDuplicateSweep(): Promise<{ started: boolean }> {
return api.post<{ started: boolean }>('/api/admin/library/duplicates/sweep', {});
}
// Merges a group into the copy chosen to keep, removing the other copies' files.
// survivor_track_id is the operator's choice; the server checks it belongs to
// the group.
// Merges a group into the copy chosen to keep, removing the other copies'
// files. survivor_track_id is an admin's choice; the server checks it belongs
// to the group.
export async function mergeDuplicateGroup(
id: string,
body: { survivor_track_id: string }
@@ -894,7 +894,7 @@ export async function mergeDuplicateGroup(
);
}
// One recording in several of an album's slots (#6184): keep the copy whose
// One recording in several of an album's slots: keep the copy whose
// title it is, or none (empty keep_track_id), and fetch the album again.
export async function refetchDuplicateGroup(
id: string,
@@ -910,7 +910,7 @@ export async function dismissDuplicateGroup(id: string): Promise<void> {
await api.post(`/api/admin/library/duplicates/${encodeURIComponent(id)}/dismiss`, {});
}
// Missing files (#2527) -----------------------------------------------------
// Missing files -----------------------------------------------------
export async function listMissingFiles(
offset: number = 0,
@@ -936,7 +936,7 @@ export function createMissingFilesQuery(offset: number = 0, limit: number = 50)
});
}
// Suspect sources (#5410) --------------------------------------------------
// Suspect sources --------------------------------------------------
export const SUSPECT_SOURCES_PAGE_SIZE = 50;
@@ -952,7 +952,7 @@ export function suspectSourcesNextOffset(last: AdminSuspectResponse): number | u
return loaded >= last.total ? undefined : loaded;
}
// Loads as the page scrolls (rule 172). Changes only when files are added or
// Loads as the page scrolls. Changes only when files are added or
// removed, so it is not re-fetched on every focus.
export function createSuspectSourcesQuery() {
return createInfiniteQuery({
@@ -965,12 +965,12 @@ export function createSuspectSourcesQuery() {
}
// 'fine' lets a flagged track back into radio and the mixes; 'suspect' holds
// it back again (M498 #5439).
// it back again.
export async function setSuspectVerdict(trackId: string, verdict: SuspectVerdict): Promise<void> {
await api.put(`/api/admin/library/suspect-sources/${encodeURIComponent(trackId)}`, { verdict });
}
// Missing-file re-acquisition (#2527 / milestone #290) ----------------------
// Missing-file re-acquisition ----------------------
export type ReacquisitionSettings = {
enabled: boolean;
@@ -992,7 +992,7 @@ export async function updateReacquisitionSettings(
return api.put<ReacquisitionSettings>('/api/admin/library/reacquisition', s);
}
// Notification emails (M489 #5346): when the grouped emails go out.
// Notification emails: when the grouped emails go out.
export type NotificationEmailSettings = {
summary_hour: number;
batch_window_minutes: number;
@@ -1008,7 +1008,7 @@ export async function updateNotificationEmailSettings(
return api.put<NotificationEmailSettings>('/api/admin/notification-email', s);
}
// Indexers and download clients (M502 #5518) -------------------------------
// Indexers and download clients -------------------------------
export type AcquisitionProtocol = 'prowlarr' | 'torznab' | 'qbittorrent' | 'exchange';
+2 -2
View File
@@ -3,8 +3,8 @@ import { api } from './client';
import { qk } from './queries';
import type { CatalogueSearchResult, RequestKind } from './types';
// searchCatalogue asks MusicBrainz through Minstrel's release catalogue
// (M502 #5519). URLSearchParams handles encoding (spaces, quotes,
// searchCatalogue asks MusicBrainz through Minstrel's release catalogue.
// URLSearchParams handles encoding (spaces, quotes,
// ampersands) so callers can pass q raw.
export async function searchCatalogue(
q: string,
+5 -5
View File
@@ -9,20 +9,20 @@ export function errCode(err: unknown): string {
}
/**
* Codes whose server message carries specifics the operator needs in order to
* Codes whose server message carries specifics an admin needs in order to
* act — which directory, which uid — that fixed copy cannot say. For these the
* message follows the copy (#3918). Kept to a named list on purpose: most
* message follows the copy. Kept to a named list on purpose: most
* server messages are internal detail and must never reach a toast. Mirrored
* in Android's ErrorCopy.
*/
const DETAIL_CODES: ReadonlySet<string> = new Set([
'library_not_writable',
'file_delete_failed',
// The server names the field and its range (#3913).
// The server names the field and its range.
'invalid_setting',
// The server says what shape of address it wants (#4981).
// The server says what shape of address it wants.
'invalid_public_url',
// The server names the folder and what is wrong with it (#6129).
// The server names the folder and what is wrong with it.
'bad_folders'
]);
+3 -3
View File
@@ -43,7 +43,7 @@ export async function changePassword(currentPassword: string, newPassword: strin
}
// The signed-in user's profile, to fill the Account form with what is
// stored (#5596): a form that starts blank sends blanks, and a blank clears.
// stored: a form that starts blank sends blanks, and a blank clears.
export async function getMyProfile(): Promise<MyProfile> {
return api.get<MyProfile>('/api/me');
}
@@ -61,7 +61,7 @@ export async function regenerateAPIToken(): Promise<APITokenResponse> {
return api.post<APITokenResponse>('/api/me/api-token', {});
}
// Subsonic password (#5026) ------------------------------------------------
// Subsonic password ------------------------------------------------
// For Subsonic clients that only sign in with a username and password (the
// t/s scheme). The server generates it, so it is never the login password;
// like the API key it is shown once, when generated.
@@ -89,7 +89,7 @@ export async function putMyTimezone(timezone: string): Promise<void> {
await api.put<void>('/api/me/timezone', { timezone });
}
// Active sessions (#370) ---------------------------------------------------
// Active sessions ---------------------------------------------------
// created_ip is frozen at issue time; last_ip moves with the session. The
// pair is the signal — the same device string arriving from an address you
+3 -3
View File
@@ -3,8 +3,8 @@ import { api } from './client';
// Mirrors internal/api/me_recommendation_metrics.go: raw play sources are
// bucketed server-side into stable surface families, grouped by intent, and
// anchored by the manual-plays baseline (milestone #127).
// A difference from the baseline, with its uncertainty (#2495). Both figures
// anchored by the manual-plays baseline.
// A difference from the baseline, with its uncertainty. Both figures
// are already in percentage points — the server does the arithmetic so both
// clients read the same numbers.
//
@@ -31,7 +31,7 @@ export type SurfaceMetric = {
skip_delta?: MetricDelta;
completion_delta?: MetricDelta;
// Present when the surface's builder stamps pick-kind provenance and
// the window holds attributed plays (#1249, generalized #1270): For
// the window holds attributed plays: For
// You's taste/fresh split, Discover's candidate buckets, the tiered
// mixes' tier1-3 — plus pre-attribution plays.
breakdown?: SurfaceMetric[];
+2 -2
View File
@@ -1,6 +1,6 @@
import { api, apiFetch } from './client';
// Loudness normalization preference (M464 #4998). One per user, stored on
// Loudness normalization preference. One per user, stored on
// the server so the web player, the Android app and casts agree.
export type NormalizationMode = 'off' | 'auto' | 'track' | 'album';
export type NormalizationTarget = -18 | -16 | -14;
@@ -41,7 +41,7 @@ export function putNormalization(p: NormalizationPrefs): Promise<NormalizationPr
return api.put<NormalizationPrefs>('/api/me/normalization', p);
}
// Per-track gains (#4997): dB to the -18 LUFS ReplayGain reference and linear
// Per-track gains: dB to the -18 LUFS ReplayGain reference and linear
// peaks. A field is absent until the server has measured it.
export type ReplayGain = {
track_gain?: number;
+2 -2
View File
@@ -2,7 +2,7 @@ import { createQuery } from '@tanstack/svelte-query';
import { api } from './client';
import { qk } from './queries';
// The notifications inbox (M489). The server renders title, body and link per
// The notifications inbox. The server renders title, body and link per
// kind, so every client says the same thing and this module never has to know
// what a kind means.
@@ -123,7 +123,7 @@ export function createNotificationSettingsQuery() {
});
}
/** Short row labels, menu-style (rule 188). Unknown kinds show their key. */
/** Short row labels, menu-style. Unknown kinds show their key. */
export const NOTIFICATION_KIND_LABELS: Record<string, string> = {
request_approved: 'Request approved',
request_rejected: 'Request declined',
+4 -5
View File
@@ -19,20 +19,19 @@ export async function getPlaylist(id: string): Promise<PlaylistDetail> {
return api.get<PlaylistDetail>(`/api/playlists/${encodeURIComponent(id)}`);
}
// #415 stage 2/3: rotation-aware play order for a system playlist.
// Rotation-aware play order for a system playlist.
// Same shape as getPlaylist but tracks are server-ordered (unplayed
// this rotation first, then heard; resets when exhausted).
// Intentionally uncached — it varies per play, unlike getPlaylist.
// {variant} is the raw system_variant (#411 R2: generic by-kind).
// {variant} is the raw system_variant.
export async function systemShuffle(variant: string): Promise<PlaylistDetail> {
return api.get<PlaylistDetail>(
`/api/playlists/system/${encodeURIComponent(variant)}/shuffle`,
);
}
// #411 R2: generic by-kind refresh. Rebuilds the caller's system
// playlists and returns the named kind's fresh row. Replaces the
// former per-kind refreshForYou/refreshDiscover.
// Generic by-kind refresh. Rebuilds the caller's system playlists and
// returns the named kind's fresh row.
export type RefreshSystemResponse = {
playlist_id: string | null;
track_count: number;
+6 -6
View File
@@ -35,21 +35,21 @@ export const qk = {
myRequests: () => ['myRequests'] as const,
adminRequests: (status?: string) =>
['adminRequests', { status: status ?? 'all' }] as const,
// Wanted list (M502 #5520). Wants sit under ['adminWants'] so one prefix
// Wanted list. Wants sit under ['adminWants'] so one prefix
// invalidation refreshes every tab and its counts.
adminWants: (state: string, q: string = '', artist: string = '') =>
['adminWants', { state, q, artist }] as const,
wantArtists: (q: string = '') => ['adminWants', 'artists', { q }] as const,
wantTracks: (id: string) => ['wantTracks', id] as const,
// The Collection's Unmatched section (#5579).
// The Collection's Unmatched section.
unmatched: () => ['adminWants', 'unmatched'] as const,
adminFollows: () => ['adminFollows'] as const,
adminFollow: (artistMbid: string) => ['adminFollow', artistMbid] as const,
releaseProfiles: () => ['releaseProfiles'] as const,
// Search and grab (M502 #5521).
// Search and grab.
wantCandidates: (wantId: string) => ['wantCandidates', wantId] as const,
searchSettings: () => ['searchSettings'] as const,
// The Wanted view's Activity and Blocked tabs (#5522).
// The Wanted view's Activity and Blocked tabs.
downloads: () => ['adminWants', 'downloads'] as const,
releaseBlocks: () => ['adminWants', 'releaseBlocks'] as const,
myQuarantine: () => ['myQuarantine'] as const,
@@ -78,7 +78,7 @@ export const qk = {
adminDiagnosticDevices: (userId?: string) =>
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
smtpConfig: () => ['smtpConfig'] as const,
// Notifications inbox (M489). Both keys sit under ['notifications'] so one
// Notifications inbox. Both keys sit under ['notifications'] so one
// prefix invalidation refreshes the list and the badge together.
notifications: () => ['notifications', 'list'] as const,
notificationsUnread: () => ['notifications', 'unread'] as const,
@@ -95,7 +95,7 @@ export const qk = {
['playlists', { kind: kind ?? 'user' }] as const,
playlist: (id: string) => ['playlist', id] as const,
systemPlaylistsStatus: () => ['systemPlaylistsStatus'] as const,
// Browse indexes (#367). Keys carry no arguments — both are whole-library
// Browse indexes. Keys carry no arguments — both are whole-library
// indexes, and the per-genre / per-year album lists are fetched outside
// svelte-query because their selection comes from the URL.
genres: () => ['genres'] as const,
+3 -4
View File
@@ -3,9 +3,8 @@ import { api, apiFetch } from './client';
import { qk } from './queries';
import type { MusicRequest, RequestKind } from './types';
/** Cadence for in-flight request polling. Tuned to match the operator's
"fast enough to feel live, slow enough to not hammer the server"
threshold from #369. */
/** Cadence for in-flight request polling: fast enough to feel live, slow
enough not to hammer the server. */
const POLL_INTERVAL_MS = 12_000;
/** True if any row is mid-ingest (status === 'approved'). The status flips
@@ -18,7 +17,7 @@ export function hasInFlightRequest(rows: readonly MusicRequest[] | undefined): b
// CreateRequestParams is the shape callers pass; unused MBID fields are
// optional, and we omit them from the wire body rather than sending empty
// strings (the backend tolerates either, but this keeps test expectations
// crisp and matches the spec §5 wire shape).
// crisp and matches the server's wire shape).
export type CreateRequestParams = {
kind: RequestKind;
artist_mbid: string;
+4 -4
View File
@@ -11,7 +11,7 @@ export function createSuggestionsQuery(limit = 12) {
return createQuery({
queryKey: qk.suggestions(limit),
queryFn: () => listSuggestions(limit),
staleTime: 5 * 60_000 // 5 minutes — see M5c spec §5
staleTime: 5 * 60_000 // 5 minutes
});
}
@@ -20,9 +20,9 @@ export function createSuggestionsQuery(limit = 12) {
// out-of-library, so there is no artists row to resolve a display name from
// and the snooze list would have nothing to render. Omitting it is a 400.
//
// No `days` is sent. There is deliberately no duration UI yet — that knob is
// slice 6 (#2377) — and hardcoding a value here would pin the default to the
// client instead of the server that owns it.
// No `days` is sent: the default is an admin setting on the server (Discover
// tuning), and hardcoding a value here would pin it to the client instead of
// the server that owns it.
export async function snoozeSuggestion(mbid: string, name: string): Promise<void> {
await api.post<null>(`/api/discover/suggestions/${encodeURIComponent(mbid)}/snooze`, { name });
}
+4 -4
View File
@@ -1,6 +1,6 @@
import { api } from './client';
// Mirrors internal/api/admin_recommendation_tuning.go (#1250): the
// Mirrors internal/api/admin_recommendation_tuning.go: the
// recommendation tuning lab. Field names are the server's snake_case
// keys, used verbatim in PATCH bodies.
@@ -26,9 +26,9 @@ export type TasteTuning = {
mood_scale: number;
};
// Discover request-surface knobs (#2377). Its own scope rather than part of
// Discover request-surface knobs. Its own scope rather than part of
// taste: snooze_days belongs here, and a snooze must never be read as taste
// signal (#2374).
// signal.
export type DiscoverTuning = {
tag_overlap_weight: number;
snooze_days: number;
@@ -65,7 +65,7 @@ export function resetTuning(scope: TuningScope): Promise<TuningSnapshot> {
return api.post<TuningSnapshot>(`/api/admin/recommendation-tuning/${scope}/reset`, {});
}
// Trends (#1251): weekly per-surface outcome series with knob-turn
// Trends: weekly per-surface outcome series with knob-turn
// markers. Mirrors internal/api/admin_recommendation_trends.go.
export type TrendPoint = {
+53 -55
View File
@@ -41,7 +41,7 @@ export type TrackRef = {
disc_number?: number;
duration_sec: number;
stream_url: string;
// #5418: the file is gone from the library (missing_since is set). Sent on
// The file is gone from the library (missing_since is set). Sent on
// direct lookups such as album detail and search; lists that pick tracks
// for playback leave missing files out instead.
unavailable?: boolean;
@@ -49,15 +49,15 @@ export type TrackRef = {
export type ArtistDetail = ArtistRef & {
albums: AlbumRef[];
// Genres across this artist's tracks (#367). Server guarantees an array.
// Genres across this artist's tracks. Server guarantees an array.
genres: string[];
// MusicBrainz id, which following is keyed by (M502 #5520); "" when none.
// MusicBrainz id, which following is keyed by; "" when none.
mbid: string;
};
export type AlbumDetail = AlbumRef & {
tracks: TrackRef[];
// Genres across this album's tracks (#367). Server guarantees an array.
// Genres across this album's tracks. Server guarantees an array.
genres: string[];
};
@@ -93,8 +93,8 @@ export type PlaylistTrack = {
duration_sec: number;
stream_url: string | null;
added_at: string;
// True when the track still exists but its file is missing from disk
// (#2527). Distinct from track_id === null: that one is gone for good,
// True when the track still exists but its file is missing from disk.
// Distinct from track_id === null: that one is gone for good,
// this one keeps its play history and likes and may come back — the
// scanner un-marks it, or adopts it if it returns renamed. Both are
// unplayable; only this one is worth telling the user about.
@@ -125,9 +125,7 @@ export type EventRequest =
// `source` is the system-playlist variant the play came from
// (for_you, discover, deep_cuts, …); empty/undefined for library
// / user-playlist / radio / Subsonic. Server stores it on
// play_events.source and uses it to advance the rotation. Drift
// #555: the type was missing `source` so callers had no typed slot
// for it and a "clean up extra properties" refactor could drop it.
// play_events.source and uses it to advance the rotation.
| { type: 'play_started'; track_id: string; client_id?: string; source?: string }
| { type: 'play_ended'; play_event_id: string; duration_played_ms: number }
| { type: 'play_skipped'; play_event_id: string; position_ms: number };
@@ -148,7 +146,7 @@ export type RequestStatus =
export type RequestKind = 'artist' | 'album' | 'track';
// One row of the request search (GET /api/catalogue/search, M502 #5519).
// One row of the request search (GET /api/catalogue/search).
// An album is a MusicBrainz release group; image_url is Minstrel's own art
// endpoint, which answers 404 when no source has an image.
export type CatalogueSearchResult = {
@@ -189,7 +187,7 @@ export type MusicRequest = {
requested_at: string;
updated_at: string;
// Live ingest counters. Server fills these from the matched entity's
// children. Useful for in-flight requests so the operator can see
// children. Useful for in-flight requests so an admin can see
// "5 albums · 47 tracks" while the rest is still being fetched.
imported_album_count: number;
imported_track_count: number;
@@ -276,7 +274,7 @@ export type AdminPlaybackError = {
};
// deleted_album is history: an album removed whole, before Minstrel filed its
// own music (M502).
// own music.
export type QuarantineAction = 'resolved' | 'deleted_file' | 'deleted_album';
export type QuarantineActionRow = {
@@ -311,9 +309,9 @@ export type ArtistSuggestion = {
score: number;
attribution: SeedContribution[]; // up to 3 entries, ordered by contribution DESC
image_url?: string; // absent → card placeholder
// Tags this candidate shares with your taste profile, strongest first (max 3,
// #2377). Absent for most candidates — tag coverage for artists you don't own
// is partial by nature (#2376) — so the card falls back to seed attribution.
// Tags this candidate shares with your taste profile, strongest first (max
// 3). Absent for most candidates — tag coverage for artists you don't own
// is partial by nature — so the card falls back to seed attribution.
matched_tags?: string[];
};
@@ -339,7 +337,7 @@ export type HomePayload = {
you_might_like_artists: ArtistRef[];
};
// Missing files (#2527) -----------------------------------------------------
// Missing files -----------------------------------------------------
// One track whose file the scan could not find. The text fields come from the
// tracks row rather than the filesystem — the recording is still a known thing
@@ -359,13 +357,13 @@ export type AdminMissingTrack = {
last_played_at: string | null;
};
// What the wanted list says about this group's album (#290, M502 #5520):
// What the wanted list says about this group's album:
// when it was wanted back, and when the library held it again. null while the
// album is inside the grace window, or when MusicBrainz can't name it.
export type AdminReacquisitionState = {
wanted_at: string | null;
held_at: string | null;
// The want itself, as the wanted list sends it (M504), so the Missing page
// The want itself, as the wanted list sends it, so the Missing page
// shows its search and download and acts on it.
want: Want | null;
};
@@ -387,7 +385,7 @@ export type AdminMissingResponse = {
groups: AdminMissingGroup[];
};
// Suspect sources (#5410) --------------------------------------------------
// Suspect sources --------------------------------------------------
// A present track whose filename carries a video-rip marker. markers are the
// reasons, read from the file's basename: "music video", "[Audio]", "MV"...
@@ -403,12 +401,12 @@ export type AdminSuspectTrack = {
disc_number: number | null;
track_number: number | null;
markers: string[];
// 'suspect': held back from radio and the mixes. 'fine': the operator let it
// back. null: the resolver hasn't looked yet (M498 #5439).
// 'suspect': held back from radio and the mixes. 'fine': an admin let it
// back. null: the resolver hasn't looked yet.
verdict: SuspectVerdict | null;
verdict_at: string | null;
// The album is on the wanted list for a clean copy, and when one was last
// searched for (#5524).
// searched for.
replacing: boolean;
searched_at: string | null;
};
@@ -428,10 +426,10 @@ export type AdminSuspectResponse = {
groups: AdminSuspectGroup[];
};
// Duplicates report (#3912) -------------------------------------------------
// Duplicates report -------------------------------------------------
// One copy in a proposed duplicate group. like_count and play_count cover every
// user: they are what the operator weighs when choosing which copy to keep.
// user: they are what an admin weighs when choosing which copy to keep.
export type AdminDuplicateMember = {
track_id: string;
title: string;
@@ -448,11 +446,11 @@ export type AdminDuplicateMember = {
play_count: number;
disc_number: number | null;
track_number: number | null;
// To play each copy from the review (#6168).
// To play each copy from the review.
stream_url: string;
};
// The resolver's verdict on a group (M498). null until its first pass.
// The resolver's verdict on a group. null until its first pass.
export type DuplicateClass = 'same_release' | 'cross_release' | 'distinct' | 'mismatch' | 'review';
// exact: identical encoded audio. acoustic: the same recording, differently
@@ -465,7 +463,7 @@ export type AdminDuplicateGroup = {
survivor_track_id: string;
survivor_reason: string;
class: DuplicateClass | null;
// Why the resolver left the group for the operator, when it had something to say.
// Why the resolver left the group for an admin, when it had something to say.
resolve_note: string | null;
members: AdminDuplicateMember[];
};
@@ -480,14 +478,14 @@ export type AdminDuplicateSweep = {
error_message: string | null;
};
// What a merge did (#3911). removed_paths are the files that were deleted from
// What a merge did. removed_paths are the files that were deleted from
// disk.
export type MergeDuplicateResult = {
survivor_track_id: string;
removed_paths: string[];
};
// What settling one recording in several slots did (#6184). kept_track_id is
// What settling one recording in several slots did. kept_track_id is
// null when every copy went; blocked names the releases set aside, empty when
// the album came from before Minstrel downloaded anything.
export type RefetchDuplicateResult = {
@@ -517,7 +515,7 @@ export type AdminDuplicatesResponse = {
// The report's group tabs. 'resolved' is the third tab, served separately.
export type DuplicatesView = 'review' | 'cross_release';
// One merge the resolver made on its own (M498), from the audit log.
// One merge the resolver made on its own, from the audit log.
// What the resolver did by itself: merged a group, dismissed two versions of a
// song as not duplicates, or removed copies that filled another song's track
// slot (their tracks stay, marked missing, and the album is fetched again).
@@ -542,15 +540,15 @@ export type ResolvedDuplicatesResponse = {
items: ResolvedDuplicateActivity[];
};
// Wanted list (M502 #5520) ------------------------------------------------
// Wanted list ------------------------------------------------
// library: in the library when the list began (#5567).
// library: in the library when the list began.
export type WantReason = 'request' | 'follow' | 'missing' | 'library';
// all: one artist's page, every album of theirs on the list (#5572).
// upgrade: held albums wanted for a better copy (#5524).
// all: one artist's page, every album of theirs on the list.
// upgrade: held albums wanted for a better copy.
export type WantState = 'wanted' | 'missing' | 'upgrade' | 'held' | 'all';
// Why a held album is wanted again: its copy is below the quality settled
// for, or one of its tracks is a video rip (#5524).
// for, or one of its tracks is a video rip.
export type WantUpgrade = 'quality' | 'rip';
// One release group on the wanted list. held_at is set once the library
@@ -572,10 +570,10 @@ export type Want = {
held_at: string | null;
image_url: string;
// A held album wanted for a better copy, and what the copy it has is
// ("MP3, about 192 kbps") (#5524).
// ("MP3, about 192 kbps").
upgrade?: WantUpgrade;
held_quality?: string;
// The search schedule's last look, and the download running (#5521).
// The search schedule's last look, and the download running.
search?: WantSearch | null;
grab?: WantGrab | null;
};
@@ -591,7 +589,7 @@ export type WantSearch = {
error: string;
};
// What an import made of a finished download (M502 #5522).
// What an import made of a finished download.
export type ImportState =
| ''
| 'downloading'
@@ -615,13 +613,13 @@ export type WantGrab = {
progress: number;
import_state: ImportState;
import_reason: string;
// Why an ended download ended (#5523).
// Why an ended download ended.
end_detail: string;
imported_at: string | null;
imported_files: number;
};
// Search and grab (M502 #5521) ----------------------------------------------
// Search and grab ----------------------------------------------
export type CandidateVerdict = 'grab' | 'review' | 'refused' | 'rejected' | 'blocked';
@@ -694,26 +692,26 @@ export type SearchSettings = {
last_pass_at: string | null;
last_pass: SearchPass;
// The library folder new music is filed into ("" = the first of
// scan_paths), and the library's folders (#5522).
// scan_paths), and the library's folders.
import_root: string;
scan_paths: string[];
// The download lifecycle (#5523): an imported download seeds to this
// The download lifecycle: an imported download seeds to this
// ratio or for this many days (0: removed once imported; no time limit),
// and one nobody shares is given up after this many hours (0: never).
seed_ratio: number;
seed_days: number;
stall_hours: number;
// The tier a held album is upgraded to; "" is no quality upgrades (#5524).
// The tier a held album is upgraded to; "" is no quality upgrades.
upgrade_until: string;
};
export type WantCounts = {
// Artists in the Collection's catalogue (#5572).
// Artists in the Collection's catalogue.
artists: number;
wanted: number;
missing: number;
held: number;
// Held albums wanted for a better copy (#5524).
// Held albums wanted for a better copy.
upgrades: number;
following: number;
// Downloads running, those of them waiting on a person, releases set aside.
@@ -721,11 +719,11 @@ export type WantCounts = {
attention: number;
blocked: number;
// Library albums no MusicBrainz album matched, waiting to be matched by
// hand or left off (#5579).
// hand or left off.
unmatched: number;
};
// What an import's check counted (#5522).
// What an import's check counted.
export type ImportEvidence = {
files: number;
tracks: number;
@@ -763,7 +761,7 @@ export type Download = {
files: string[];
skipped: number;
rehomed_from?: string;
// Why an ended download ended; for an imported one, its seeding (#5523).
// Why an ended download ended; for an imported one, its seeding.
end_detail: string;
seeding: boolean;
ratio: number;
@@ -772,7 +770,7 @@ export type Download = {
condition: DownloadCondition | null;
};
// An open condition on a download, from the lifecycle's ledger (#5523).
// An open condition on a download, from the lifecycle's ledger.
export type DownloadCondition = {
trigger: 'no_peers' | 'no_metadata' | 'client_error' | 'client_unreachable' | 'gone' | 'not_removed';
detail: string;
@@ -805,10 +803,10 @@ export type ReleaseBlock = {
export type ReleaseBlocksPage = { items: ReleaseBlock[]; limit: number; offset: number };
// The one-time listing of the library's albums as held (#5567): when it
// The one-time listing of the library's albums as held: when it
// finished (null while it runs), and how many albums nothing names to
// MusicBrainz, so they could not be listed.
// One album in the Collection's Unmatched section (#5579): why no
// One album in the Collection's Unmatched section: why no
// MusicBrainz album matched it, or that a person left it off.
export type UnmatchedAlbum = {
album_id: string;
@@ -829,7 +827,7 @@ export type LibraryListing = { listed_at: string | null; unlisted: number };
// artist heads the list when it is narrowed to one artist's page.
export type WantsPage = Page<Want> & { counts: WantCounts; library: LibraryListing; artist?: WantArtist };
// One artist in the Collection's catalogue (#5572): how many of their albums
// One artist in the Collection's catalogue: how many of their albums
// on the list are held, of how many.
export type WantArtist = {
artist_mbid: string;
@@ -847,7 +845,7 @@ export type WantArtist = {
export type WantArtistsPage = Page<WantArtist> & { counts: WantCounts; library: LibraryListing };
// An album's tracks in the Wanted view (#5572): the library's own for an
// An album's tracks in the Wanted view: the library's own for an
// album it holds (a lost file marked missing), else MusicBrainz's standard
// edition, named by edition; none when MusicBrainz lists no edition.
export type WantTrack = { disc: number; number: number; title: string; length_ms: number; missing: boolean };
@@ -888,7 +886,7 @@ export type ReleaseProfilesResponse = {
secondary_types: string[];
};
// What GET /api/admin/summary returns (#5605): each queue that waits on an
// What GET /api/admin/summary returns: each queue that waits on an
// admin, with how many and since when the oldest has waited (null when empty).
export type AdminQueue = { count: number; since: string | null };
export type AdminSummary = {
@@ -900,7 +898,7 @@ export type AdminSummary = {
missing: AdminQueue;
};
// What GET /api/admin/status returns (M504): the Overview's status sections.
// What GET /api/admin/status returns: the Overview's status sections.
export type AdminListening = {
username: string;
track_id: string;
+9 -9
View File
@@ -21,12 +21,12 @@ import type {
WantsPage
} from './types';
// The wanted list (M502 #5520): release groups Minstrel is to get, the
// The wanted list: release groups Minstrel is to get, the
// artists it follows for new releases, and the release-type profiles that
// say which kinds of release a follow wants.
// q narrows the list to an artist or album whose name holds it; artist to
// one artist's page, which the response then heads (#5572).
// one artist's page, which the response then heads.
export async function listWants(
state: WantState,
offset: number = 0,
@@ -44,7 +44,7 @@ export function wantsNextOffset(last: WantsPage): number | undefined {
return last.items.length === 0 || loaded >= last.total ? undefined : loaded;
}
// Loads as the page scrolls (rule 172), one tab at a time.
// Loads as the page scrolls, one tab at a time.
export function createWantsQuery(state: WantState, q: string = '', artist: string = '') {
return createInfiniteQuery({
queryKey: qk.adminWants(state, q, artist),
@@ -57,7 +57,7 @@ export function createWantsQuery(state: WantState, q: string = '', artist: strin
});
}
// The Collection's catalogue (#5572): every artist on the list, A–Z, with
// The Collection's catalogue: every artist on the list, A–Z, with
// how many of their albums are held. Its first page carries every section's
// count.
export async function listWantArtists(offset: number = 0, q: string = ''): Promise<WantArtistsPage> {
@@ -80,7 +80,7 @@ export function createWantArtistsQuery(q: string = '') {
});
}
// An album's tracks (#5572): the library's own for an album it holds,
// An album's tracks: the library's own for an album it holds,
// otherwise MusicBrainz's standard edition.
export async function getWantTracks(id: string): Promise<WantTracks> {
return api.get<WantTracks>(`/api/admin/wanted/${id}/tracks`);
@@ -94,7 +94,7 @@ export function createWantTracksQuery(id: string) {
});
}
// The Collection's Unmatched section (#5579): library albums no MusicBrainz
// The Collection's Unmatched section: library albums no MusicBrainz
// album matched, and after them the ones a person left off.
export async function listUnmatched(): Promise<UnmatchedAlbum[]> {
const out = await api.get<{ items: UnmatchedAlbum[] }>('/api/admin/wanted/unmatched');
@@ -191,7 +191,7 @@ export async function deleteReleaseProfile(id: string): Promise<void> {
await api.del(`/api/admin/release-profiles/${id}`);
}
// Search and grab (M502 #5521): each want's last search, a person's Search,
// Search and grab: each want's last search, a person's Search,
// Grab and "never this one", and the schedule's settings.
export async function listCandidates(wantId: string): Promise<Candidate[]> {
@@ -252,7 +252,7 @@ export async function putSearchSettings(body: {
return api.put<SearchSettings>('/api/admin/acquisition/settings', body);
}
// Downloads and their imports (M502 #5522) ----------------------------------
// Downloads and their imports ----------------------------------
// A page holding fewer than it asked for is the last.
function nextOffset(last: { items: unknown[]; limit: number; offset: number }): number | undefined {
@@ -288,7 +288,7 @@ export async function refuseDownload(id: string): Promise<void> {
}
// A running download given up: the release is set aside for good, the
// download removed and the album searched for again (#5523).
// download removed and the album searched for again.
export async function abandonDownload(id: string): Promise<void> {
await api.post(`/api/admin/downloads/${id}/abandon`, {});
}
+7 -9
View File
@@ -6,10 +6,10 @@ describe('isPublicRoute', () => {
expect(isPublicRoute('/login')).toBe(true);
});
// Regression guard for #376: /register was previously bounced to /login
// by the layout guard, breaking bootstrap-admin self-registration on
// fresh deployments. Do NOT remove /register from the public set unless
// you have a deliberate replacement (e.g. invite-only landing page).
// /register must stay public: bounced to /login by the layout guard, the
// bootstrap admin of a fresh deployment could not register. Do NOT remove
// /register from the public set unless you have a deliberate replacement
// (e.g. invite-only landing page).
test('/register is public — bootstrap admin must reach the form', () => {
expect(isPublicRoute('/register')).toBe(true);
});
@@ -31,11 +31,9 @@ describe('isPublicRoute', () => {
expect(isPublicRoute('/loginx')).toBe(false);
});
// Regression guard for drift #558: /forgot-password and the
// /reset-password/<token> deep links must be reachable without a
// session — the reset flow is entered by clicking an email link
// while signed out, so the auth gate redirecting to /login broke
// account recovery.
// /forgot-password and the /reset-password/<token> deep links must be
// reachable without a session — the reset flow is entered by clicking
// an email link while signed out.
test('/forgot-password is public', () => {
expect(isPublicRoute('/forgot-password')).toBe(true);
});
+4 -7
View File
@@ -1,12 +1,9 @@
// Routes that the +layout.svelte auth guard must NOT redirect away from
// when the visitor is unauthenticated. Bootstrap-admin self-registration
// (#376) requires /register to be reachable without a session — without
// /register here, the login page's "Register" link bounces back to /login.
//
// Drift #558: /forgot-password and /reset-password/<token> were missing
// from this set. The email-link reset flow is by definition entered
// without a session — a signed-out user clicking their reset link was
// being bounced to /login, breaking account recovery.
// requires /register to be reachable without a session — without /register
// here, the login page's "Register" link bounces back to /login. The
// email-link reset flow (/forgot-password, /reset-password/<token>) is by
// definition entered without a session.
const PUBLIC_ROUTES = new Set(['/login', '/register', '/forgot-password']);
const PUBLIC_PREFIXES = ['/reset-password/'];
+1 -1
View File
@@ -9,7 +9,7 @@ import { user, setUser } from './user.svelte';
export { user };
// Weekly client-driven cadence for sending the browser's current IANA
// timezone to PUT /api/me/timezone (#392 Half B). Tracked in
// timezone to PUT /api/me/timezone. Tracked in
// localStorage so the cadence survives tab restarts; bumped after each
// successful PUT. Failures are swallowed — server keeps its previous
// value (or 'UTC' default).
+4 -3
View File
@@ -13,9 +13,10 @@
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
// AcoustID recording-id lookup (M401): fills the MusicBrainz ids that
// untagged tracks lack, so the similarity arms can see them. Off until the
// operator adds their own key (rule 26), and it never gates anything else:
// AcoustID recording-id lookup: fills the MusicBrainz ids that untagged
// tracks lack, so the similarity arms can see them. Off until an admin adds
// their own key — a third-party account is never on by default — and it
// never gates anything else:
// when AcoustID is unreachable the card says so and nothing else notices.
let overview = $state<AcoustIDOverview | null>(null);
@@ -11,10 +11,10 @@
import AcquisitionServiceRow from './AcquisitionServiceRow.svelte';
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
// Where Minstrel looks for music and what downloads it (M502 #5518),
// Fabled Librarian's model: two lists of connections the operator adds and
// configures. Nothing is seeded or copied. A FabledExchange is
// one entry under download clients, with a switch for each of its jobs.
// Where Minstrel looks for music and what downloads it: two lists of
// connections an admin adds and configures. Nothing is seeded or copied. A
// FabledExchange is one entry under download clients, with a switch for each
// of its jobs.
let services = $state<AcquisitionService[] | null>(null);
let loadError = $state(false);
@@ -125,7 +125,7 @@
{:else if services === null}
<p class="text-sm text-text-secondary">Loading indexers and download clients…</p>
{:else}
<!-- The verdict first (#189): can music be found and downloaded at all. -->
<!-- The verdict first: can music be found and downloaded at all. -->
<p class="text-sm" data-testid="acquisition-health">
{#if !canFetch}
<span class="text-error-fg">No download client is switched on, so nothing can be downloaded.</span>
@@ -13,7 +13,7 @@
import { errCode, errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// One indexer or download client (M502 #5518). The form is a copy of the
// One indexer or download client. The form is a copy of the
// saved row, so Save is only offered when something changed. Secrets are
// write-only: the server says whether one is set and never what it is.
+1 -1
View File
@@ -120,7 +120,7 @@
}
</script>
<!-- Settings › Account's last group (#5595), styled as SettingsGroup is. -->
<!-- Settings › Account's last group, styled as SettingsGroup is. -->
<section class="space-y-3 rounded-lg border border-border bg-surface px-4 pt-3 pb-4">
<header>
<h2 class="text-base font-medium text-text-primary">Signed-in devices</h2>
@@ -1,6 +1,6 @@
<script lang="ts">
// The header of every page that is one collection of tracks, album and
// playlist (milestone 494): cover, title, a line or two of facts, then
// playlist: cover, title, a line or two of facts, then
// Play and Shuffle (PlayActions).
import type { Snippet } from 'svelte';
import PlayActions from './PlayActions.svelte';
+1 -2
View File
@@ -6,8 +6,7 @@
// CoverTile/ServerImage: one place that owns the loading placeholder and
// the error fallback so no card sits blank while art loads or 404s (the
// "You might like" row surfaces unplayed items whose art is often not yet
// backfilled). Replaces the three hand-rolled <img> tags that previously
// disagreed on fallback handling.
// backfilled).
let {
src,
alt = '',
@@ -2,7 +2,7 @@
export type DiscoverCardKind = 'artist' | 'album' | 'track';
// 'snoozed' is a transient state the card flips to in place after the user
// parks it, so the disappearance is legible and undoable rather than a card
// silently vanishing from under the cursor (rule #24). The row is gone on
// silently vanishing from under the cursor. The row is gone on
// the next refetch; the persistent way back is the snoozed list.
export type DiscoverCardState = 'requestable' | 'kept' | 'requested' | 'snoozed';
</script>
@@ -37,7 +37,7 @@
} = $props();
// Art comes from Minstrel's catalogue endpoint, which answers 404 when no
// source has an image (M502 #5519): show the icon then, not a broken image.
// source has an image: show the icon then, not a broken image.
// Reset when the card is reused for another result.
let imageFailed = $state(false);
$effect(() => {
@@ -118,7 +118,7 @@
Icon-only to keep Request unambiguously the primary action, with
the intent carried by the accessible name. "Not right now" is the
whole point of the wording: this parks a suggestion, it does not
record an opinion about the artist (rule #101).
record an opinion about the artist.
-->
<button
type="button"
@@ -34,7 +34,8 @@ describe('DiscoverResultCard', () => {
},
});
expect(screen.getByRole('button', { name: /in library/i })).toBeDisabled();
// Kept pill is exposed as a status region so SR users hear it when it appears.
// Kept pill is exposed as a status region so SR users hear it when it
// appears.
const status = screen.getByRole('status');
expect(status).toBeInTheDocument();
expect(status.textContent).toMatch(/kept/i);
@@ -62,14 +63,15 @@ describe('DiscoverResultCard', () => {
},
});
// Button is disabled — the test still verifies onRequest isn't called even
// if a click slips through (jsdom does not enforce :disabled at fireEvent level).
// if a click slips through (jsdom does not enforce :disabled at fireEvent
// level).
const btn = screen.getByRole('button');
await fireEvent.click(btn);
expect(onRequest).not.toHaveBeenCalled();
});
// NOTE: previous versions of this file asserted .badge-row { min-height: 22px }
// and .actions { margin-top: auto } via getComputedStyle, which only worked
// NOTE: previous versions of this file asserted .badge-row { min-height: 22px
// } and .actions { margin-top: auto } via getComputedStyle, which only worked
// because the component shipped inline style="" attributes — a layering
// violation. Stylesheet introspection (document.styleSheets) was tried as a
// replacement, but vitest's @testing-library/svelte renderer does not inject
@@ -101,7 +103,8 @@ describe('DiscoverResultCard', () => {
},
});
expect(container.querySelector('img')).not.toBeInTheDocument();
// Lucide renders an inline <svg>; verify its presence as a proxy for "fallback rendered"
// Lucide renders an inline <svg>; verify its presence as a proxy for
// "fallback rendered"
expect(container.querySelector('svg')).toBeInTheDocument();
});
@@ -126,7 +129,7 @@ describe('DiscoverResultCard', () => {
expect(screen.queryByTestId('attribution')).not.toBeInTheDocument();
});
// --- snooze affordance (#2375) ---
// --- snooze affordance ---
test('snooze button appears only when onSnooze is supplied', () => {
// The catalogue search results reuse this card and have nothing to park, so
@@ -143,7 +146,8 @@ describe('DiscoverResultCard', () => {
props: { kind: 'artist', title: 'Outsider', state: 'requestable', onSnooze }
});
const btn = screen.getByRole('button', { name: /not right now — hide outsider for a while/i });
// Rule #101: the accessible name must carry no verdict on the music.
// A snooze is not a dislike: the accessible name must carry no verdict on
// the music.
expect(btn.getAttribute('aria-label')).not.toMatch(/dislike|not for me|never|hate/i);
await fireEvent.click(btn);
expect(onSnooze).toHaveBeenCalledOnce();
@@ -10,8 +10,8 @@
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
// How Minstrel fingerprints tracks and when it looks for duplicates (#3913).
// On Admin › Library (#5607); Duplicates says whether it is on and links here.
// How Minstrel fingerprints tracks and when it looks for duplicates.
// On Admin › Library; Duplicates says whether it is on and links here.
let { libraryTotal = 0, onSaved }: { libraryTotal?: number; onSaved?: () => void } =
$props();
@@ -33,14 +33,14 @@
form.acoustic_max_bit_error_rate = value === '' ? NaN : Math.round(100 - Number(value)) / 100;
}
// The one setting with a cost the operator can't see from here: fingerprints
// The one setting with a cost an admin can't see from here: fingerprints
// taken at two lengths can't be compared, so a new length redoes the library.
const lengthChanged = $derived(
!!saved && !!form && form.chromaprint_length_sec !== saved.chromaprint_length_sec
);
// Mirrors the server's ranges, so a bad value is named in the card's own terms
// (a percentage, not a bit-error rate) before anything is sent.
// Mirrors the server's ranges, so a bad value is named in the card's own
// terms (a percentage, not a bit-error rate) before anything is sent.
const between = (v: number, lo: number, hi: number) => Number.isInteger(v) && v >= lo && v <= hi;
const problems = $derived.by(() => {
if (!form) return [] as string[];
@@ -69,7 +69,7 @@ describe('FingerprintSettingsCard', () => {
});
// A new length re-fingerprints the whole library. Nothing else on the page
// would say so before the operator commits to it.
// would say so before an admin commits to it.
test('changing the length warns that every track is re-fingerprinted', async () => {
await renderCard({}, { libraryTotal: 18026 });
expect(screen.queryByTestId('length-warning')).toBeNull();
@@ -117,7 +117,7 @@ describe('FingerprintSettingsCard', () => {
);
});
// M498, rule 25: the resolver acting on its own is the operator's to turn off.
// The resolver acting on its own is an admin's to turn off.
test('auto-resolve can be turned off and is saved with the rest', async () => {
vi.mocked(updateFingerprintSettings).mockResolvedValue({ ...base, auto_resolve: false });
await renderCard();
+1 -1
View File
@@ -12,7 +12,7 @@
import { pushToast } from '#lib/stores/toast.svelte.js';
import Modal from './Modal.svelte';
// Follow an artist for the wanted list (M502 #5520): their new releases are
// Follow an artist for the wanted list: their new releases are
// wanted, filtered by a release-type profile. Admin-only; the artist page
// shows it when the artist carries a MusicBrainz id.
@@ -10,7 +10,7 @@
import { plural } from '#lib/utils/plural.js';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
// The library's folders (#6129), set here rather than only by
// The library's folders, set here rather than only by
// MINSTREL_LIBRARY_SCAN_PATHS. Editing a folder's path says its files moved
// there: the server rewrites its tracks' paths, so plays, likes and
// playlists stay with them. A folder removed is no longer read.
@@ -1,7 +1,7 @@
<script lang="ts">
// The bottom of every paged list (#5416, preference 172): the next page
// loads on its own as the reader nears the end, with no "Load more"
// button. What the button used to give for free is kept here:
// The bottom of every paged list: the next page loads on its own as the
// reader nears the end, with no "Load more" button. What a button gives
// for free is kept here:
//
// - In flight: a "Loading more…" line, announced through aria-live, so the
// list doesn't look finished while it isn't.
@@ -12,9 +12,9 @@
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
// Loudness analysis (#4995): the background measurement that loudness
// Loudness analysis: the background measurement that loudness
// normalization levels playback from, its progress, and its knobs. The
// cache size bounds the leveled copies rendered for speakers (#5001).
// cache size bounds the leveled copies rendered for speakers.
let saved = $state<LoudnessSettings | null>(null);
let form = $state<LoudnessSettings | null>(null);
@@ -6,9 +6,9 @@
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
// The Android app this server bundles, at the top of Settings › Account
// (#5595). Polls /api/client/version once on mount. Renders nothing on
// 404 (no bundled APK: dev and pre-CI images) or 401.
// The Android app this server bundles, at the top of Settings › Account.
// Polls /api/client/version once on mount. Renders nothing on
// 404 (no bundled APK: a local build) or 401.
//
// The /api/client/* endpoints are auth-gated to prevent anonymous
// bandwidth abuse on the APK stream, so this only shows to a signed-in
@@ -27,7 +27,7 @@
.map((s) => s.trim())
.filter(Boolean)
);
// The operator can count their proxies from what actually arrived rather
// An admin can count their proxies from what actually arrived rather
// than guessing — one XFF entry per proxy in front of us.
const suggested = $derived(chain.length);
@@ -69,7 +69,7 @@ describe('NetworkSettingsCard', () => {
expect(await screen.findByText('203.0.113.9')).toBeTruthy();
});
// Counting proxies is the operator's job and the hint is how they do it
// Counting proxies is an admin's job and the hint is how they do it
// without guessing.
test('hints the likely depth when it disagrees with the arriving chain', async () => {
getNetworkSettings.mockResolvedValue(
@@ -14,7 +14,7 @@
import SettingRow from '#lib/components/settings/SettingRow.svelte';
import Segmented from '#lib/components/settings/Segmented.svelte';
// Volume leveling (M464 #4998). The choice is the user's, stored on the
// Volume leveling. The choice is the user's, stored on the
// server, so it follows them to the Android app and to casts.
onMount(() => {
void loadNormalization();
@@ -45,7 +45,7 @@
const targets = NORMALIZATION_TARGETS.map((t) => ({ value: t, label: `${t} LUFS` }));
</script>
<!-- Rows of the Playback group (#5595); the caller wraps them in it. -->
<!-- Rows of the Playback group; the caller wraps them in it. -->
<SettingRow label="Volume leveling" hint={modeHints[normalization.value.mode]}>
<Segmented
name="normalization-mode"
@@ -10,7 +10,7 @@
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
// When notification emails go out (M489 #5346). Nothing is emailed per
// When notification emails go out. Nothing is emailed per
// event: new music is a daily summary, everything else a batch.
const button =
@@ -55,8 +55,8 @@
form = { ...saved };
pushToast('Notification email schedule saved.');
} catch (e) {
// The server names the field it refused (#3937: errMessage, not
// e.message, because the client throws a plain object).
// The server names the field it refused (errMessage, not e.message:
// the client throws a plain object).
pushToast(errMessage(e), 'error');
} finally {
saving = false;
@@ -13,7 +13,7 @@
import { user } from '#lib/auth/store.svelte.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// Per-user notification settings (M489): one row per kind, a toggle per
// Per-user notification settings: one row per kind, a toggle per
// channel. Toggles are optimistic; a failed save puts the switch back.
const client = useQueryClient();
@@ -30,7 +30,7 @@
// Bumped by every local change, so a save resolving after a newer toggle
// does not overwrite it, and a failed save only reverts when nothing newer
// happened (snippet #5106's generation counter).
// happened.
let generation = 0;
function commit(next: NotificationSettings) {
@@ -96,8 +96,8 @@
{/each}
{/snippet}
<!-- Settings › Notifications (#5595): one table, its column labels once
(#189), the admins' library-health kinds under their own heading in
<!-- Settings › Notifications: one table, its column labels once
, the admins' library-health kinds under their own heading in
the same columns. -->
<section id="notifications" class="rounded-lg border border-border bg-surface">
<header class="px-4 pt-3 pb-2">
+4 -5
View File
@@ -1,9 +1,8 @@
<script lang="ts">
// The centred column every track-list page sits in: album, playlist,
// Liked, History and Search → Tracks (milestone 494). The playlist page
// had it alone from May 2026; the operator picked it as the page shape
// on 2026-10-08. Shelf and grid pages (Home, Library, Discover, Artist,
// Search) stay full width, since a narrow column would cost them cards.
// The centred column every track-list page sits in: album, playlist, Liked,
// History and Search → Tracks. Shelf and grid pages (Home, Library, Discover,
// Artist, Search) stay full width, since a narrow column would cost them
// cards.
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
+3 -4
View File
@@ -1,9 +1,8 @@
<script lang="ts">
// Play and Shuffle, the same on every page that has them: album,
// playlist and artist (milestone 494). Play is filled and Shuffle
// outlined. The fill is the accent: the operator's call on 2026-10-08,
// made with the house rule against accent on action buttons stated to
// them.
// playlist and artist. Play is filled and Shuffle outlined. The fill is
// the accent, a deliberate exception to the house rule against accent on
// action buttons.
import type { Snippet } from 'svelte';
import { Play, Shuffle } from 'lucide-svelte';
+1 -1
View File
@@ -52,7 +52,7 @@
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
}
// #9 — dominant-color accent. Sample the current track's cover and
// Dominant-color accent. Sample the current track's cover and
// pipe it into a CSS custom property; a 2px strip above the bar
// takes on the colour, so the page subtly tracks what's playing.
// Cache-backed in dominantColorFromUrl so revisits are free.
+3 -3
View File
@@ -69,7 +69,7 @@ function track(): TrackRef {
// PlayerBar renders both a compact (mobile, md:hidden) and desktop
// (hidden md:flex) block. jsdom doesn't apply CSS media queries, so
// both DOM trees are present in tests. Scope every query to the
// compact block — that's the user-facing change in #358; desktop
// compact block — that's where the phone layout lives; desktop
// behavior is covered structurally by the same store + handlers.
function compact() {
return within(screen.getByTestId('player-bar-compact'));
@@ -209,8 +209,8 @@ describe('PlayerBar', () => {
});
describe('"Up next" line (desktop only)', () => {
// The "Next · …" copy was dropped from compact per #358; only the
// desktop block surfaces it. Scope queries to desktop.
// The compact block has no "Next · …" copy; only the desktop block
// surfaces it. Scope queries to desktop.
function desktop() {
return within(screen.getByTestId('player-bar-desktop'));
}
+4 -4
View File
@@ -15,11 +15,11 @@
let { playlist }: { playlist: Playlist } = $props();
const isOwn = $derived(user.value?.id === playlist.user_id);
// Server tells us which system playlists support by-kind refresh
// (#411 R2) — no hardcoded kind list here.
// Server tells us which system playlists support by-kind refresh — no
// hardcoded kind list here.
const refreshable = $derived(playlist.refreshable === true);
// #417: system playlists atomic-replace on rebuild, so created_at
// System playlists atomic-replace on rebuild, so created_at
// is the last-rotated time. Surface it so users know how fresh the
// mix is. Friendly wording, not the HistoryRow "m/h ago" style.
function refreshedLabel(iso: string): string {
@@ -75,7 +75,7 @@
// seed_artist_id which is non-null only for per-artist mixes.
const isPerArtist = playlist.seed_artist_id != null;
if (variant != null && !isPerArtist) {
// #415: server returns the rotation-aware order (unplayed
// Server returns the rotation-aware order (unplayed
// this rotation first). Play it as-is — no client shuffle —
// and tag the queue so play_started carries `source` and
// the server advances the rotation.
+1 -1
View File
@@ -253,7 +253,7 @@ describe('PlaylistCard', () => {
await fireEvent.click(playButton);
await new Promise(resolve => setTimeout(resolve, 10));
// #415/#411: system play hits the rotation-aware shuffle endpoint
// System play hits the rotation-aware shuffle endpoint
// (raw variant), not the cached detail GET, and never refreshes.
expect(systemShuffle).toHaveBeenCalledWith('for_you');
expect(getPlaylist).not.toHaveBeenCalled();
@@ -23,7 +23,7 @@
// Two different ways a row can be dead, with the same behaviour and
// deliberately different copy. A removed track is gone for good; a
// missing file is a track we still have — history, likes and all —
// whose bytes aren't on disk right now (#2527). Telling the user
// whose bytes aren't on disk right now. Telling the user
// "file missing" invites them to fix it; a strikethrough would say
// "deleted", which is a lie about a file that may well come back.
const isMissingFile = $derived(row.unavailable);
@@ -53,7 +53,7 @@ const removed: PlaylistTrack = {
stream_url: null
};
// Missing file (#2527): the track row still exists — ids intact, history
// Missing file: the track row still exists — ids intact, history
// intact — but the server withholds the stream URL because the bytes are
// not on disk. Deliberately distinct from `removed` above.
const missingFile: PlaylistTrack = {
@@ -6,7 +6,7 @@
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
// The address links in email point at (#4981), on Admin › Email & network.
// The address links in email point at, on Admin › Email & network.
const button =
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
@@ -17,7 +17,7 @@
const dirty = $derived(saved !== null && value.trim() !== saved);
// The address this page was loaded from is almost always the right answer,
// so offer it rather than making the operator type it.
// so offer it rather than making an admin type it.
const here = typeof window !== 'undefined' ? window.location.origin : '';
async function load() {
+7 -7
View File
@@ -44,15 +44,15 @@
{isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : 'bg-background'}"
>
<!--
The album art is the grab surface (#2395). The grip used to occupy its own
column in every row; it now sits OVER the art, so it costs no horizontal
space at all. `use:reorderDrag` is on the row (above), so dragging already
worked from anywhere — the grip's real jobs are being the visual cue and
the keyboard target, and both survive here.
The album art is the grab surface. The grip sits OVER the art rather than
in a column of its own, so it costs no horizontal space at all.
`use:reorderDrag` is on the row (above), so dragging works from anywhere —
the grip's real jobs are being the visual cue and the keyboard target, and
both survive here.
It stays VISIBLE at rest, just quiet — it is the only thing that says this
list can be reordered at all, so hiding it until hover would trade the
operator's space complaint for a discoverability one (rule #24), and would
list can be reordered at all, so hiding it until hover would trade a
space problem for a discoverability one, and would
leave nothing for touch, which has no hover. The scrim only appears on
hover/focus so the artwork stays legible the rest of the time; the drop
shadow is what keeps the glyph readable over pale covers without one.
+2 -2
View File
@@ -95,7 +95,7 @@ describe('QueueTrackRow', () => {
expect(moveQueueItem).not.toHaveBeenCalled();
});
// --- handle placement (#2395) ---
// --- handle placement ---
it('the reorder handle overlays the album art instead of taking its own column', () => {
const { container } = render(QueueTrackRow, {
@@ -106,7 +106,7 @@ describe('QueueTrackRow', () => {
expect(art).not.toBeNull();
// Sharing a parent is what "overlaid" means structurally. If someone moves
// the grip back into its own flex slot, this fails — which is the point:
// that slot cost horizontal space in every row and is why #2395 exists.
// that slot costs horizontal space in every row.
expect(handle.parentElement).toBe(art!.parentElement);
});
@@ -12,8 +12,8 @@
import SettingRow from '#lib/components/settings/SettingRow.svelte';
// Policy for putting an album whose files went missing back on the wanted
// list (milestone #290, M502 #5520). On Admin › Acquisition with the rest
// of how music is got (#5607); Missing files says what it is set to and
// list. On Admin › Acquisition with the rest
// of how music is got; Missing files says what it is set to and
// links here. Searching for a want, and retrying, is the search
// schedule's job, so there is no retry policy here.
@@ -66,7 +66,7 @@
// names the offending field, so surface its message rather than a
// generic failure. errMessage, not `e.message`: the API client throws a
// plain {code, message} object, never an Error, so an instanceof check
// here silently discarded every reason the server gave (#3937).
// here would silently discard every reason the server gives.
pushToast(errMessage(e), 'error');
} finally {
saving = false;
@@ -42,7 +42,7 @@ describe('ReacquisitionSettingsCard', () => {
});
// Saving an unchanged form would be a pointless round-trip, and a live Save
// button invites the operator to wonder whether anything happened.
// button invites an admin to wonder whether anything happened.
test('save is disabled until something changes', async () => {
await renderCard();
const save = await screen.findByRole('button', { name: /save/i });
@@ -72,9 +72,9 @@ describe('ReacquisitionSettingsCard', () => {
// generic "couldn't save" would throw that away.
//
// Rejects with what api.put actually throws — a plain {code, message, status}
// object, not an Error. The old version of this test rejected with an Error,
// which no code path produces, and so passed while the card was discarding
// every server message it was handed (#3937).
// object, not an Error. Rejecting with an Error, which no code path
// produces, would let this pass while the card discarded every server
// message.
test('a rejected save surfaces the server message', async () => {
const message = 'grace_hours must be 1-720';
vi.mocked(updateReacquisitionSettings).mockRejectedValue({
@@ -93,7 +93,7 @@ describe('ReacquisitionSettingsCard', () => {
);
});
// Without this the operator watches those albums never get wanted and
// Without this an admin watches those albums never get wanted and
// reasonably concludes the feature is broken.
test('unnameable albums are called out with the reason', async () => {
await renderCard({ unnameable_albums: 3 });
@@ -8,8 +8,8 @@
} from '#lib/api/metrics.js';
// How plays launched from each recommendation surface land against the
// music you pick yourself. Library › Your listening (#5597): a report on
// your own listening, so it moved out of Settings, which holds choices.
// music you pick yourself. On Library › Your listening: a report on your
// own listening, kept out of Settings, which holds choices.
const metrics = createRecommendationMetricsQuery() as CreateQueryResult<RecommendationMetrics>;
@@ -25,13 +25,13 @@
return `${(v * 100).toFixed(0)}%`;
}
// Deltas come from the server with their margin of error (#2495). The client
// no longer subtracts rates itself: the margin needs the sample sizes and
// Deltas come from the server with their margin of error. The client does
// not subtract rates itself: the margin needs the sample sizes and
// variances, and having both clients re-derive it invites them to disagree.
//
// A delta that isn't distinguishable from zero is prefixed "≈" and dimmed.
// That is the point of this whole change — the card used to render a −12 on
// 59 plays exactly as boldly as a −6 on 400, and the first of those is noise.
// A delta that isn't distinguishable from zero is prefixed "≈" and dimmed,
// so a −12 on 59 plays, which is noise, does not read as boldly as a −6 on
// 400.
function deltaText(d: MetricDelta | undefined): string {
if (!d) return '';
const pts = Math.round(d.delta_pp);
@@ -60,7 +60,7 @@
return d.delta_pp < 0 ? 'text-danger' : 'text-text-secondary';
}
// Pick-kind breakdowns are collapsed by default (#1270): with every
// Pick-kind breakdowns are collapsed by default: with every
// system mix stamping provenance, always-open sub-rows would triple
// the table height and bury the surface-level comparison.
let expandedBreakdowns = $state(new Set<string>());
@@ -156,7 +156,7 @@
{/if}
</td>
</tr>
<!-- Pick-kind provenance sub-rows (#1249/#1270): each
<!-- Pick-kind provenance sub-rows: each
surface splits into the populations its builder
stamped — For You's taste vs freshness injection,
Discover's buckets, the tiered mixes' tiers — the
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
// Moved with the card from Settings to Library › Your listening (#5597).
// The card lives on Library › Your listening.
// Mutable holder so individual tests can inject populated metrics;
// vi.mock is hoisted, hence vi.hoisted for the shared reference.
@@ -78,10 +78,9 @@ describe('RecommendationMetrics (Library › Your listening)', () => {
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
});
// #2495: the card used to render a delta computed client-side with no notion
// of uncertainty, so a -12 on 59 plays looked exactly as solid as a -6 on 400.
// Deltas now arrive from the server with a margin, and an indistinguishable
// one is marked with "≈" and dimmed rather than coloured.
// Deltas arrive from the server with a margin, and an indistinguishable one
// is marked with "≈" and dimmed rather than coloured, so a -12 on 59 plays
// does not look as solid as a -6 on 400.
test('a delta smaller than its margin is marked as indistinguishable', async () => {
metricsMock.data = {
window_days: 30,
@@ -12,7 +12,7 @@
import { pushToast } from '#lib/stores/toast.svelte.js';
import type { ReleaseProfile, ReleaseProfileInput } from '#lib/api/types.js';
// Release-type profiles (M502 #5520): which kinds of release a followed
// Release-type profiles: which kinds of release a followed
// artist's new releases must be to be wanted. A release passes when its
// primary type is ticked and so is every one of its secondary types, so a
// profile with no secondary types wants studio releases only and leaves out
+1 -1
View File
@@ -23,7 +23,7 @@ function req(over: Partial<MusicRequest> = {}): MusicRequest {
const text = (el: HTMLElement) => (el.textContent ?? '').replace(/\s+/g, ' ').trim();
// The row both Requests pages draw (#5651): the admin queue and the
// The row both Requests pages draw: the admin queue and the
// requester's own list read the same request the same way.
describe('RequestRow', () => {
test('says what was asked for, where it stands, and who or when', () => {
@@ -9,14 +9,14 @@
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
// Searching and grabbing (M502 #5521): how often each want is searched
// Searching and grabbing: how often each want is searched
// for, whether the best release is taken without asking, and which
// qualities are taken at all. The schedule wakes every ten minutes and
// keeps to each indexer's searches-per-hour. A finished download is
// checked and filed into one of the library's folders (#5522); it then
// checked and filed into one of the library's folders; it then
// seeds until the ratio or the days below, and a download nobody shares
// is given up after the hours below (#5523). A held album below "Upgrade
// until" is searched for weekly and replaced by a better copy (#5524).
// is given up after the hours below. A held album below "Upgrade
// until" is searched for weekly and replaced by a better copy.
const client = useQueryClient();
const button =
+2 -2
View File
@@ -16,9 +16,9 @@
<script lang="ts">
import { page } from '$app/state';
// A section's pages, down the left (#5572, #5595): Collection and Settings
// A section's pages, down the left: Collection and Settings
// both use it, so a section's navigation sits in the same place and looks
// the same everywhere (#189, "stable chrome"). On a narrow screen the rail
// the same everywhere (stable chrome). On a narrow screen the rail
// runs across the top. An item can carry a count and, accented, how many
// of those need a person.
+7 -8
View File
@@ -2,14 +2,13 @@
import { onMount } from 'svelte';
// Reads /healthz on mount and surfaces the deployed server version
// as understated text. Useful when verifying which image a container
// is actually running (came up debugging the in-app update flow when
// it wasn't obvious whether v2026.05.10.0 or .1 was deployed).
// as understated text, for verifying which image a container is
// actually running.
//
// This is now the ONLY place an operator can see which build they are on.
// Image tags stopped carrying the version on 2026-09-10 — :latest and :dev
// are rolling names and :<sha> answers "which commit", not "which build" —
// so the server's self-report is the answer.
// This is the ONLY place an admin can see which build they are on: image
// tags do not carry the version — :latest and :dev are rolling names and
// :<sha> answers "which commit", not "which build" — so the server's
// self-report is the answer.
//
// The channel is shown BESIDE the version, never spliced into it: the same
// commit built on both lanes reports an identical version and differs only
@@ -42,7 +41,7 @@
});
</script>
<!-- The value of Settings › About's "Server version" row (#5595). -->
<!-- The value of Settings › About's "Server version" row. -->
{#if version}
<span class="text-sm tabular-nums text-text-secondary" data-testid="server-version">
{version}{#if channel}&nbsp;·&nbsp;{channel}{/if}
+15 -18
View File
@@ -17,13 +17,12 @@
let menuButtonRef: HTMLElement | undefined = $state();
// Close the dropdown when the click was outside both the button and the menu.
// The previous implementation used onclick={(e) => e.stopPropagation()} on the
// menu container, which kept the window-level close-on-outside-click handler
// from firing — but it also blocked SvelteKit's document-level link-click
// interceptor, so clicking Admin/Settings inside the dropdown fell through
// to the browser's native navigation (a full-page reload). On a hard reload,
// /admin/+layout.ts's load function ran before bootstrap() settled the user
// store, so the admin guard saw user.value === null and redirected to /.
// Checked here, not with stopPropagation on the menu container: that would
// also block SvelteKit's document-level link-click interceptor, so clicking
// Admin/Settings in the dropdown would fall through to a full-page reload —
// and on a hard reload /admin/+layout.ts's load runs before bootstrap()
// settles the user store, so the admin guard sees user.value === null and
// redirects to /.
function handleWindowClick(e: MouseEvent) {
if (!menuOpen) return;
const target = e.target as Node | null;
@@ -43,17 +42,15 @@
return page.url.pathname.startsWith(prefix);
}
// Primary nav. Sits centered in the top bar (replaces the prior left
// sidebar). Mirrors the Android top-app-bar treatment: three first-class
// destinations, plus Library acting as a parent for Artists / Albums /
// Liked / History / Playlists (tabs at /library/+layout.svelte).
// Search + user menu live on the right side of the header (unchanged).
// Library targets the Artists tab directly so SPA navigation doesn't
// bounce through the /library 308-redirect. isActive('/library')
// still matches all sub-routes by prefix so the active state covers
// every tab.
// Collection (M502) is an admin's: the catalogue of what the library holds
// and wants, and what Minstrel is fetching for it.
// Primary nav. Sits centered in the top bar. Mirrors the Android top-app-bar
// treatment: three first-class destinations, plus Library acting as a parent
// for Artists / Albums / Liked / History / Playlists (tabs at
// /library/+layout.svelte). Search + user menu live on the right side of the
// header. Library targets the Artists tab directly so SPA navigation doesn't
// bounce through the /library 308-redirect. isActive('/library') still
// matches all sub-routes by prefix so the active state covers every tab.
// Collection is an admin's: the catalogue of what the library holds and
// wants, and what Minstrel is fetching for it.
const navItems = $derived([
{ href: '/', label: 'Home', icon: House, matchPrefix: '/' },
{ href: '/library/artists', label: 'Library', icon: LibraryBig, matchPrefix: '/library' },
+1 -1
View File
@@ -5,7 +5,7 @@
type StatusEntry = { label: string; tone: Tone };
// Voice-rule labels per spec §6 (and project_design_system.md). Pending requests
// Labels in Minstrel's voice. Pending requests
// are an "awaiting" moment, completed requests are "kept" (Minstrel-voice for
// success), rejected is "set aside", failed is the error register.
const STATUS_TABLE: Record<RequestStatus, StatusEntry> = {
+1 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import StatusPill from './StatusPill.svelte';
// Voice-rule labels and semantic tones per spec §6 / project_design_system.md.
// Labels in Minstrel's voice, and their semantic tones.
// Each row is the contract: input status -> rendered label + data-tone attr.
const CASES = [
{ status: 'pending', label: 'Awaiting review', tone: 'warning' },
+7 -7
View File
@@ -45,11 +45,11 @@
}
// "Matches your taste in shoegaze and dream pop." — the reason line when the
// candidate's own tags overlap the taste profile (#2377). Preferred over seed
// candidate's own tags overlap the taste profile. Preferred over seed
// attribution because it describes the MUSIC ("sounds like what you like")
// rather than the graph ("adjacent to something you played"), which is the
// whole point of slice 6. Falls back when there are no matched tags, which is
// the common case: coverage is partial by nature (#2376).
// rather than the graph ("adjacent to something you played"). Falls back
// when there are no matched tags, which is the common case: coverage is
// partial by nature.
function reasonText(s: ArtistSuggestion): string {
const tags = s.matched_tags ?? [];
if (tags.length === 0) return attributionText(s.attribution);
@@ -148,9 +148,9 @@
{#if !query.isPending && suggestions.length === 0}
<!--
An empty deck used to mean one thing — no listening signal yet. With
snoozing it can also mean "you parked them all", and telling that user to
go listen to something would be wrong advice.
An empty deck means no listening signal yet, or "you parked them all",
and telling the second user to go listen to something would be wrong
advice.
-->
<p class="text-text-secondary">
{snoozes.length === 0
@@ -138,7 +138,7 @@ describe('SuggestionFeed snooze (#2375)', () => {
render(SuggestionFeed);
await fireEvent.click(screen.getByRole('button', { name: /not right now/i }));
// Still on screen — the disappearance happens on refetch, not under the
// cursor (rule #24).
// cursor.
expect(screen.getByText('Outsider')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /bring outsider back/i })).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('Not right now');
+2 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts" generics="Id extends string">
// In-page tabs: one strip of buttons switching what a single page shows
// (#5416). The same look as DiscoverTabs, which is route
// In-page tabs: one strip of buttons switching what a single page shows.
// The same look as DiscoverTabs, which is route
// navigation and so stays links; this is for state inside one page, such as
// Liked's Artists | Albums | Tracks or a queue's status filter.
type Item = {
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
// The box every track list sits in (milestone 494). No fill of its own:
// The box every track list sits in. No fill of its own:
// the rows stripe themselves, and a filled box would hide the stripe.
// Rows are direct children so `odd:` counts them.
import type { Snippet } from 'svelte';
+2 -2
View File
@@ -1,11 +1,11 @@
<script lang="ts">
// The one shape every track list in the web app draws (milestone 494):
// The one shape every track list in the web app draws:
// a lead column (track number, drag grip or cover), title over a muted
// subtitle, then like, radio, add-to-queue, the track menu and the
// duration, then whatever the list adds at the end (remove, a timestamp).
// The wrappers — TrackRow, PlaylistTrackRow, HistoryRow — own behaviour
// (selection, reorder, remove); this component owns how a row looks, so
// the lists cannot drift apart again.
// the lists cannot drift apart.
import type { Snippet } from 'svelte';
import { Plus, Radio } from 'lucide-svelte';
import type { TrackRef } from '#lib/api/types.js';
+1 -1
View File
@@ -25,7 +25,7 @@
} = $props();
const track = $derived(tracks[index]);
// #5418: the file is gone. The row stays, greyed, so an album still reads
// The file is gone. The row stays, greyed, so an album still reads
// in order, but there is nothing to play, like or queue.
const missing = $derived(!!track.unavailable);
+1 -1
View File
@@ -89,7 +89,7 @@ describe('TrackRow', () => {
).toBeInTheDocument();
});
// #5418: a missing file stays in the list, greyed, with nothing to act on.
// A missing file stays in the list, greyed, with nothing to act on.
test('a missing file is marked, does not play, and offers no actions', async () => {
const withMissing = [{ ...tracks[0], unavailable: true }, tracks[1]];
render(TrackRow, { props: { tracks: withMissing, index: 0 } });
+1 -1
View File
@@ -14,7 +14,7 @@
import { formatBytes } from '#lib/utils/bytes.js';
import type { Candidate, CandidateVerdict, GrabResult } from '#lib/api/types.js';
// Every release a want's last search found (M502 #5521), with the verdict
// Every release a want's last search found, with the verdict
// and the reasons behind it, so a person can see why nothing was taken and
// take something anyway, or set a release aside for good.
@@ -5,8 +5,8 @@
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// One block for a list of outside sources (#5607): cover art and tags had
// the same panel pasted twice. Each source is a row: its name and what it
// One block for a list of outside sources, shared by cover art and tags.
// Each source is a row: its name and what it
// supplies, a switch, a key field when it takes one, Save and Test.
type Provider = {
@@ -64,7 +64,7 @@
return words.length ? words.join(', ').replace(/^./, (c) => c.toUpperCase()) : '';
}
// The operator's edits, kept across refetches until saved.
// An admin's edits, kept across refetches until saved.
let local = $state<Record<string, { enabled: boolean; apiKey: string }>>({});
let results = $state<Record<string, TestResult>>({});
let saving = $state<Record<string, boolean>>({});
@@ -7,7 +7,7 @@
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
// Outgoing email (#5607): the mail server password resets and
// Outgoing email: the mail server password resets and
// notifications go out through. Send test mails the signed-in admin.
const client = useQueryClient();
@@ -35,7 +35,7 @@
// Filled from the saved config once, when it first loads, and again after
// a save, so a refetch doesn't throw edits away. Until it has loaded the
// form holds defaults, so Save stays off: saving then would write the
// defaults over the real config (#5604).
// defaults over the real config.
let loaded = $state(false);
$effect(() => {
if (smtpQ.data && !loaded) {
+1 -1
View File
@@ -1,5 +1,5 @@
// Drag-to-reorder for a row in a vertical list, shared by the playlist and
// queue rows (#5393). Wraps @neodrag/svelte with the three things a list
// queue rows. Wraps @neodrag/svelte with the three things a list
// needs that neodrag does not do on its own:
//
// - The row moves in whole-row steps, so it visibly snaps into the slot it
@@ -1,5 +1,5 @@
<script lang="ts" generics="V extends string | number">
// A one-of-N choice as a row of joined buttons (#5595): Theme, Volume
// A one-of-N choice as a row of joined buttons: Theme, Volume
// leveling, Target loudness. Each option is a real radio, so it is
// reachable by keyboard and named by its own text.
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// One setting (#188, #5595): its name and at most one short line on the
// One setting: its name and at most one short line on the
// left, its control at the end. `for` names the control's id so the name
// labels it. `hint` is a line of text, or a snippet when it needs a link.
// A control too wide to sit beside the name (a form field on a phone)
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// A settings page's groups laid out for the width it has (#5595 follow-up):
// A settings page's groups laid out for the width it has:
// one column on a phone or a narrow window, two side by side on a wide
// screen, so a wide window shows more at once instead of one narrow strip
// with empty space beside it. Each column stays narrow enough that a row's
@@ -1,9 +1,9 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// A group of settings on a Settings page (#5595): a titled card whose
// rows are SettingRows, ruled apart. A settings page reads as a menu
// (#188), so a group carries a title and, at most, one short line.
// A group of settings on a Settings page: a titled card whose
// rows are SettingRows, ruled apart. A settings page reads as a menu,
// so a group carries a title and, at most, one short line.
let {
title,
@@ -8,7 +8,7 @@
import ListContinuation from '#lib/components/ListContinuation.svelte';
import type { ReleaseBlock } from '#lib/api/types.js';
// The Blocked tab (M502 #5521–#5523): releases the search will not take
// The Blocked tab: releases the search will not take
// again — set aside by a person, by what their files were, or by an
// import's check that found they weren't the album; and, for a week,
// releases given up on because they would not finish.
@@ -2,7 +2,7 @@
import Cover from '#lib/components/Cover.svelte';
import type { WantArtist } from '#lib/api/types.js';
// One artist in the Collection's catalogue (#5572), as a poster: the
// One artist in the Collection's catalogue, as a poster: the
// picture, a bar of how many of their albums on the list are held, and
// whether Minstrel follows them for new releases.
@@ -7,11 +7,11 @@
import CatalogueArtistCard from './CatalogueArtistCard.svelte';
import type { WantArtist } from '#lib/api/types.js';
// The Collection's catalogue (#5572), its main page, an artist index: every
// The Collection's catalogue, its main page, an artist index: every
// artist on the list A–Z, each card showing how many of their albums are
// held. An artist opens on their albums. The library's albums were put on
// the list as held once (#5567), each matched to MusicBrainz (#5569); the
// ones no match was found for are listed under Unmatched (#5579).
// the list as held once, each matched to MusicBrainz; the
// ones no match was found for are listed under Unmatched.
let filter = $state('');
const q = $derived(filter.trim());
@@ -3,13 +3,13 @@
import { countOf } from '#lib/admin/queues.js';
import type { AdminSummary, WantCounts } from '#lib/api/types.js';
// The Collection's sections, down the left (#5572): the catalogue of
// The Collection's sections, down the left: the catalogue of
// artists first, then the sections that show state. Activity carries a
// second, accented count when downloads are waiting on a person. Unmatched
// holds the library albums no MusicBrainz album matched (#5579). Upgrades
// holds the albums held but wanted for a better copy (#5524). Reports,
// holds the library albums no MusicBrainz album matched. Upgrades
// holds the albums held but wanted for a better copy. Reports,
// Duplicates, Suspect sources and Missing files are the library's health
// queues (M504): their counts come from the admin summary and are
// queues: their counts come from the admin summary and are
// accented, as they wait on a person. Missing files counts tracks.
let { counts, summary }: { counts?: WantCounts; summary?: AdminSummary } = $props();
@@ -15,13 +15,13 @@
import ListContinuation from '#lib/components/ListContinuation.svelte';
import type { Download, DownloadCondition, ImportEvidence } from '#lib/api/types.js';
// The Activity tab (M502 #5522): every download Minstrel took, running
// The Activity tab: every download Minstrel took, running
// ones first. A finished download is checked against MusicBrainz before it
// is filed; one the check could not settle waits here for a person, who
// imports it anyway or refuses it. A refused release is set aside (the
// Blocked tab) and its album searched for again. A download that fails, or
// that nobody shares, is retried and then given up the same way, held back
// for a week; an imported one seeds, then is removed (#5523).
// for a week; an imported one seeds, then is removed.
const client = useQueryClient();
const downloadsStore = createDownloadsQuery();
@@ -13,7 +13,7 @@
import { relativeTime } from '#lib/utils/relativeTime.js';
import type { Follow } from '#lib/api/types.js';
// The Following tab (M502 #5520): every artist whose new releases are
// The Following tab: every artist whose new releases are
// wanted, each on a release-type profile saying which kinds of release.
const client = useQueryClient();
@@ -9,7 +9,7 @@
import UnmatchedMatch from './UnmatchedMatch.svelte';
import type { UnmatchedAlbum } from '#lib/api/types.js';
// The Collection's Unmatched section (#5579): the library albums no
// The Collection's Unmatched section: the library albums no
// MusicBrainz album matched by artist, title and tracks. Each can be
// matched by hand, to a search result or a pasted link, and goes on the
// list as held; or left off, as a local band's album MusicBrainz will
@@ -3,7 +3,7 @@
import { createCatalogueSearchQuery } from '#lib/api/catalogue.js';
import type { CatalogueSearchResult, UnmatchedAlbum } from '#lib/api/types.js';
// Naming the MusicBrainz album an unmatched album is (#5579): a search of
// Naming the MusicBrainz album an unmatched album is: a search of
// MusicBrainz's albums, started from the album's own title and artist, or
// a link pasted from musicbrainz.org. MusicBrainz takes a request a second,
// so the search runs when it's asked for, not on each keystroke.
@@ -16,19 +16,19 @@
import { needsYou, searched, wantStatus } from '#lib/wanted/wantStatus.js';
import type { Want, WantState } from '#lib/api/types.js';
// Albums on the Collection's list (M502 #5520–#5522): the ones still
// Albums on the Collection's list: the ones still
// wanted, the missing ones wanted back, or, given an artist, every album
// of theirs in the catalogue, held or not, under a header (#5572). Each
// of theirs in the catalogue, held or not, under a header. Each
// open want says what its last search found and how its download is
// getting on; Releases lists every candidate with its verdict, and Search
// runs one now. Taking a held album off leaves its files and stops it
// being fetched back if they go.
//
// Upgrades (#5524) are held albums wanted for a better copy: below the
// Upgrades are held albums wanted for a better copy: below the
// quality set under Acquisition, or holding a video rip. They search and
// download like an open want, and say what copy they have.
//
// The list reads like the library (#5572): artists A–Z, each with its
// The list reads like the library: artists A–Z, each with its
// albums oldest first, and each album's tracks a click away. The filter
// narrows it to an artist or album by name, on the server, so it finds
// what has not been scrolled to yet.
@@ -4,7 +4,7 @@
import { errCode } from '#lib/api/errors.js';
import type { WantTrack } from '#lib/api/types.js';
// An album's tracks under its row in the Wanted view (#5572): the
// An album's tracks under its row in the Wanted view: the
// library's own for an album it holds, a lost file marked, otherwise
// MusicBrainz's standard edition of what is wanted.
+1 -1
View File
@@ -1,6 +1,6 @@
// Fallback cover used when the real cover fails to load (e.g. scanner
// didn't pick up embedded art and there's no sidecar). Served as a static
// asset from web/static/placeholders/ so the operator can swap the artwork
// asset from web/static/placeholders/ so an admin can swap the artwork
// without touching code. SvelteKit + adapter-static publishes everything
// under web/static/ at the URL root, and the Go binary embeds the build
// output, so this resolves identically in dev and production.
+1 -1
View File
@@ -2,7 +2,7 @@
// (player/ui/DominantColor.kt): androidx.palette's dominant swatch, not
// the mean and not the most vibrant. A mean turns a busy cover to mud;
// a vibrant pick lets a small bright accent tint the whole page. The
// dominant swatch matches what the eye sees most of (#5592).
// dominant swatch matches what the eye sees most of.
//
// Palette's method, kept close so the web and Android agree: shrink the
// image, quantise each pixel to 5 bits a channel, drop the colours its
+4 -5
View File
@@ -1,13 +1,12 @@
// Audio loader seam. Today: returns the server-relative stream URL
// directly. In the future Tauri shell, the loader can be swapped to
// return blob URLs backed by a Rust-side cache (mirroring the Flutter
// drift+LockCachingAudioSource pattern), without touching the player UI.
// Audio loader seam: returns the server-relative stream URL directly. A
// native shell can swap the loader to return blob URLs backed by its own
// cache, without touching the player UI.
//
// Two operations:
// - resolve(track) — returns the URL the <audio> element should src.
// - prefetch(track) — hint that a track will be needed soon. Default
// no-op (the browser handles it via the prefetch <audio> element);
// Tauri can warm its native cache here.
// a native shell can warm its cache here.
import type { TrackRef } from '#lib/api/types.js';
+11 -14
View File
@@ -17,22 +17,19 @@ import type { PlayStartedResponse, EventOkResponse } from '#lib/api/types.js';
// - pagehide with a live row → navigator.sendBeacon a play_ended with
// the last position. The server's rule still classifies skip-vs-not.
//
// Earlier shape made the skip-vs-ended call client-side via a "within 3s
// of duration" rule, which left the server's threshold dead code and
// treated every "next" tap as a skip even after most of the track played.
// Backflow of the Android fix (commit d82e744d, 2026-06-01); see Scribe
// task 519. Flutter client is deprecated and not updated.
// The skip-vs-ended call is the server's, as on Android: a client-side
// "within 3s of duration" rule would leave the server's threshold dead and
// treat every "next" tap as a skip even after most of the track played.
export function useEventsDispatcher(): void {
let openPlayEventId: string | null = null;
let openTrackId: string | null = null;
// openLastPositionMs tracks the *open row's* track and is updated
// ONLY while that track is current — so a track change (which
// synchronously resets the store's position to 0 and duration to 0)
// can't clobber it before the close branch reads it. This is the
// #426 race fix. The pagehide + natural-end branches can read
// player.position directly because their current track is still the
// open one at fire time.
// openLastPositionMs tracks the *open row's* track and is updated ONLY while
// that track is current — so a track change (which synchronously resets the
// store's position to 0 and duration to 0) can't clobber it before the close
// branch reads it. The pagehide + natural-end branches can read
// player.position directly because their current track is still the open one
// at fire time.
let openLastPositionMs = 0;
let prevTrackId: string | null = null;
let prevState: string | null = null;
@@ -121,11 +118,11 @@ export function useEventsDispatcher(): void {
type: 'play_started',
track_id: trackId,
client_id: clientId,
// #415: tag the play with the system playlist it came from
// Tag the play with the system playlist it came from
// (null for library / user-playlist / radio) so the server
// can advance that playlist's rotation.
source: player.queueSource ?? undefined,
// #1551: device class for context conditioning. The web app is a
// Device class for context conditioning. The web app is a
// browser session, so it reports 'web'.
device_class: 'web'
});
+1 -1
View File
@@ -1,4 +1,4 @@
// Loudness normalization math for the web player (M464 #4999). Pure, so the
// Loudness normalization math for the web player. Pure, so the
// rules are tested without an <audio> element or an AudioContext.
import type { NormalizationPrefs, ReplayGain } from '#lib/api/normalization.js';
+4 -4
View File
@@ -1,4 +1,4 @@
// The web player's gain stage (M464 #4999). A cut is applied through the
// The web player's gain stage. A cut is applied through the
// element's own volume. A boost needs Web Audio, because element.volume
// stops at 1: the element is routed through a GainNode and a
// DynamicsCompressor, which acts as a limiter when the user picked one.
@@ -6,8 +6,8 @@
// Routing is permanent once made (createMediaElementSource cannot be
// undone), and a context that is not running plays nothing. So the graph is
// built only when a track actually wants a boost, and only once a context
// has been confirmed running. Until then a boost plays at unity, which is
// what the track sounded like before leveling existed.
// has been confirmed running. Until then a boost plays at unity: the track
// as it sounds unleveled.
import { SvelteMap } from 'svelte/reactivity';
import { getReplayGain, type ReplayGain } from '#lib/api/normalization.js';
@@ -137,7 +137,7 @@ export async function ensureGraph(el: HTMLAudioElement): Promise<void> {
// iOS suspends Web Audio when the screen locks, and a routed element goes
// silent with it, so iOS never gets the graph. It ignores element.volume
// too, so leveling there waits for the server's leveled stream (#5001).
// too, so leveling there waits for the server's leveled stream.
function isIOS(): boolean {
const n = navigator;
return /iPad|iPhone|iPod/.test(n.userAgent) || (n.platform === 'MacIntel' && n.maxTouchPoints > 1);
+22 -23
View File
@@ -70,26 +70,26 @@ let _queueDrawerOpen = $state(false);
// saved position after metadata loads.
let _pendingRestorePosition: number | null = null;
// M4c: track when the queue was seeded by a radio call so we can
// Track when the queue was seeded by a radio call so we can
// auto-refresh at 80% consumption. Cleared when the user enqueues
// from a non-radio source.
let _radioSeedId = $state<string | null>(null);
let _radioRefreshInFlight = false;
// #415: which system playlist (if any) this queue was seeded from.
// Which system playlist (if any) this queue was seeded from.
// 'for_you' | 'discover' | null. Read by the events dispatcher so
// play_started carries `source` and the play counts against that
// playlist's rotation. Re-set on every playQueue (a fresh non-system
// queue clears it).
let _queueSource = $state<string | null>(null);
// Track B (#968): consecutive load failures since the last successful
// Consecutive load failures since the last successful
// 'playing'. A bad track auto-advances instead of dead-ending; the streak
// (capped against queue length) stops a fully-unplayable queue from
// looping forever. Reset on a successful play and on a fresh playQueue.
let _failureStreak = 0;
// Track B (#968): when a queue is seeded from a refreshable source (a system
// When a queue is seeded from a refreshable source (a system
// playlist or radio), the seeder hands us a closure that re-pulls the fresh
// snapshot. On total failure (whole queue unplayable — likely a stale tab
// left open across the daily rebuild) we call it once to self-heal before
@@ -123,7 +123,7 @@ export function registerAudioEl(el: HTMLAudioElement | null): void {
_audioEl = el;
}
// #5418: a missing file can't stream, so it never enters the queue. Returns
// A missing file can't stream, so it never enters the queue. Returns
// the same array when nothing is missing.
function playable(tracks: TrackRef[]): TrackRef[] {
return tracks.some((t) => t.unavailable) ? tracks.filter((t) => !t.unavailable) : tracks;
@@ -138,12 +138,12 @@ export function playQueue(
refetch?: () => Promise<TrackRef[]>;
} = {},
): void {
_radioSeedId = null; // M4c: non-radio enqueue clears the radio refresh state
// #415: a fresh queue resets the system-playlist source. Set only
_radioSeedId = null; // Non-radio enqueue clears the radio refresh state
// A fresh queue resets the system-playlist source. Set only
// when seeded from a system playlist (server already returned the
// rotation-aware order, so no client shuffle in that path).
_queueSource = opts.source ?? null;
// #968: a fresh play replaces the self-heal closure + resets its budget.
// A fresh play replaces the self-heal closure + resets its budget.
_queueRefetch = opts.refetch ?? null;
_selfHealAttempts = 0;
// Start on the chosen track's place in the playable list; if the chosen
@@ -184,8 +184,8 @@ export function playQueue(
export function togglePlay(): void {
if (_queue.length === 0) return;
// "Intent to play" (loading + playing) → paused. Anything else → loading (user
// wants to play; audio will catch up).
// "Intent to play" (loading + playing) → paused. Anything else → loading
// (user wants to play; audio will catch up).
if (_state === 'playing' || _state === 'loading') {
_state = 'paused';
} else {
@@ -340,7 +340,7 @@ export function reportStateFromAudio(
}
}
// Track B (#968): a single failed track must not strand the player on the
// A single failed track must not strand the player on the
// "Try again" dead-end. Advance past it; only surface the error once the
// whole queue has proven unplayable — every track failed, or we reached the
// end with nothing playable. The streak cap (vs queue length) stops a
@@ -362,7 +362,7 @@ function handleLoadFailure(detail?: string): void {
_error = detail ?? 'Playback failed.';
}
// #968: re-pull a stale refreshable queue. Returns true if a self-heal was
// Re-pull a stale refreshable queue. Returns true if a self-heal was
// started (caller must not dead-end). Bounded to one attempt per exhaustion
// (reset on the next successful play) so a still-broken refresh can't loop.
function trySelfHeal(): boolean {
@@ -411,12 +411,12 @@ export function enqueueTrack(t: TrackRef): void {
}
/**
* M7 #372: insert a track immediately after the currently-playing one
* Insert a track immediately after the currently-playing one
* so it plays next. If the queue is empty, behaves like enqueueTrack
* and starts the queue with this track at index 0.
*/
export function playNext(t: TrackRef): void {
_radioSeedId = null; // M4c: any user-driven enqueue clears the radio refresh state
_radioSeedId = null; // Any user-driven enqueue clears the radio refresh state
if (t.unavailable) return;
if (_queue.length === 0) {
_queue = [t];
@@ -461,10 +461,10 @@ async function fetchRadioTracks(seedTrackId: string): Promise<TrackRef[]> {
export async function playRadio(seedTrackId: string): Promise<void> {
const tracks = await fetchRadioTracks(seedTrackId);
if (tracks.length === 0) return;
// #968: hand the player a self-heal closure so a fully-stale radio queue
// Hand the player a self-heal closure so a fully-stale radio queue
// re-seeds from the same track instead of dead-ending.
playQueue(tracks, 0, { refetch: () => fetchRadioTracks(seedTrackId) });
_radioSeedId = seedTrackId; // M4c: set AFTER playQueue (which clears it)
_radioSeedId = seedTrackId; // Set AFTER playQueue (which clears it)
}
export function moveQueueItem(from: number, to: number): void {
@@ -577,7 +577,7 @@ export function consumePendingRestorePosition(): number | null {
return p;
}
// M4c: auto-refresh radio queue when 80% consumed.
// Auto-refresh radio queue when 80% consumed.
// Fires whenever the consumption ratio crosses 80% AND the queue was
// seeded by playRadio() AND no refresh is currently in-flight.
$effect.root(() => {
@@ -590,7 +590,7 @@ $effect.root(() => {
_radioRefreshInFlight = true;
const seed = _radioSeedId;
// Drift #554: cap the exclude list so a multi-hour radio session
// Cap the exclude list so a multi-hour radio session
// doesn't grow the query string past common 8KB limits and start
// 414-ing /api/radio. The .catch() below would silently swallow
// that failure and the player would stop topping up — a dead
@@ -625,7 +625,7 @@ $effect.root(() => {
});
});
// Session-end teardown (#375): auth signals via sessionEnd; player
// Session-end teardown: auth signals via sessionEnd; player
// owns the player-specific cleanup (clear persisted queue for the
// outgoing user, reset in-memory queue, close drawer) without auth
// having to import any player APIs.
@@ -641,10 +641,9 @@ $effect.root(() => {
});
});
// Persistence (M7 #364): split-effect shape. Queue/index changes write
// immediately (rare, user-driven); position changes throttle to once
// per 5s during steady playback. See spec section "Persistence write
// effect" for the alternative debounce shape.
// Persistence: split-effect shape. Queue/index changes write immediately
// (rare, user-driven); position changes throttle to once per 5s during
// steady playback.
$effect.root(() => {
let lastPositionWriteAt = 0;
+17 -13
View File
@@ -40,7 +40,8 @@ function track(id: string, dur = 180): TrackRef {
beforeEach(() => {
localStorage.clear();
// Reset state by starting with an empty queue (playQueue([]) leaves queue=[], index=0).
// Reset state by starting with an empty queue (playQueue([]) leaves queue=[],
// index=0).
playQueue([]);
closeQueueDrawer();
setVolume(1);
@@ -73,12 +74,13 @@ describe('player store — basic actions', () => {
expect(player.state).toBe('idle'); // still idle with empty queue
playQueue([track('1')]);
// After playQueue we're in 'loading'; togglePlay should not advance without an audio event.
// But togglePlay from 'loading' should set the intent to play anyway — tested via report later.
// Here, simulate the paused state:
// After playQueue we're in 'loading'; togglePlay should not advance without
// an audio event. But togglePlay from 'loading' should set the intent to
// play anyway — tested via report later. Here, simulate the paused state:
reportTimeUpdate(0);
// manually put us in paused state by calling togglePlay twice isn't clean; set via the state-report API in Task 2.
// Instead, test the simpler case: playQueue then togglePlay = still 'loading' (intent is to play).
// manually put us in paused state by calling togglePlay twice isn't clean;
// set via the state-report API in Task 2. Instead, test the simpler case:
// playQueue then togglePlay = still 'loading' (intent is to play).
togglePlay(); // from 'loading' — inverts intent; store moves to 'paused' intent
expect(player.state).toBe('paused');
togglePlay(); // from 'paused' — moves to 'loading' (re-play intent)
@@ -185,8 +187,8 @@ describe('player store — shuffle + repeat + audio reports', () => {
});
test('toggleShuffle randomizes queue[index+1..end], leaves current and earlier alone', () => {
// Deterministic RNG: always returns 0 so Fisher-Yates swaps each i with i itself → no change.
// Flip to always 0.999 to force swaps.
// Deterministic RNG: always returns 0 so Fisher-Yates swaps each i with i
// itself → no change. Flip to always 0.999 to force swaps.
const seq = [0.999, 0.999, 0.999, 0.999, 0.999];
let i = 0;
__setShuffleRng(() => seq[i++ % seq.length]);
@@ -199,9 +201,10 @@ describe('player store — shuffle + repeat + audio reports', () => {
// Index 0 and 1 preserved:
expect(player.queue[0].id).toBe('1');
expect(player.queue[1].id).toBe('2');
// Index 2..4 reordered: because RNG = 0.999 always, Math.floor(rng() * (j+1)) === j for each,
// so swaps are with self — queue is unchanged. So validate that the IDs 3/4/5 are still the
// tail set, in any order, to tolerate future RNG semantics.
// Index 2..4 reordered: because RNG = 0.999 always, Math.floor(rng() *
// (j+1)) === j for each, so swaps are with self — queue is unchanged. So
// validate that the IDs 3/4/5 are still the tail set, in any order, to
// tolerate future RNG semantics.
const tailIds = [player.queue[2].id, player.queue[3].id, player.queue[4].id].sort();
expect(tailIds).toEqual(['3', '4', '5']);
@@ -399,7 +402,7 @@ describe('player store — enqueue + playRadio', () => {
});
});
// #5418: a missing file can't stream, so no entry point lets one into the queue.
// A missing file can't stream, so no entry point lets one into the queue.
describe('missing files stay out of the queue', () => {
const gone = (id: string) => ({ ...track(id), unavailable: true });
@@ -487,7 +490,8 @@ describe('M4c radio auto-refresh at 80%', () => {
const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks: initial });
await playRadio('seed');
apiGet.mockClear();
// Refresh response: 5 tracks where the first is the seed (will be stripped).
// Refresh response: 5 tracks where the first is the seed (will be
// stripped).
const refreshTracks = [track('seed'), track('n1'), track('n2'), track('n3'), track('n4')];
apiGet.mockResolvedValueOnce({ tracks: refreshTracks });
skipNext(); skipNext(); skipNext(); // index = 3 (80%)
+1 -1
View File
@@ -7,7 +7,7 @@
// - track_id is null — the track was removed from the library and only
// the playlist's text snapshot remains.
// - unavailable — the track is still there, with its history intact,
// but its file is missing from disk (#2527).
// but its file is missing from disk.
// Callers filter nulls, which is what makes a missing file "get skipped"
// everywhere a queue is built rather than at each call site.

Some files were not shown because too many files have changed in this diff Show More