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