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