From 4c48d11e9d007adbba44e4ef5b0bc78e7d82ead8 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 14:40:48 +0400 Subject: [PATCH] feat: enhance discovery, vibe sessions, and library enrichment --- backend/src/app.ts | 2 +- backend/src/db/migrations.test.ts | 22 + backend/src/db/migrations.ts | 134 ++ backend/src/db/schema.sql | 82 +- backend/src/db/types.ts | 28 + backend/src/routes/admin.routes.ts | 270 +++- backend/src/routes/discovery.routes.ts | 15 +- backend/src/routes/settings.routes.ts | 1 + backend/src/routes/v2.routes.ts | 126 +- backend/src/services/db.service.test.ts | 85 ++ backend/src/services/db.service.ts | 278 +++- .../src/services/discovery.service.test.ts | 47 + backend/src/services/discovery.service.ts | 61 +- backend/src/services/generators.service.ts | 16 +- backend/src/services/generators.test.ts | 35 +- backend/src/services/job.service.ts | 188 ++- .../src/services/session-director.service.ts | 137 +- backend/src/services/session-director.test.ts | 70 +- backend/src/types/job.types.ts | 11 +- docker-compose.yml | 13 +- frontend/package-lock.json | 1270 ++++++++++++++++- frontend/package.json | 9 +- frontend/src/components/AppShell.tsx | 22 +- frontend/src/components/ArtistLinks.tsx | 10 +- frontend/src/components/AudioEngine.tsx | 2 +- frontend/src/components/Inspector.tsx | 169 --- frontend/src/components/NavRail.tsx | 82 +- .../src/components/NowPlayingPanel.test.tsx | 27 + frontend/src/components/NowPlayingPanel.tsx | 42 +- frontend/src/components/PanelHeader.tsx | 4 +- frontend/src/components/PlaybackBar.tsx | 32 +- frontend/src/components/TopBar.tsx | 25 +- frontend/src/components/TrackRow.tsx | 47 +- frontend/src/index.css | 53 + frontend/src/pages/Discover.tsx | 37 +- frontend/src/pages/Settings.tsx | 43 +- frontend/src/pages/Vibe.tsx | 124 +- frontend/src/services/vibeService.test.ts | 32 + frontend/src/services/vibeService.ts | 35 +- frontend/src/services/vibeSession.test.ts | 56 + frontend/src/services/vibeSession.ts | 51 + frontend/src/store/useVibeStore.ts | 5 + frontend/src/test/setup.ts | 5 + frontend/vite.config.js | 5 + workers/Dockerfile | 7 +- workers/src/acquisition.service.ts | 289 ++++ workers/src/audio-analysis.test.ts | 37 + workers/src/audio-analysis.ts | 64 + workers/src/audio-features.service.ts | 113 +- workers/src/enrichment.service.ts | 87 +- workers/src/essentia.d.ts | 1 - workers/src/index.ts | 122 +- workers/src/scanner.service.ts | 113 +- workers/src/types.ts | 16 +- 54 files changed, 4136 insertions(+), 521 deletions(-) create mode 100644 backend/src/db/migrations.test.ts create mode 100644 backend/src/services/discovery.service.test.ts delete mode 100644 frontend/src/components/Inspector.tsx create mode 100644 frontend/src/components/NowPlayingPanel.test.tsx create mode 100644 frontend/src/services/vibeService.test.ts create mode 100644 frontend/src/services/vibeSession.test.ts create mode 100644 frontend/src/services/vibeSession.ts create mode 100644 frontend/src/test/setup.ts create mode 100644 workers/src/acquisition.service.ts create mode 100644 workers/src/audio-analysis.test.ts create mode 100644 workers/src/audio-analysis.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index fd97d79..081a0fa 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -160,7 +160,7 @@ export async function buildApp(config: AppConfig) { const sessionDirector = new SessionDirector(dbService); fastify.register(v2Routes, { prefix: '/api', dbService, sessionDirector }); - fastify.register(discoveryRoutes, { prefix: '/api', dbService }); + fastify.register(discoveryRoutes, { prefix: '/api', dbService, jobService }); // ponytail: /api/test/enqueue-job (manual job-enqueue test endpoint) removed — // nothing in the deployed app or its tests called it, and deployment never // sets NODE_ENV so an env gate would've stayed live in prod anyway. diff --git a/backend/src/db/migrations.test.ts b/backend/src/db/migrations.test.ts new file mode 100644 index 0000000..edf3bd0 --- /dev/null +++ b/backend/src/db/migrations.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { MIGRATIONS } from './migrations.js'; + +describe('track release-date migration', () => { + const migration = MIGRATIONS.find( + ({ id }) => id === '20260801_track_release_dates_from_albums', + ); + + it('backfills from albums with a null-safe, deterministic conflict rule', () => { + expect(migration).toBeDefined(); + expect(migration!.sql).toContain('ALTER TABLE albums ADD COLUMN IF NOT EXISTS release_date DATE'); + expect(migration!.sql).toContain('UPDATE tracks AS t'); + expect(migration!.sql).toContain('SET release_date = al.release_date'); + expect(migration!.sql).toContain('t.release_date IS DISTINCT FROM al.release_date'); + }); + + it('keeps new tracks and album metadata updates synchronized', () => { + expect(migration!.sql).toContain('BEFORE INSERT OR UPDATE OF album_id, release_date ON tracks'); + expect(migration!.sql).toContain('AFTER UPDATE OF release_date ON albums'); + expect(migration!.sql).toContain('WHEN (OLD.release_date IS DISTINCT FROM NEW.release_date)'); + }); +}); diff --git a/backend/src/db/migrations.ts b/backend/src/db/migrations.ts index 053af3a..f0a4bc9 100644 --- a/backend/src/db/migrations.ts +++ b/backend/src/db/migrations.ts @@ -510,4 +510,138 @@ export const MIGRATIONS: Migration[] = [ ON pending_file_deletions(requested_at); `, }, + { + // System E originally wrote graph_exploration claims without registering it + // in source_trust, so the FK rejected every graph walk. It also lacked a + // durable link from an acquired candidate to the track the scanner created. + id: '20260801_acquisition_pipeline', + sql: ` + INSERT INTO source_trust (key, trust, description) + VALUES ('graph_exploration', 0.40, + 'Muzick graph traversal provenance for discovery candidates.') + ON CONFLICT (key) DO NOTHING; + + ALTER TABLE discovery_candidates + ADD COLUMN IF NOT EXISTS acquired_track_id UUID + REFERENCES tracks(id) ON DELETE SET NULL; + ALTER TABLE discovery_candidates + ADD COLUMN IF NOT EXISTS acquired_at TIMESTAMPTZ; + ALTER TABLE discovery_candidates + ADD COLUMN IF NOT EXISTS acquisition_attempts INTEGER NOT NULL DEFAULT 0; + ALTER TABLE discovery_candidates + ADD COLUMN IF NOT EXISTS last_error TEXT; + CREATE INDEX IF NOT EXISTS idx_discovery_candidates_status + ON discovery_candidates (status, first_seen_at); + `, + }, + { + // Separate portrait fetching from structural metadata so an operator can + // re-enrich missing artist images without implicitly enabling MB writes. + id: '20260801_artist_image_enrichment_toggle', + sql: ` + INSERT INTO settings (key, value) + VALUES ('enrich_artist_images', 'false') + ON CONFLICT (key) DO NOTHING; + `, + }, + { + // Audio analysis v1 stored an arbitrary linear energy scale, which made + // nearly every mastered track look maximally energetic. Track the analysis + // contract and source hash so bounded background jobs can safely refresh + // stale results without repeatedly decoding unchanged files. + id: '20260801_audio_analysis_v2', + sql: ` + ALTER TABLE track_audio_features + ADD COLUMN IF NOT EXISTS analysis_version SMALLINT NOT NULL DEFAULT 0; + ALTER TABLE track_audio_features + ADD COLUMN IF NOT EXISTS source_hash TEXT; + ALTER TABLE track_audio_features + ADD COLUMN IF NOT EXISTS analyzed_at TIMESTAMPTZ; + + DO $mig$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'track_audio_features'::regclass + AND conname = 'track_audio_features_bpm_range' + ) THEN + ALTER TABLE track_audio_features + ADD CONSTRAINT track_audio_features_bpm_range + CHECK (bpm IS NULL OR (bpm >= 30 AND bpm <= 300)) NOT VALID; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'track_audio_features'::regclass + AND conname = 'track_audio_features_energy_range' + ) THEN + ALTER TABLE track_audio_features + ADD CONSTRAINT track_audio_features_energy_range + CHECK (energy IS NULL OR (energy >= 0 AND energy <= 1)) NOT VALID; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'track_audio_features'::regclass + AND conname = 'track_audio_features_danceability_range' + ) THEN + ALTER TABLE track_audio_features + ADD CONSTRAINT track_audio_features_danceability_range + CHECK (danceability IS NULL OR (danceability >= 0 AND danceability <= 1)) NOT VALID; + END IF; + END $mig$; + `, + }, + { + // tracks.release_date is a denormalised copy of the canonical + // MusicBrainz release-group date on albums. Before this migration the + // worker populated albums only, leaving every historical track invisible + // to the novelty generator. The backfill and triggers make the canonical + // album value win deterministically for both old and future rows. + id: '20260801_track_release_dates_from_albums', + sql: ` + -- The worker used to create this column lazily, but migration execution + -- must not rely on worker startup order. + ALTER TABLE albums ADD COLUMN IF NOT EXISTS release_date DATE; + + -- Backfill only mismatches. IS DISTINCT FROM is null-safe and makes the + -- statement safe to rerun manually for diagnostics/recovery. + UPDATE tracks AS t + SET release_date = al.release_date + FROM albums AS al + WHERE t.album_id = al.id + AND t.release_date IS DISTINCT FROM al.release_date; + + CREATE OR REPLACE FUNCTION sync_track_release_date_from_album() + RETURNS TRIGGER AS $mig$ + BEGIN + SELECT release_date INTO NEW.release_date + FROM albums + WHERE id = NEW.album_id; + RETURN NEW; + END; + $mig$ LANGUAGE plpgsql; + + DROP TRIGGER IF EXISTS trg_tracks_sync_release_date ON tracks; + CREATE TRIGGER trg_tracks_sync_release_date + BEFORE INSERT OR UPDATE OF album_id, release_date ON tracks + FOR EACH ROW EXECUTE FUNCTION sync_track_release_date_from_album(); + + CREATE OR REPLACE FUNCTION propagate_album_release_date_to_tracks() + RETURNS TRIGGER AS $mig$ + BEGIN + UPDATE tracks + SET release_date = NEW.release_date + WHERE album_id = NEW.id + AND release_date IS DISTINCT FROM NEW.release_date; + RETURN NEW; + END; + $mig$ LANGUAGE plpgsql; + + DROP TRIGGER IF EXISTS trg_albums_propagate_release_date ON albums; + CREATE TRIGGER trg_albums_propagate_release_date + AFTER UPDATE OF release_date ON albums + FOR EACH ROW + WHEN (OLD.release_date IS DISTINCT FROM NEW.release_date) + EXECUTE FUNCTION propagate_album_release_date_to_tracks(); + `, + }, ]; diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index c824d34..6ca5be5 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -130,6 +130,17 @@ CREATE TABLE IF NOT EXISTS tracks ( -- Add normalized columns as generated columns for existing databases where the -- CREATE TABLE IF NOT EXISTS above was a no-op (column didn't exist before). +-- `albums.release_date` was introduced after the original albums table. It +-- must exist before the track synchronisation trigger below is compiled. +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'albums' AND column_name = 'release_date' + ) THEN + ALTER TABLE albums ADD COLUMN release_date DATE; + END IF; +END $$; + DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns @@ -140,6 +151,49 @@ DO $$ BEGIN END $$; CREATE INDEX IF NOT EXISTS idx_tracks_release_date ON tracks (release_date) WHERE release_date IS NOT NULL; + +-- An album's MusicBrainz first-release-date is the canonical date for every +-- track on that album. Keep the denormalised tracks.release_date column in +-- lockstep so date-based recommendation queries stay indexable and never have +-- to guess which of two conflicting values is authoritative. +-- +-- The trigger intentionally also overwrites a direct tracks.release_date +-- update. There is no per-recording release-date provenance in this schema; +-- accepting an independent track value would silently make novelty results +-- depend on write order. A future per-recording metadata source needs its own +-- canonical/provenance column before changing this rule. +CREATE OR REPLACE FUNCTION sync_track_release_date_from_album() +RETURNS TRIGGER AS $$ +BEGIN + SELECT release_date INTO NEW.release_date + FROM albums + WHERE id = NEW.album_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_tracks_sync_release_date ON tracks; +CREATE TRIGGER trg_tracks_sync_release_date +BEFORE INSERT OR UPDATE OF album_id, release_date ON tracks +FOR EACH ROW EXECUTE FUNCTION sync_track_release_date_from_album(); + +CREATE OR REPLACE FUNCTION propagate_album_release_date_to_tracks() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE tracks + SET release_date = NEW.release_date + WHERE album_id = NEW.id + AND release_date IS DISTINCT FROM NEW.release_date; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_albums_propagate_release_date ON albums; +CREATE TRIGGER trg_albums_propagate_release_date +AFTER UPDATE OF release_date ON albums +FOR EACH ROW +WHEN (OLD.release_date IS DISTINCT FROM NEW.release_date) +EXECUTE FUNCTION propagate_album_release_date_to_tracks(); DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns @@ -306,12 +360,20 @@ CREATE TABLE IF NOT EXISTS track_audio_features ( key TEXT, energy REAL, danceability REAL, + -- Reserved legacy columns: Vibe readers tolerate these as NULL. The local + -- analyzer intentionally does not claim to infer them. valence REAL, acousticness REAL, instrumentalness REAL, liveness REAL, valence_score REAL, - tempo REAL + tempo REAL, + analysis_version SMALLINT NOT NULL DEFAULT 0, + source_hash TEXT, + analyzed_at TIMESTAMPTZ, + CONSTRAINT track_audio_features_bpm_range CHECK (bpm IS NULL OR (bpm >= 30 AND bpm <= 300)), + CONSTRAINT track_audio_features_energy_range CHECK (energy IS NULL OR (energy >= 0 AND energy <= 1)), + CONSTRAINT track_audio_features_danceability_range CHECK (danceability IS NULL OR (danceability >= 0 AND danceability <= 1)) ); CREATE TABLE IF NOT EXISTS track_lyrics ( @@ -331,6 +393,10 @@ CREATE TABLE IF NOT EXISTS settings ( ); INSERT INTO settings (key, value) VALUES ('enrich_metadata', 'true') ON CONFLICT (key) DO NOTHING; +-- Artist portraits are independent from structural metadata. Keeping this +-- separate lets an operator re-fill artwork without re-running MusicBrainz +-- canonicalisation across the entire library. +INSERT INTO settings (key, value) VALUES ('enrich_artist_images', 'true') ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('enrich_cover_art', 'true') ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('enrich_genres', 'true') ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('enrich_lyrics', 'true') ON CONFLICT (key) DO NOTHING; @@ -355,6 +421,10 @@ INSERT INTO source_trust (key, trust, description) VALUES ('cover_art_archive', 0.85, 'Cover Art Archive, MB-backed.'), ('discogs', 0.75, 'Discogs release/artist credits.'), ('lastfm', 0.50, 'Last.fm tags + similar. Noisy; used as weak signal.'), + -- A first-party record of how a candidate was reached through the graph. + -- This is deliberately separate from Last.fm/MB: it describes the + -- traversal strategy, not a claim made by an external provider. + ('graph_exploration', 0.40, 'Muzick graph traversal provenance for discovery candidates.'), ('listener_behavior', 0.40, 'Derived from observed play patterns. User-keyed.'), ('tag', 0.30, 'File-tag-derived via scanner heuristic. Lowest trust.') ON CONFLICT (key) DO NOTHING; @@ -487,9 +557,19 @@ CREATE TABLE IF NOT EXISTS discovery_candidates ( first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_eval_at TIMESTAMPTZ, status TEXT NOT NULL DEFAULT 'candidate', + -- Filled only after a worker scanned a successfully acquired file. Keeping + -- this FK makes candidate -> local-track provenance auditable and avoids + -- guessing from filename metadata later. + acquired_track_id UUID REFERENCES tracks(id) ON DELETE SET NULL, + acquired_at TIMESTAMPTZ, + acquisition_attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, UNIQUE (source, external_id) ); +CREATE INDEX IF NOT EXISTS idx_discovery_candidates_status + ON discovery_candidates (status, first_seen_at); + -- Probation status for acquired tracks. DO $$ BEGIN IF NOT EXISTS ( diff --git a/backend/src/db/types.ts b/backend/src/db/types.ts index e392baf..c45eda7 100644 --- a/backend/src/db/types.ts +++ b/backend/src/db/types.ts @@ -16,6 +16,8 @@ export interface Album { artist_id: string; title: string; year?: number | null; + /** Canonical MusicBrainz release-group first-release-date (YYYY-MM-DD). */ + release_date?: string | null; artwork_id?: string | null; } @@ -38,6 +40,8 @@ export interface Track { skip_count: number; dislike_count: number; last_played_at?: Date | null; + /** Denormalised from albums.release_date by a database trigger. */ + release_date?: string | null; mtime?: number | null; source_type: string; artists?: TrackArtist[]; @@ -136,6 +140,30 @@ export interface ListenerBelief { last_decayed_at: Date; } +/** + * Stable pseudo-entity IDs for audio preference buckets. listener_beliefs uses + * UUID entity IDs for every entity type, while audio dimensions are values rather + * than rows in their own table. Keeping the IDs fixed makes these beliefs usable + * by the planner without introducing a second, unbounded vocabulary. + */ +export const AUDIO_PREFERENCE_BUCKETS = { + energy: { + low: '10000000-0000-0000-0000-000000000001', + medium: '10000000-0000-0000-0000-000000000002', + high: '10000000-0000-0000-0000-000000000003', + }, + bpm: { + slow: '10000000-0000-0000-0000-000000000011', + medium: '10000000-0000-0000-0000-000000000012', + fast: '10000000-0000-0000-0000-000000000013', + }, + valence: { + low: '10000000-0000-0000-0000-000000000021', + neutral: '10000000-0000-0000-0000-000000000022', + high: '10000000-0000-0000-0000-000000000023', + }, +} as const; + export interface ClaimEdge { subjectType: string; subjectId: string; diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts index a3d7ab1..085076a 100644 --- a/backend/src/routes/admin.routes.ts +++ b/backend/src/routes/admin.routes.ts @@ -2,6 +2,87 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { JobService } from '../services/job.service.js'; import { DbService } from '../services/db.service.js'; +const ENRICHMENT_SETTING_KEYS = [ + 'enrich_metadata', + 'enrich_artist_images', + 'enrich_cover_art', + 'enrich_genres', + 'enrich_lyrics', + 'enrich_artist_similarity', + 'enrich_audio_analysis', +] as const; + +type ReenrichmentScope = { + metadata?: boolean; + artistImages?: boolean; + albumCovers?: boolean; +}; + +type ReenrichmentRequest = { + /** Preview by default. Jobs are only added with an explicit confirmation. */ + confirm?: boolean; + /** Missing-only is the safe default; false means refresh the selected scope. */ + missingOnly?: boolean; + /** Per-scope batch cap. Defaults to 250 and never exceeds 1,000. */ + limit?: number; + scope?: ReenrichmentScope; +}; + +function getReenrichmentOptions(body: ReenrichmentRequest = {}) { + const requestedScope = body.scope ?? {}; + const scope = { + metadata: requestedScope.metadata ?? true, + artistImages: requestedScope.artistImages ?? true, + albumCovers: requestedScope.albumCovers ?? true, + }; + const rawLimit = Number(body.limit ?? 250); + const limit = Number.isInteger(rawLimit) ? Math.max(1, Math.min(rawLimit, 1000)) : 250; + return { confirm: body.confirm === true, missingOnly: body.missingOnly !== false, limit, scope }; +} + +async function getReenrichmentStatus(dbService: DbService, jobService: JobService) { + const db = dbService.pgClient; + const [settingsRes, coverageRes, queue] = await Promise.all([ + db.query<{ key: string; value: string }>( + `SELECT key, value FROM settings WHERE key = ANY($1::text[])`, + [ENRICHMENT_SETTING_KEYS], + ), + db.query<{ + library_tracks: number; + tracks_without_release_date: number; + tracks_without_album_mbid: number; + artists_total: number; + artists_without_image: number; + artists_without_mbid: number; + albums_total: number; + albums_without_artwork: number; + albums_without_release_date: number; + }>(` + SELECT + COUNT(*) FILTER (WHERE t.state = 'LIBRARY')::int AS library_tracks, + COUNT(*) FILTER (WHERE t.state = 'LIBRARY' AND t.release_date IS NULL)::int AS tracks_without_release_date, + COUNT(*) FILTER (WHERE t.state = 'LIBRARY' AND al.mbid IS NULL)::int AS tracks_without_album_mbid, + (SELECT COUNT(*)::int FROM artists) AS artists_total, + (SELECT COUNT(*)::int FROM artists WHERE image_path IS NULL OR image_path = '') AS artists_without_image, + (SELECT COUNT(*)::int FROM artists WHERE mbid IS NULL) AS artists_without_mbid, + (SELECT COUNT(*)::int FROM albums) AS albums_total, + (SELECT COUNT(*)::int FROM albums WHERE artwork_id IS NULL OR artwork_id = '') AS albums_without_artwork, + (SELECT COUNT(*)::int FROM albums WHERE release_date IS NULL) AS albums_without_release_date + FROM tracks t + LEFT JOIN albums al ON al.id = t.album_id + `), + jobService.getEnrichmentQueueDiagnostics(), + ]); + const settings = Object.fromEntries( + ENRICHMENT_SETTING_KEYS.map((key) => [ + key, + settingsRes.rows.find((row) => row.key === key)?.value === 'true', + ]), + ) as Record<(typeof ENRICHMENT_SETTING_KEYS)[number], boolean>; + + return { settings, coverage: coverageRes.rows[0], queue }; +} + export default async function adminRoutes(fastify: FastifyInstance, options: { jobService: JobService; dbService: DbService }) { const { jobService, dbService } = options; @@ -24,6 +105,60 @@ export default async function adminRoutes(fastify: FastifyInstance, options: { j return { status: 'Artist reprocessing job enqueued' }; }); + /** Rebuild artist/genre/audio beliefs from durable history after a model upgrade. */ + fastify.post('/vibe/rebuild-beliefs', async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = (request.body ?? {}) as { userId?: string }; + const resolvedUserId = userId || (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const result = await dbService.rebuildDerivedListenerBeliefs(resolvedUserId); + return reply.send({ status: 'rebuilt', userId: resolvedUserId, ...result }); + }); + + /** + * Attach a human/resolver-vetted source to a System E candidate. This is an + * admin-only hand-off: graph traversal identifies an artist/path, but must + * never turn that into an arbitrary web search and download. The worker still + * applies its own host allow-list immediately before invoking yt-dlp. + */ + fastify.post('/discovery/candidates/:id/acquisition-source', async (request, reply) => { + const { id } = request.params as { id: string }; + const body = request.body as { url?: string; expectedTitle?: string; expectedArtist?: string }; + if (!body?.url || typeof body.url !== 'string' || body.url.length > 4000) { + return reply.code(400).send({ error: 'HTTPS url is required' }); + } + let url: URL; + try { + url = new URL(body.url); + } catch { + return reply.code(400).send({ error: 'url is invalid' }); + } + if (url.protocol !== 'https:' || url.username || url.password) { + return reply.code(400).send({ error: 'url must be credential-free HTTPS' }); + } + const source = { + url: url.toString(), + ...(typeof body.expectedTitle === 'string' ? { expectedTitle: body.expectedTitle.slice(0, 500) } : {}), + ...(typeof body.expectedArtist === 'string' ? { expectedArtist: body.expectedArtist.slice(0, 500) } : {}), + }; + try { + const result = await dbService.pgClient.query( + `UPDATE discovery_candidates + SET notes = jsonb_set(COALESCE(notes, '{}'::jsonb), '{acquisition}', $2::jsonb, true), + status = 'candidate', last_eval_at = NULL, last_error = NULL + WHERE id = $1::uuid + AND status IN ('candidate', 'awaiting_resolution', 'acquisition_disabled', 'failed') + RETURNING id, status`, + [id, JSON.stringify(source)] + ); + if (result.rows.length === 0) { + return reply.code(404).send({ error: 'candidate does not exist or cannot be re-queued' }); + } + return reply.send({ candidate: result.rows[0] }); + } catch (err) { + const message = err instanceof Error ? err.message : 'could not attach acquisition source'; + return reply.code(400).send({ error: message }); + } + }); + fastify.post('/dedup-albums', async (_request: FastifyRequest, reply: FastifyReply) => { // Merge duplicate album rows directly (synchronous — it's just SQL, no // external API calls). Returns the number of albums merged away. @@ -92,17 +227,132 @@ export default async function adminRoutes(fastify: FastifyInstance, options: { j return { status: 'Albums deduplicated', merged: res.rows[0]?.count ?? 0 }; }); + /** + * Return coverage, active toggles, queued work, and recent worker failures. + * This is deliberately separate from enqueueing so an operator can diagnose + * a disabled provider or a failing worker before launching another batch. + */ + fastify.get('/reenrichment/status', async () => { + return getReenrichmentStatus(dbService, jobService); + }); + + /** + * Safe re-enrichment control plane. A call is a preview unless confirm=true; + * the default is a 250-entity, missing-only batch. The three job types remain + * independent so artwork work is not hidden behind track metadata work. + */ + fastify.post<{ Body: ReenrichmentRequest }>('/reenrichment', async (request, reply) => { + const options = getReenrichmentOptions(request.body); + const status = await getReenrichmentStatus(dbService, jobService); + const blockedBySettings: Partial> = {}; + const metadataEnabled = status.settings.enrich_metadata + || status.settings.enrich_genres + || status.settings.enrich_lyrics; + if (options.scope.metadata && !metadataEnabled) { + blockedBySettings.metadata = 'All track enrichment toggles are disabled.'; + } + if (options.scope.artistImages && !status.settings.enrich_artist_images) { + blockedBySettings.artistImages = 'enrich_artist_images is disabled.'; + } + if (options.scope.albumCovers && !status.settings.enrich_cover_art) { + blockedBySettings.albumCovers = 'enrich_cover_art is disabled.'; + } + + const db = dbService.pgClient; + const [tracks, artists, albums] = await Promise.all([ + options.scope.metadata && !blockedBySettings.metadata + ? db.query<{ id: string }>( + `SELECT t.id + FROM tracks t + LEFT JOIN albums al ON al.id = t.album_id + LEFT JOIN track_artists ta ON ta.track_id = t.id AND ta.role = 'main' + LEFT JOIN artists ar ON ar.id = ta.artist_id + WHERE t.state = 'LIBRARY' + AND ($1::boolean = false OR t.release_date IS NULL OR al.mbid IS NULL OR ar.mbid IS NULL) + ORDER BY t.id + LIMIT $2`, + [options.missingOnly, options.limit], + ) + : Promise.resolve({ rows: [] as Array<{ id: string }> }), + options.scope.artistImages && !blockedBySettings.artistImages + ? db.query<{ id: string }>( + `SELECT id FROM artists + WHERE $1::boolean = false OR image_path IS NULL OR image_path = '' + ORDER BY id + LIMIT $2`, + [options.missingOnly, options.limit], + ) + : Promise.resolve({ rows: [] as Array<{ id: string }> }), + options.scope.albumCovers && !blockedBySettings.albumCovers + ? db.query<{ id: string }>( + `SELECT id FROM albums + WHERE $1::boolean = false OR artwork_id IS NULL OR artwork_id = '' + ORDER BY id + LIMIT $2`, + [options.missingOnly, options.limit], + ) + : Promise.resolve({ rows: [] as Array<{ id: string }> }), + ]); + + const plan = { + metadataTrackIds: tracks.rows.map((row) => row.id), + artistIds: artists.rows.map((row) => row.id), + albumIds: albums.rows.map((row) => row.id), + }; + const wouldQueue = { + metadata: plan.metadataTrackIds.length, + artistImages: plan.artistIds.length, + albumCovers: plan.albumIds.length, + }; + + if (!options.confirm) { + return { + status: 'preview', + message: 'No jobs were queued. Repeat with confirm=true to enqueue this bounded plan.', + options, + blockedBySettings, + wouldQueue, + diagnostics: status, + }; + } + + if (Object.keys(blockedBySettings).length === 3 || + (wouldQueue.metadata + wouldQueue.artistImages + wouldQueue.albumCovers === 0)) { + return reply.code(409).send({ + status: 'not_queued', + message: Object.keys(blockedBySettings).length === 3 + ? 'Every selected scope is disabled by enrichment settings.' + : 'No eligible entities matched this re-enrichment batch.', + options, + blockedBySettings, + wouldQueue, + diagnostics: status, + }); + } + + const queued = await jobService.enqueueReenrichment(plan); + const enqueueFailures = queued.metadata.failed.length + + queued.artistImages.failed.length + + queued.albumCovers.failed.length; + return { + status: enqueueFailures === 0 ? 'queued' : 'partially_queued', + message: enqueueFailures === 0 + ? 'Re-enrichment jobs were queued. Check /reenrichment/status and job history for outcomes.' + : 'Some jobs could not be queued; see queued.*.failed for exact errors.', + options, + blockedBySettings, + queued, + diagnostics: await getReenrichmentStatus(dbService, jobService), + }; + }); + + // The prior endpoint queued every track and implied cover/image work that it + // never scheduled. Keep the failure explicit instead of silently claiming a + // successful library-wide refresh. fastify.post('/reenrich-tracks', async (_request: FastifyRequest, reply: FastifyReply) => { - // Re-enqueue metadata_refresh for every LIBRARY track without re-reading - // files from disk. This re-runs the MusicBrainz canonicalisation (artist - // names, album titles, MBIDs) and re-triggers album_cover jobs — much - // faster than a full scan when only metadata needs refreshing. - const res = await dbService.pgClient.query<{ id: string }>( - `SELECT id FROM tracks WHERE state = 'LIBRARY' ORDER BY id` - ); - const trackIds = res.rows.map((r) => r.id); - const enqueued = await jobService.enqueueMetadataRefreshBatch(trackIds); - return { status: 'Re-enrich enqueued', trackCount: enqueued }; + return reply.code(410).send({ + error: 'Deprecated endpoint. Use POST /admin/reenrichment (preview first; confirm=true to queue).', + }); }); fastify.get('/queue-stats', async () => { diff --git a/backend/src/routes/discovery.routes.ts b/backend/src/routes/discovery.routes.ts index 2b82f19..4f35200 100644 --- a/backend/src/routes/discovery.routes.ts +++ b/backend/src/routes/discovery.routes.ts @@ -2,9 +2,11 @@ import { FastifyInstance } from 'fastify'; import { DbService } from '../services/db.service.js'; import { DiscoveryService } from '../services/discovery.service.js'; import { ImageEnrichmentService } from '../services/image-enrichment.service.js'; +import { JobService } from '../services/job.service.js'; -export default async function discoveryRoutes(fastify: FastifyInstance, options: { dbService: DbService }) { +export default async function discoveryRoutes(fastify: FastifyInstance, options: { dbService: DbService; jobService: JobService }) { const { dbService } = options; + const { jobService } = options; const discovery = new DiscoveryService(dbService); const images = new ImageEnrichmentService(dbService); @@ -39,6 +41,17 @@ export default async function discoveryRoutes(fastify: FastifyInstance, options: fastify.post('/discovery/eval', async (request, reply) => { const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; const results = await discovery.evalCandidates(userId); + for (const result of results) { + if (!result.shouldAcquire) continue; + try { + await jobService.enqueueDiscoveryAcquisition(result.candidateId); + } catch (err) { + const reason = err instanceof Error ? err.message : 'failed to enqueue acquisition'; + await discovery.markEnqueueFailed(result.candidateId, reason); + result.shouldAcquire = false; + result.reason = `queue unavailable: ${reason}`; + } + } return reply.send({ evaluated: results.length, results }); }); diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts index f62ce7b..862325a 100644 --- a/backend/src/routes/settings.routes.ts +++ b/backend/src/routes/settings.routes.ts @@ -3,6 +3,7 @@ import { DbService } from '../services/db.service.js'; const SETTING_KEYS = [ 'enrich_metadata', + 'enrich_artist_images', 'enrich_cover_art', 'enrich_genres', 'enrich_lyrics', diff --git a/backend/src/routes/v2.routes.ts b/backend/src/routes/v2.routes.ts index 0afee1c..7fbb04d 100644 --- a/backend/src/routes/v2.routes.ts +++ b/backend/src/routes/v2.routes.ts @@ -9,14 +9,42 @@ interface ActivePlan { sessionId: string; plan: Candidate[]; seedTrackId: string | null; + /** Tracks handed to the player during this Redis-backed session. */ + servedTrackIds?: string[]; + /** Explicit feedback targets (skip, dislike, completion, promotion). */ + excludedTrackIds?: string[]; + /** Main artists served or explicitly rejected in this session. */ + excludedArtistIds?: string[]; } const PLAN_TTL_SEC = 2 * 3600; +// Retain enough history for long listening sessions without allowing an +// unbounded Redis value if a client leaves a session running for days. +const MAX_SESSION_EXCLUSIONS = 1000; function planKey(userId: string): string { return `v2:plan:${userId}`; } +function appendUniqueTrackId(ids: string[] | undefined, trackId: string): string[] { + const next = ids ? [...ids] : []; + if (!next.includes(trackId)) next.push(trackId); + return next.length > MAX_SESSION_EXCLUSIONS + ? next.slice(next.length - MAX_SESSION_EXCLUSIONS) + : next; +} + +function sessionExclusions(active: ActivePlan): string[] { + return [...new Set([ + ...(active.servedTrackIds ?? []), + ...(active.excludedTrackIds ?? []), + ])]; +} + +function sessionArtistExclusions(active: ActivePlan): string[] { + return [...new Set(active.excludedArtistIds ?? [])]; +} + export default async function v2Routes(fastify: FastifyInstance, options: { dbService: DbService; sessionDirector: SessionDirector }) { const { dbService, sessionDirector: director } = options; @@ -47,6 +75,7 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe // span multiple keys/services atomically, swap this for a WATCH/MULTI transaction // or move the plan into a single Lua script instead of an app-level lock. const RELEASE_LOCK_LUA = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`; + const RENEW_LOCK_LUA = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("pexpire", KEYS[1], ARGV[2]) else return 0 end`; async function withPlanLock(userId: string, fn: () => Promise): Promise { const lockKey = `v2:planlock:${userId}`; @@ -54,16 +83,20 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe const deadline = Date.now() + 5000; let acquired = false; while (Date.now() < deadline) { - const res = await redisClient.set(lockKey, token, { NX: true, PX: 3000 }); + const res = await redisClient.set(lockKey, token, { NX: true, PX: 5000 }); if (res) { acquired = true; break; } await new Promise((r) => setTimeout(r, 20 + Math.random() * 30)); } if (!acquired) { throw new Error('Timed out waiting for active-plan lock'); } + const renewal = setInterval(() => { + void redisClient.eval(RENEW_LOCK_LUA, { keys: [lockKey], arguments: [token, '5000'] }); + }, 1500); try { return await fn(); } finally { + clearInterval(renewal); await redisClient.eval(RELEASE_LOCK_LUA, { keys: [lockKey], arguments: [token] }); } } @@ -78,9 +111,18 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe const { seedTrackId } = request.body as { seedTrackId?: string }; const sessionId = await dbService.createSessionState(userId, undefined, { energy: 0.5, novelty_hunger: 0.3 }); - const plan = await director.buildPlan(userId, sessionId, seedTrackId); + const initialExclusions = seedTrackId ? [seedTrackId] : []; + const plan = await director.buildPlan(userId, sessionId, seedTrackId, { + excludedTrackIds: initialExclusions, + }); - await setActivePlan(userId, { sessionId, plan, seedTrackId: seedTrackId ?? null }); + await setActivePlan(userId, { + sessionId, + plan, + seedTrackId: seedTrackId ?? null, + servedTrackIds: [], + excludedTrackIds: initialExclusions, + }); return reply.send({ sessionId, plan: plan.slice(0, 10) }); }); @@ -90,34 +132,71 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe */ fastify.get('/v2/vibe/next', async (request, reply) => { const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const { sessionId } = request.query as { sessionId?: string }; const result = await withPlanLock(userId, async () => { const active = await getActivePlan(userId); - if (!active || active.plan.length === 0) { - return null; + if (!active) { + return { kind: 'missing' as const }; + } + if (!sessionId || active.sessionId !== sessionId) { + return { kind: 'replaced' as const }; + } + if (active.plan.length === 0) { + return { kind: 'exhausted' as const }; } const next = active.plan.shift()!; + active.servedTrackIds = appendUniqueTrackId(active.servedTrackIds, next.trackId); + const artistResult = await dbService.pgClient.query<{ artist_id: string }>( + `SELECT artist_id FROM track_artists_v2 + WHERE track_id = $1 AND role = 'main' + ORDER BY confidence DESC NULLS LAST LIMIT 1`, + [next.trackId] + ); + if (artistResult.rows[0]?.artist_id) { + active.excludedArtistIds = appendUniqueTrackId(active.excludedArtistIds, artistResult.rows[0].artist_id); + } // Enrich with track details const track = await dbService.getTrackById(next.trackId); // Replan if running low if (active.plan.length < 5) { - const refill = await director.replan(userId, active.sessionId, active.plan, [next.trackId], active.seedTrackId ?? undefined); + const refill = await director.replan( + userId, + active.sessionId, + active.plan, + [next.trackId], + active.seedTrackId ?? undefined, + { excludedTrackIds: sessionExclusions(active), excludedArtistIds: sessionArtistExclusions(active) } + ); active.plan = refill; } await setActivePlan(userId, active); - return { track, explanation: next.explanation, planRemaining: active.plan.length }; + return { kind: 'track' as const, track, explanation: next.explanation, planRemaining: active.plan.length }; }); - if (!result) { + if (result.kind === 'missing') { return reply.code(404).send({ error: 'No active plan. POST /api/v2/vibe/start first.' }); } + if (result.kind === 'replaced') { + return reply.code(409).send({ error: 'This Vibe session was replaced by a newer session.', code: 'VIBE_SESSION_REPLACED' }); + } + if (result.kind === 'exhausted') { + return reply.code(409).send({ + error: 'Vibe plan exhausted: no eligible unserved tracks remain for this session.', + code: 'VIBE_PLAN_EXHAUSTED', + }); + } - return reply.send(result); + return reply.send({ + track: result.track, + explanation: result.explanation, + planRemaining: result.planRemaining, + }); }); /** @@ -126,11 +205,14 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe */ fastify.post('/v2/vibe/feedback', async (request, reply) => { const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; - const { trackId, action } = request.body as { trackId: string; action: string }; + const { trackId, action, sessionId } = request.body as { trackId: string; action: string; sessionId?: string }; if (!trackId || !action) { return reply.code(400).send({ error: 'trackId and action are required' }); } + if (!['completed', 'skipped', 'promoted', 'disliked'].includes(action)) { + return reply.code(400).send({ error: 'Unsupported Vibe feedback action' }); + } // Route to existing handlers for evidence wiring if (action === 'completed') { @@ -148,8 +230,28 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe const planRemaining = await withPlanLock(userId, async () => { const active = await getActivePlan(userId); if (!active) return 0; - const playedTrackIds = [trackId]; - const refill = await director.replan(userId, active.sessionId, active.plan, playedTrackIds, active.seedTrackId ?? undefined); + if (!sessionId || active.sessionId !== sessionId) return 0; + // Feedback may race /next or arrive after a client-side prefetch. In all + // cases its track becomes ineligible for the rest of this session. + active.excludedTrackIds = appendUniqueTrackId(active.excludedTrackIds, trackId); + const artistResult = await dbService.pgClient.query<{ artist_id: string }>( + `SELECT artist_id FROM track_artists_v2 + WHERE track_id = $1 AND role = 'main' + ORDER BY confidence DESC NULLS LAST LIMIT 1`, + [trackId] + ); + if (artistResult.rows[0]?.artist_id) { + active.excludedArtistIds = appendUniqueTrackId(active.excludedArtistIds, artistResult.rows[0].artist_id); + } + const sessionTrackIds = sessionExclusions(active); + const refill = await director.replan( + userId, + active.sessionId, + active.plan, + [trackId], + active.seedTrackId ?? undefined, + { excludedTrackIds: sessionTrackIds, excludedArtistIds: sessionArtistExclusions(active) } + ); active.plan = refill; await setActivePlan(userId, active); return active.plan.length; diff --git a/backend/src/services/db.service.test.ts b/backend/src/services/db.service.test.ts index 5ce99ad..1eff997 100644 --- a/backend/src/services/db.service.test.ts +++ b/backend/src/services/db.service.test.ts @@ -8,6 +8,22 @@ function makeService(): { service: DbService; mockQuery: ReturnType { + describe('createAlbum', () => { + it('persists the canonical release date instead of discarding it', async () => { + const { service, mockQuery } = makeService(); + mockQuery.mockResolvedValue({ rows: [{ id: 'album-1' }] }); + + await service.createAlbum({ + id: 'album-1', artist_id: 'artist-1', title: 'Album', + year: 2026, release_date: '2026-07-15', artwork_id: null, + }); + + const [sql, params] = mockQuery.mock.calls[0]; + expect(sql).toContain('release_date'); + expect(params[3]).toBe('2026-07-15'); + }); + }); + describe('upsertClaim', () => { it('calls INSERT ... ON CONFLICT with correct parameters', async () => { const { service, mockQuery } = makeService(); @@ -168,6 +184,75 @@ describe('DbService v2 methods', () => { }); }); + describe('track evidence propagation', () => { + it('projects a favourite onto artist, genre, and present audio dimensions', async () => { + const { service, mockQuery } = makeService(); + let evidenceNumber = 0; + mockQuery.mockImplementation((sql: string) => { + if (sql.includes('INSERT INTO evidence')) return Promise.resolve({ rows: [{ id: `ev-${++evidenceNumber}` }] }); + if (sql.includes('WITH artist_ids')) { + return Promise.resolve({ rows: [ + { entity_type: 'artist', entity_id: 'artist-1' }, + { entity_type: 'genre', entity_id: 'genre-1' }, + ] }); + } + if (sql.includes('SELECT energy, bpm, valence')) { + return Promise.resolve({ rows: [{ energy: 0.81, bpm: 128, valence: 0.22 }] }); + } + return Promise.resolve({ rowCount: 1, rows: [] }); + }); + + await service.recordTrackEvidence({ + user_id: 'user-1', track_id: 'track-1', signal: 'add_to_favorites', + profile: 'longterm', weight: 0.60, + }); + + const evidenceWrites = mockQuery.mock.calls + .filter(([sql]) => (sql as string).includes('INSERT INTO evidence')) + .map(([, params]) => params as unknown[]); + expect(evidenceWrites.map(params => params[1])).toEqual([ + 'track', 'artist', 'genre', 'audio', 'audio', 'audio', + ]); + + const beliefWrites = mockQuery.mock.calls + .filter(([sql]) => (sql as string).includes('INSERT INTO listener_beliefs')) + .map(([, params]) => params as unknown[]); + const artistBelief = beliefWrites.find(params => params[2] === 'artist'); + const genreBelief = beliefWrites.find(params => params[2] === 'genre'); + expect(artistBelief?.[3]).toBe('artist-1'); + expect(artistBelief?.[5]).toBeCloseTo(0.54); // one favourite is a usable comfort signal + expect(genreBelief?.[5]).toBeCloseTo(0.27); + expect(beliefWrites.filter(params => params[2] === 'audio')).toHaveLength(3); + }); + + it('rebuilds shared beliefs from local completed plays and feedback without appending evidence', async () => { + const clientQuery = vi.fn((sql: string) => { + if (sql === 'BEGIN' || sql === 'COMMIT' || sql === 'ROLLBACK') return Promise.resolve({ rows: [] }); + if (sql.includes('DELETE FROM listener_beliefs')) return Promise.resolve({ rowCount: 0, rows: [] }); + if (sql.includes('FROM play_history ph')) { + return Promise.resolve({ rows: [{ + track_id: 'track-1', signal: 'playback_completed', profile: 'longterm', weight: 0.10, + }] }); + } + if (sql.includes('WITH artist_ids')) return Promise.resolve({ rows: [{ entity_type: 'artist', entity_id: 'artist-1' }] }); + if (sql.includes('SELECT energy, bpm, valence')) return Promise.resolve({ rows: [] }); + return Promise.resolve({ rowCount: 1, rows: [] }); + }); + const connect = vi.fn().mockResolvedValue({ query: clientQuery, release: vi.fn() }); + const service = new DbService({ query: vi.fn(), connect } as any); + + const result = await service.rebuildDerivedListenerBeliefs('user-1'); + + expect(result).toEqual({ interactions: 1, beliefs: 1 }); + expect(clientQuery.mock.calls.some(([sql]) => (sql as string).includes('FROM feedback f'))).toBe(true); + expect(clientQuery.mock.calls.some(([sql]) => (sql as string).includes('INSERT INTO evidence'))).toBe(false); + const beliefCall = (clientQuery.mock.calls as unknown as Array<[string, unknown[]]>). + find(([sql]) => sql.includes('INSERT INTO listener_beliefs')); + const beliefParams = beliefCall?.[1] ?? []; + expect(beliefParams.slice(1, 5)).toEqual(['longterm', 'artist', 'artist-1', 'affinity']); + }); + }); + describe('getFusedTrackArtists', () => { it('reads from claim_fusion view', async () => { const { service, mockQuery } = makeService(); diff --git a/backend/src/services/db.service.ts b/backend/src/services/db.service.ts index e3bd2d6..e987ce0 100644 --- a/backend/src/services/db.service.ts +++ b/backend/src/services/db.service.ts @@ -34,7 +34,7 @@ import type { DiversityBudget, RepetitionRule, } from '../db/types.js'; -import { FEEDBACK_ACTIONS } from '../db/types.js'; +import { AUDIO_PREFERENCE_BUCKETS, FEEDBACK_ACTIONS } from '../db/types.js'; export * from '../db/types.js'; export class DbService { @@ -372,11 +372,11 @@ export class DbService { ); }); - // Write evidence: hidden → negative profile (only on success) - await this.recordEvidence({ + // Write evidence: hidden → negative profile (only on success), then carry + // that signal through the track's artist/genre/audio identities. + await this.recordTrackEvidence({ user_id: userId, - entity_type: 'track', - entity_id: trackId, + track_id: trackId, signal: 'hidden', profile: 'negative', weight: -0.60, @@ -462,10 +462,9 @@ export class DbService { ); // 3. Write evidence: playback_completed → longterm affinity - await this.recordEvidence({ + await this.recordTrackEvidence({ user_id: userId, - entity_type: 'track', - entity_id: trackId, + track_id: trackId, signal: 'playback_completed', profile: 'longterm', weight: 0.10, @@ -480,18 +479,16 @@ export class DbService { [userId, trackId] ); if ((recentPlays.rows[0]?.cnt as number) > 1) { - await this.recordEvidence({ + await this.recordTrackEvidence({ user_id: userId, - entity_type: 'track', - entity_id: trackId, + track_id: trackId, signal: 'replay_within_24h', profile: 'longterm', weight: 0.25, }, client); - await this.recordEvidence({ + await this.recordTrackEvidence({ user_id: userId, - entity_type: 'track', - entity_id: trackId, + track_id: trackId, signal: 'replay_within_24h', profile: 'obsession', weight: 0.40, @@ -591,10 +588,9 @@ export class DbService { [userId, trackId] ); // Write evidence: skip_quick → negative profile - await this.recordEvidence({ + await this.recordTrackEvidence({ user_id: userId, - entity_type: 'track', - entity_id: trackId, + track_id: trackId, signal: 'skip_quick', profile: 'negative', weight: -0.20, @@ -613,19 +609,17 @@ export class DbService { // Also write evidence for promoted/disliked signals if (action === 'promoted') { - await this.recordEvidence({ + await this.recordTrackEvidence({ user_id: userId, - entity_type: 'track', - entity_id: trackId, + track_id: trackId, signal: 'add_to_favorites', profile: 'longterm', weight: 0.60, }); } else if (action === 'disliked') { - await this.recordEvidence({ + await this.recordTrackEvidence({ user_id: userId, - entity_type: 'track', - entity_id: trackId, + track_id: trackId, signal: 'hidden', profile: 'negative', weight: -0.60, @@ -688,8 +682,9 @@ export class DbService { async createAlbum(data: Album): Promise { const res = await this.pgClient.query( - 'INSERT INTO albums (artist_id, title, year, artwork_id) VALUES ($1, $2, $3, $4) RETURNING *', - [data.artist_id, data.title, data.year, data.artwork_id] + `INSERT INTO albums (artist_id, title, year, release_date, artwork_id) + VALUES ($1, $2, $3, $4::date, $5) RETURNING *`, + [data.artist_id, data.title, data.year, data.release_date ?? null, data.artwork_id] ); return res.rows[0]; } @@ -1102,6 +1097,123 @@ export class DbService { // v2 — System B: Listener Model // ========================================================================= + private beliefDimensionForSignal(signal: string): string { + return signal === 'play_of_never_seen' ? 'novelty_tolerance' : 'affinity'; + } + + /** + * Resolve the durable identities represented by a track. Artist credits use + * the fusion-backed view (with the legacy table as a fallback during an + * enrichment transition); genres and audio features are direct metadata. + */ + private async getTrackBeliefTargets(trackId: string, client?: Queryable): Promise; + }>> { + const queryable = client ?? this.pgClient; + const targets: Array<{ + entity_type: 'artist' | 'genre' | 'audio'; + entity_id: string; + factor: number; + context: Record; + }> = []; + + const identities = await queryable.query( + `WITH artist_ids AS ( + SELECT artist_id FROM track_artists_v2 WHERE track_id = $1 + UNION + SELECT artist_id FROM track_artists WHERE track_id = $1 + ) + SELECT 'artist' AS entity_type, artist_id AS entity_id FROM artist_ids + UNION ALL + SELECT 'genre' AS entity_type, genre_id AS entity_id + FROM track_genre WHERE track_id = $1`, + [trackId] + ); + for (const row of identities.rows as Array<{ entity_type: 'artist' | 'genre'; entity_id: string }>) { + targets.push({ + entity_type: row.entity_type, + entity_id: row.entity_id, + // An explicit favourite should be enough to form a usable comfort + // artist belief; ordinary completed plays still accumulate gradually. + factor: row.entity_type === 'artist' ? 0.90 : 0.45, + context: { source_track_id: trackId, association: row.entity_type }, + }); + } + + const featuresRes = await queryable.query( + `SELECT energy, bpm, valence + FROM track_audio_features + WHERE track_id = $1`, + [trackId] + ); + const features = featuresRes.rows[0] as { energy?: number | null; bpm?: number | null; valence?: number | null } | undefined; + if (!features) return targets; + + const addAudioBucket = (dimension: 'energy' | 'bpm' | 'valence', bucket: string) => { + targets.push({ + entity_type: 'audio', + entity_id: bucket, + factor: 0.30, + context: { source_track_id: trackId, association: 'audio', dimension }, + }); + }; + if (typeof features.energy === 'number' && Number.isFinite(features.energy)) { + addAudioBucket('energy', features.energy < 0.34 + ? AUDIO_PREFERENCE_BUCKETS.energy.low + : features.energy < 0.67 ? AUDIO_PREFERENCE_BUCKETS.energy.medium : AUDIO_PREFERENCE_BUCKETS.energy.high); + } + if (typeof features.bpm === 'number' && Number.isFinite(features.bpm) && features.bpm > 0) { + addAudioBucket('bpm', features.bpm < 90 + ? AUDIO_PREFERENCE_BUCKETS.bpm.slow + : features.bpm <= 140 ? AUDIO_PREFERENCE_BUCKETS.bpm.medium : AUDIO_PREFERENCE_BUCKETS.bpm.fast); + } + if (typeof features.valence === 'number' && Number.isFinite(features.valence)) { + addAudioBucket('valence', features.valence < 0.34 + ? AUDIO_PREFERENCE_BUCKETS.valence.low + : features.valence < 0.67 ? AUDIO_PREFERENCE_BUCKETS.valence.neutral : AUDIO_PREFERENCE_BUCKETS.valence.high); + } + return targets; + } + + /** + * Append the track-level event, then project it onto the track's meaningful + * shared identities. The original event remains the canonical audit record; + * projected evidence makes artist/genre/audio affinity directly queryable by + * Vibe generators. Callers pass their transaction client to keep the event + * and every derived belief atomic. + */ + async recordTrackEvidence(evidence: { + user_id: string; + track_id: string; + signal: string; + profile: string; + weight: number; + context?: Record; + }, client?: Queryable): Promise { + const { track_id: trackId, ...event } = evidence; + const id = await this.recordEvidence({ + ...event, + entity_type: 'track', + entity_id: trackId, + }, client); + const targets = await this.getTrackBeliefTargets(trackId, client); + for (const target of targets) { + await this.recordEvidence({ + user_id: event.user_id, + entity_type: target.entity_type, + entity_id: target.entity_id, + signal: event.signal, + profile: event.profile, + weight: event.weight * target.factor, + context: { ...event.context, ...target.context }, + }, client); + } + return id; + } + /** * Record evidence (append-only). Writes a signal into the evidence stream. */ @@ -1134,7 +1246,7 @@ export class DbService { // which feeds 'novelty_tolerance'. Each new evidence row must also // upsert the matching listener_belief (spec §B.4) — otherwise evidence // accumulates but beliefs never materialise. - const dimension = evidence.signal === 'play_of_never_seen' ? 'novelty_tolerance' : 'affinity'; + const dimension = this.beliefDimensionForSignal(evidence.signal); await this.updateListenerBelief({ user_id: evidence.user_id, profile: evidence.profile, @@ -1163,6 +1275,119 @@ export class DbService { }); } + /** + * Rebuild only the derived shared-preference layer from durable local + * interaction history. This intentionally does not append new evidence (the + * evidence log is an audit stream) and does not replace track beliefs. It is + * safe to run repeatedly after deploying propagation or after enrichment has + * added artist/genre/audio metadata to old tracks. + */ + async rebuildDerivedListenerBeliefs(userId: string): Promise<{ interactions: number; beliefs: number }> { + return this.withTransaction(async (client) => { + await client.query( + `DELETE FROM listener_beliefs + WHERE user_id = $1 AND entity_type IN ('artist', 'genre', 'audio')`, + [userId] + ); + + const interactionRes = await client.query( + `SELECT track_id, signal, profile, weight + FROM ( + SELECT ph.track_id, ph.played_at AS occurred_at, + 'playback_completed'::text AS signal, + 'longterm'::text AS profile, + 0.10::real AS weight + FROM play_history ph + WHERE ph.user_id = $1 AND ph.completed = true + + UNION ALL + + SELECT replay.track_id, replay.played_at AS occurred_at, + 'replay_within_24h'::text AS signal, + 'longterm'::text AS profile, + 0.25::real AS weight + FROM ( + SELECT track_id, played_at, + COUNT(*) OVER ( + PARTITION BY track_id ORDER BY played_at + RANGE BETWEEN INTERVAL '24 hours' PRECEDING AND CURRENT ROW + ) AS recent_plays + FROM play_history WHERE user_id = $1 AND completed = true + ) replay + WHERE replay.recent_plays > 1 + + UNION ALL + + SELECT replay.track_id, replay.played_at AS occurred_at, + 'replay_within_24h'::text AS signal, + 'obsession'::text AS profile, + 0.40::real AS weight + FROM ( + SELECT track_id, played_at, + COUNT(*) OVER ( + PARTITION BY track_id ORDER BY played_at + RANGE BETWEEN INTERVAL '24 hours' PRECEDING AND CURRENT ROW + ) AS recent_plays + FROM play_history WHERE user_id = $1 AND completed = true + ) replay + WHERE replay.recent_plays > 1 + + UNION ALL + + SELECT fav.track_id, fav.created_at AS occurred_at, + 'add_to_favorites'::text AS signal, + 'longterm'::text AS profile, + 0.60::real AS weight + FROM favorites fav WHERE fav.user_id = $1 + + UNION ALL + + SELECT f.track_id, f.created_at AS occurred_at, + CASE f.action + WHEN 'promoted' THEN 'add_to_favorites' + WHEN 'disliked' THEN 'hidden' + WHEN 'skipped' THEN 'skip_quick' + END AS signal, + CASE WHEN f.action = 'promoted' THEN 'longterm' ELSE 'negative' END AS profile, + CASE f.action + WHEN 'promoted' THEN 0.60::real + WHEN 'disliked' THEN -0.60::real + WHEN 'skipped' THEN -0.20::real + END AS weight + FROM feedback f + WHERE f.user_id = $1 + AND f.track_id IS NOT NULL + AND f.action IN ('promoted', 'disliked', 'skipped') + ) interactions + ORDER BY occurred_at ASC`, + [userId] + ); + + let beliefs = 0; + for (const interaction of interactionRes.rows as Array<{ + track_id: string; + signal: string; + profile: string; + weight: number; + }>) { + const targets = await this.getTrackBeliefTargets(interaction.track_id, client); + for (const target of targets) { + await this.updateListenerBelief({ + user_id: userId, + profile: interaction.profile, + entity_type: target.entity_type, + entity_id: target.entity_id, + dimension: this.beliefDimensionForSignal(interaction.signal), + value_delta: interaction.weight * target.factor, + confidence_delta: 0.05, + }, client); + beliefs++; + } + } + return { interactions: interactionRes.rows.length, beliefs }; + }); + } + /** * Get listener beliefs for a user, optionally filtered by profile/entity. */ @@ -1395,4 +1620,3 @@ export class DbService { return res.rowCount ?? 0; } } - diff --git a/backend/src/services/discovery.service.test.ts b/backend/src/services/discovery.service.test.ts new file mode 100644 index 0000000..9a0c273 --- /dev/null +++ b/backend/src/services/discovery.service.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; +import { DiscoveryService } from './discovery.service.js'; +import type { DbService } from './db.service.js'; + +function makeDb() { + const query = vi.fn(); + return { + pgClient: { query }, + getListenerBeliefs: vi.fn(), + upsertClaim: vi.fn().mockResolvedValue('claim-id'), + } as unknown as DbService; +} + +describe('DiscoveryService System E provenance', () => { + it('writes graph-walk claims against discovery_candidate using registered graph provenance', async () => { + const db = makeDb(); + (db.getListenerBeliefs as any).mockResolvedValue([ + { entity_id: '11111111-1111-1111-1111-111111111111', value: 0.8 }, + ]); + (db.pgClient.query as any) + .mockResolvedValueOnce({ rows: [{ candidate_artist_id: '22222222-2222-2222-2222-222222222222' }] }) + .mockResolvedValueOnce({ rows: [{ id: '33333333-3333-3333-3333-333333333333' }] }); + + await new DiscoveryService(db).walkGraphForDiscovery('00000000-0000-0000-0000-000000000000'); + + expect(db.upsertClaim).toHaveBeenCalledWith(expect.objectContaining({ + subject_type: 'discovery_candidate', + source: 'graph_exploration', + })); + }); + + it('does not queue artist-only graph candidates without a vetted acquisition URL', async () => { + const db = makeDb(); + (db.pgClient.query as any) + .mockResolvedValueOnce({ rows: [{ id: 'candidate-1', notes: { discovery_source: 'graph_exploration' } }] }) + .mockResolvedValueOnce({ rows: [{ cnt: 0 }] }) + .mockResolvedValueOnce({ rows: [{ object_id: 'artist-1', fused_value: 0.7 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const result = await new DiscoveryService(db).evalCandidates('00000000-0000-0000-0000-000000000000'); + + expect(result).toEqual([{ + candidateId: 'candidate-1', shouldAcquire: false, reason: 'awaiting resolved acquisition source', + }]); + expect((db.pgClient.query as any).mock.calls.at(-1)[0]).toContain("status = 'awaiting_resolution'"); + }); +}); diff --git a/backend/src/services/discovery.service.ts b/backend/src/services/discovery.service.ts index 2e87009..dbe40a7 100644 --- a/backend/src/services/discovery.service.ts +++ b/backend/src/services/discovery.service.ts @@ -83,12 +83,17 @@ export class DiscoveryService { const dcId = dcRes.rows[0].id; const relevance = Math.min(belief.value, 0.8); + // A candidate is not a track. Claims intentionally support generic + // entity types, so keeping this distinction prevents graph reads from + // treating a random candidate UUID as a library track UUID. await this.db.upsertClaim({ - subject_type: 'track', + subject_type: 'discovery_candidate', subject_id: dcId, predicate: 'discovery_candidate', object_type: 'artist', object_id: row.candidate_artist_id, + // Registered in source_trust by the System E migration. Previously + // this unregistered value violated claims.source's foreign key. source: 'graph_exploration', confidence: relevance, raw: { @@ -134,7 +139,7 @@ export class DiscoveryService { const claimRes = await this.db.pgClient.query<{ object_id: string; fused_value: number }>( `SELECT object_id, fused_value FROM claim_fusion - WHERE subject_type = 'track' AND subject_id = $1::uuid + WHERE subject_type = 'discovery_candidate' AND subject_id = $1::uuid AND predicate = 'discovery_candidate' LIMIT 1`, [row.id] @@ -143,6 +148,24 @@ export class DiscoveryService { const relevance = claimRes.rows[0]?.fused_value ?? 0; const candidateArtistId = claimRes.rows[0]?.object_id; + // Graph walks identify artists, not a legal/downloadable recording. A + // resolver (or a human) must attach a vetted HTTPS source before the + // worker can acquire anything. Do not guess a search query and download + // an arbitrary track under an artist's name. + const notes = typeof row.notes === 'string' ? safeJson(row.notes) : row.notes; + const resolvedUrl = (notes as { acquisition?: { url?: unknown } } | null)?.acquisition?.url; + if (typeof resolvedUrl !== 'string' || resolvedUrl.trim() === '') { + await this.db.pgClient.query( + `UPDATE discovery_candidates + SET status = 'awaiting_resolution', last_eval_at = NOW(), + last_error = 'no vetted acquisition source attached' + WHERE id = $1`, + [row.id] + ); + results.push({ candidateId: row.id, shouldAcquire: false, reason: 'awaiting resolved acquisition source' }); + continue; + } + const noveltyBeliefs = await this.db.getListenerBeliefs({ userId, profile: 'discovery', @@ -160,6 +183,7 @@ export class DiscoveryService { FROM discovery_candidates dc JOIN claims c ON c.subject_id = dc.id WHERE dc.status = 'acquiring' + AND c.subject_type = 'discovery_candidate' AND c.predicate = 'discovery_candidate' AND c.object_id = $1::uuid`, [candidateArtistId] @@ -200,6 +224,16 @@ export class DiscoveryService { return results; } + /** Undo the state transition when Redis rejected an acquisition enqueue. */ + async markEnqueueFailed(candidateId: string, reason: string): Promise { + await this.db.pgClient.query( + `UPDATE discovery_candidates + SET status = 'candidate', last_eval_at = NOW(), last_error = $2 + WHERE id = $1 AND status = 'acquiring'`, + [candidateId, reason.slice(0, 2000)] + ); + } + // --------------------------------------------------------------- // E.4 — Probation lifecycle // --------------------------------------------------------------- @@ -226,13 +260,18 @@ export class DiscoveryService { if (completedPlays >= 3) { await this.db.pgClient.query( - `UPDATE tracks SET probation_status = 'retained' WHERE id = $1`, + // Retained recommendations become normal library candidates. Their + // source_type remains RECOMMENDATION for provenance, while state=LIBRARY + // is the explicit promotion gate consumed by the existing Vibe queries. + `UPDATE tracks + SET probation_status = 'retained', state = 'LIBRARY' + WHERE id = $1`, [trackId] ); const claimRes = await this.db.pgClient.query<{ source: string }>( `SELECT source FROM claims - WHERE subject_type = 'track' AND subject_id = $1 AND predicate = 'discovery_candidate' + WHERE subject_type = 'track' AND subject_id = $1 AND predicate = 'acquired_from' LIMIT 1`, [trackId] ); @@ -252,7 +291,9 @@ export class DiscoveryService { if (completedPlays === 0 && skips >= 3 && daysSinceProbation > 7) { await this.db.pgClient.query( - `UPDATE tracks SET probation_status = 'retired' WHERE id = $1`, + // Retirement is reversible and never unlinks a file. The existing + // cleanup hard-delete workflow remains separately gated. + `UPDATE tracks SET probation_status = 'retired', state = 'HIDDEN' WHERE id = $1`, [trackId] ); return 'retired'; @@ -286,7 +327,7 @@ export class DiscoveryService { `SELECT c.source, COUNT(*)::int AS cnt FROM claims c JOIN tracks t ON t.id = c.subject_id - WHERE c.predicate = 'discovery_candidate' + WHERE c.predicate = 'acquired_from' AND t.probation_status = 'retained' GROUP BY c.source ORDER BY cnt DESC` @@ -295,3 +336,11 @@ export class DiscoveryService { console.log('[MetaLearning] Discovery source retention counts:', JSON.stringify(res.rows)); } } + +function safeJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return null; + } +} diff --git a/backend/src/services/generators.service.ts b/backend/src/services/generators.service.ts index 1f9c815..1077563 100644 --- a/backend/src/services/generators.service.ts +++ b/backend/src/services/generators.service.ts @@ -68,7 +68,7 @@ async function comfortGenerator(db: DbService, ctx: GeneratorContext): Promise= NOW() - INTERVAL '60 days' - AND t.state = 'LIBRARY' + AND (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation')) AND NOT (t.id = ANY($1::uuid[])) ) sub ORDER BY release_date DESC @@ -435,7 +435,7 @@ async function experimentalGenerator(db: DbService, ctx: GeneratorContext): Prom FROM tracks t JOIN track_genre tg ON tg.track_id = t.id JOIN unfamiliar_genres ug ON ug.id = tg.genre_id - WHERE t.state = 'LIBRARY' + WHERE (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation')) AND NOT (t.id = ANY($2::uuid[])) LIMIT 30 ) @@ -491,7 +491,7 @@ async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promis AND cf.object_type = 'artist' AND cf.object_id = ANY($1::uuid[]) AND (cf.user_id = $2 OR cf.user_id = $3) - WHERE t.state = 'LIBRARY' + WHERE (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation')) AND NOT (t.id = ANY($4::uuid[])) ORDER BY t.id, cf.fused_value DESC NULLS LAST ) sub diff --git a/backend/src/services/generators.test.ts b/backend/src/services/generators.test.ts index 14d6250..e23e733 100644 --- a/backend/src/services/generators.test.ts +++ b/backend/src/services/generators.test.ts @@ -53,6 +53,19 @@ describe('generators', () => { expect(results[0].explanation.length).toBeGreaterThanOrEqual(1); }); + it('accepts the fresh artist affinity shape produced by a promoted track', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 'track-1', artist_id: 'artist-1' }] }); + // recordTrackEvidence(add_to_favorites) projects 0.60 * 0.90 = 0.54 + // onto a fresh longterm artist affinity belief. + const ctx = makeCtx({ beliefs: [ + { entity_type: 'artist', entity_id: 'artist-1', value: 0.54, confidence: 0.05, profile: 'longterm', dimension: 'affinity' } as any, + ] }); + const results = await generatorByName.comfort(db, ctx); + expect(results).toHaveLength(1); + expect(results[0].generatorId).toBe('comfort'); + }); + it('returns empty when no high-affinity artists', async () => { const db = makeMockDb(); const ctx = makeCtx({ beliefs: [ { entity_type: 'artist', entity_id: 'a1', value: 0.3, profile: 'longterm', dimension: 'affinity' } as any ] }); @@ -128,16 +141,30 @@ describe('generators', () => { }); }); describe('novelty', () => { - it('returns recent tracks', async () => { + it('returns a candidate when canonical release-date coverage makes a recent track eligible', async () => { const db = makeMockDb(); (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1' }] }); const ctx = makeCtx({ beliefs: [ { entity_type: 'artist', entity_id: 'trusted-1', value: 0.5, profile: 'longterm', dimension: 'affinity' } as any ], }); const results = await generatorByName.novelty(db, ctx); - if (results.length > 0) { - expect(results[0].generatorId).toBe('novelty'); - } + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ trackId: 't1', generatorId: 'novelty' }); + }); + + it('queries only dated library tracks from the current novelty window', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ rows: [] }); + const ctx = makeCtx({ + beliefs: [ { entity_type: 'artist', entity_id: 'trusted-1', value: 0.5, profile: 'longterm', dimension: 'affinity' } as any ], + }); + + await generatorByName.novelty(db, ctx); + + const [sql] = (db.pgClient.query as any).mock.calls[0]; + expect(sql).toContain('t.release_date IS NOT NULL'); + expect(sql).toContain("t.release_date >= NOW() - INTERVAL '60 days'"); + expect(sql).toContain("t.state = 'LIBRARY'"); }); }); diff --git a/backend/src/services/job.service.ts b/backend/src/services/job.service.ts index 7e6da7b..fcc63f7 100644 --- a/backend/src/services/job.service.ts +++ b/backend/src/services/job.service.ts @@ -1,5 +1,12 @@ import { Queue, Job } from 'bullmq'; -import { MetadataRefreshJob, AudioAnalysisJob, CleanupJob, LibraryScanJob, ReindexTracksJob, ReprocessArtistsJob } from '../types/job.types.js'; +import { MetadataRefreshJob, AudioAnalysisJob, CleanupJob, LibraryScanJob, ReindexTracksJob, ReprocessArtistsJob, AcquisitionJob } from '../types/job.types.js'; + +const AUDIO_ANALYSIS_JOB_OPTIONS = { + attempts: 3, + backoff: { type: 'exponential', delay: 5_000 }, + removeOnComplete: { age: 7 * 24 * 60 * 60, count: 10_000 }, + removeOnFail: { age: 30 * 24 * 60 * 60, count: 10_000 }, +} as const; export interface JobServiceConfig { redisUrl: string; @@ -26,6 +33,37 @@ export interface JobHistoryEntry { returnvalue?: unknown; } +export interface ReenrichmentQueuePlan { + metadataTrackIds: string[]; + artistIds: string[]; + albumIds: string[]; +} + +export interface EnqueueResult { + requested: number; + enqueued: number; + alreadyQueued: number; + failed: Array<{ id: string; error: string }>; +} + +export interface ReenrichmentQueueResult { + metadata: EnqueueResult; + artistImages: EnqueueResult; + albumCovers: EnqueueResult; +} + +export interface EnrichmentQueueDiagnostics { + pending: Record; + recentFailures: Array<{ + id: string; + name: string; + data: Record; + failedReason?: string; + timestamp: number; + finishedOn?: number; + }>; +} + export class JobService { private queue: Queue; @@ -42,9 +80,12 @@ export class JobService { await this.queue.add('metadata_refresh', payload); } - async enqueueAudioAnalysis(trackId: string, features: string[]) { - const payload: AudioAnalysisJob = { trackId, features }; - await this.queue.add('audio_analysis', payload); + async enqueueAudioAnalysis(trackId: string) { + const payload: AudioAnalysisJob = { trackId }; + await this.queue.add('audio_analysis', payload, { + jobId: `audio-${trackId}`, + ...AUDIO_ANALYSIS_JOB_OPTIONS, + }); } async enqueueCleanup(reason: 'expired' | 'manual', targetFiles: string[]) { @@ -67,30 +108,123 @@ export class JobService { await this.queue.add('reprocess_artists', payload); } - /** - * Enqueue metadata_refresh jobs for a batch of track IDs. Used by the - * /admin/reenrich-tracks endpoint to re-canonicalize metadata (artist names, - * album titles, MBIDs, cover art) without re-scanning files from disk. - * - * Each job is deduped by `jobId: meta-` so re-running the endpoint - * doesn't stack duplicate jobs. Old completed/failed jobs with the same ID - * are removed first so re-enrichment actually works (BullMQ otherwise treats - * existing jobIds as duplicates and silently skips them). - */ - async enqueueMetadataRefreshBatch(trackIds: string[]): Promise { - let enqueued = 0; - for (const trackId of trackIds) { - const jobId = `meta-${trackId}`; - await this.queue.remove(jobId).catch(() => {}); - const payload: MetadataRefreshJob = { trackId, refreshType: 'full' }; - await this.queue.add('metadata_refresh', payload, { - jobId, - removeOnComplete: { age: 86400, count: 10000 }, - removeOnFail: { age: 86400, count: 10000 }, - }); - enqueued++; + async enqueueDiscoveryAcquisition(candidateId: string): Promise { + const payload: AcquisitionJob = { candidateId }; + const jobId = `acquire-${candidateId}`; + // BullMQ returns a retained completed/failed job for the same id without + // executing it. Remove terminal attempts before retrying so a candidate + // cannot be stranded in `acquiring` for the retention window. + const existing = await this.queue.getJob(jobId); + if (existing) { + const state = await existing.getState(); + if (state === 'completed' || state === 'failed') await existing.remove(); } - return enqueued; + await this.queue.add('acquire_discovery_candidate', payload, { + // A candidate may have one active attempt. Failed/completed jobs expire + // so an operator can explicitly re-evaluate it later. + jobId, + removeOnComplete: { age: 86400, count: 1000 }, + removeOnFail: { age: 86400, count: 1000 }, + }); + } + + /** + * Queue a bounded re-enrichment plan. Metadata, artist images and album + * covers deliberately have separate jobs: metadata resolution can complete + * even when artwork providers are unavailable, and artwork is deduplicated + * at its natural artist/album boundary. + * + * Existing active/waiting jobs are preserved and reported as alreadyQueued. + * Completed/failed jobs are removed before retrying, so a request never + * claims a retry was enqueued when BullMQ retained an old job id. + */ + async enqueueReenrichment(plan: ReenrichmentQueuePlan): Promise { + return { + metadata: await this.enqueueMany( + [...new Set(plan.metadataTrackIds)], + 'metadata_refresh', + (trackId) => ({ trackId, refreshType: 'full' }), + (trackId) => `meta-${trackId}`, + ), + artistImages: await this.enqueueMany( + [...new Set(plan.artistIds)], + 'artist_image', + (artistId) => ({ artistId }), + (artistId) => `artist-image-${artistId}`, + ), + albumCovers: await this.enqueueMany( + [...new Set(plan.albumIds)], + 'album_cover', + (albumId) => ({ albumId }), + (albumId) => `album-cover-${albumId}`, + ), + }; + } + + private async enqueueMany( + ids: string[], + name: string, + payloadFor: (id: string) => Record, + jobIdFor: (id: string) => string, + ): Promise { + const result: EnqueueResult = { requested: ids.length, enqueued: 0, alreadyQueued: 0, failed: [] }; + const keep = { + removeOnComplete: { age: 86400, count: 10000 }, + removeOnFail: { age: 86400, count: 10000 }, + } as const; + + for (const id of ids) { + const jobId = jobIdFor(id); + try { + const existing = await this.queue.getJob(jobId); + if (existing) { + const state = await existing.getState(); + if (state === 'active' || state === 'waiting' || state === 'delayed' || state === 'prioritized') { + result.alreadyQueued++; + continue; + } + await existing.remove(); + } + await this.queue.add(name, payloadFor(id), { jobId, ...keep }); + result.enqueued++; + } catch (err) { + result.failed.push({ id, error: err instanceof Error ? err.message : String(err) }); + } + } + return result; + } + + async getEnrichmentQueueDiagnostics(limit = 25): Promise { + const relevant = new Set(['metadata_refresh', 'artist_image', 'album_cover']); + const [waiting, active, delayed, failed] = await Promise.all([ + this.queue.getJobs(['waiting'], 0, 1000), + this.queue.getJobs(['active'], 0, 1000), + this.queue.getJobs(['delayed'], 0, 1000), + this.queue.getJobs(['failed'], 0, Math.max(limit * 4, 100)), + ]); + const pending: Record = { + metadata_refresh: 0, + artist_image: 0, + album_cover: 0, + }; + for (const job of [...waiting, ...active, ...delayed]) { + if (relevant.has(job.name)) pending[job.name]++; + } + + const recentFailures = failed + .filter((job) => relevant.has(job.name)) + .sort((a, b) => (b.finishedOn ?? b.timestamp) - (a.finishedOn ?? a.timestamp)) + .slice(0, limit) + .map((job) => ({ + id: String(job.id), + name: job.name, + data: job.data as Record, + failedReason: job.failedReason, + timestamp: job.timestamp, + finishedOn: job.finishedOn, + })); + + return { pending, recentFailures }; } async getQueueStats(): Promise { diff --git a/backend/src/services/session-director.service.ts b/backend/src/services/session-director.service.ts index ce66ef8..8c06585 100644 --- a/backend/src/services/session-director.service.ts +++ b/backend/src/services/session-director.service.ts @@ -1,5 +1,6 @@ import { DbService, ListenerBelief } from './db.service.js'; import { Candidate, GeneratorContext, Generator, ALL_GENERATORS } from './generators.service.js'; +import { AUDIO_PREFERENCE_BUCKETS } from '../db/types.js'; export interface FatigueState { artist: Map; @@ -33,11 +34,43 @@ export interface RepetitionState { recentArtistIds: Set; } +export interface PlanBuildOptions { + /** Tracks already exposed during this Vibe session; they are ineligible. */ + excludedTrackIds?: Iterable; + /** Main artists already served or rejected in this Vibe session. */ + excludedArtistIds?: Iterable; +} + const W_ENJOY = 1.0; const W_FATIGUE = 0.4; const W_DIVERSITY = 0.3; const W_ENTROPY = 0.2; const W_REPETITION = 0.5; +const PLAN_SIZE = 20; + +/** + * Preserve the existing queue, append only genuinely new candidates, and + * never emit a duplicate. This is deliberately pure so the queue boundary is + * testable without a database. + */ +export function mergeUniquePlan( + currentPlan: Candidate[], + additions: Candidate[], + excludedTrackIds: Iterable = [], + targetSize = PLAN_SIZE +): Candidate[] { + const seen = new Set(excludedTrackIds); + const merged: Candidate[] = []; + + for (const candidate of [...currentPlan, ...additions]) { + if (seen.has(candidate.trackId)) continue; + seen.add(candidate.trackId); + merged.push(candidate); + if (merged.length >= targetSize) break; + } + + return merged; +} export class SessionDirector { constructor(private db: DbService) {} @@ -98,7 +131,22 @@ export class SessionDirector { WHERE taf.energy IS NOT NULL`, [userId] ); - const energy = (energyRes.rows[0]?.energy as number) ?? 0.5; + let energy = (energyRes.rows[0]?.energy as number) ?? 0.5; + // Audio beliefs are projected from track feedback. Blend the strongest + // energy preference with the recent-play state so the projection affects + // the session without making a single old preference a hard constraint. + const energyBelief = await this.db.pgClient.query<{ entity_id: string; value: number }>( + `SELECT entity_id, value FROM listener_beliefs + WHERE user_id = $1 AND entity_type = 'audio' AND entity_id = ANY($2::uuid[]) + ORDER BY value DESC LIMIT 1`, + [userId, [AUDIO_PREFERENCE_BUCKETS.energy.low, AUDIO_PREFERENCE_BUCKETS.energy.medium, AUDIO_PREFERENCE_BUCKETS.energy.high]] + ); + const preferredEnergy = energyBelief.rows[0]?.entity_id === AUDIO_PREFERENCE_BUCKETS.energy.low ? 0.2 + : energyBelief.rows[0]?.entity_id === AUDIO_PREFERENCE_BUCKETS.energy.high ? 0.8 + : energyBelief.rows[0]?.entity_id === AUDIO_PREFERENCE_BUCKETS.energy.medium ? 0.5 : null; + if (preferredEnergy !== null && (energyBelief.rows[0]?.value ?? 0) > 0) { + energy = energy * 0.65 + preferredEnergy * 0.35; + } // Read novelty hunger from discovery profile const noveltyRes = await this.db.pgClient.query( @@ -783,8 +831,21 @@ export class SessionDirector { // --------------------------------------------------------------- // D.9 — Plan + replan loop // --------------------------------------------------------------- - async buildPlan(userId: string, sessionId: string, seedTrackId?: string): Promise { - const allBeliefs = await this.db.getListenerBeliefs({ userId, limit: 200 }); + async buildPlan( + userId: string, + sessionId: string, + seedTrackId?: string, + options: PlanBuildOptions = {} + ): Promise { + // Do not let abundant track-level beliefs crowd out the artist/genre + // affinities required by the discovery generators. + const beliefGroups = await Promise.all([ + this.db.getListenerBeliefs({ userId, entityType: 'track', limit: 100 }), + this.db.getListenerBeliefs({ userId, entityType: 'artist', limit: 100 }), + this.db.getListenerBeliefs({ userId, entityType: 'genre', limit: 75 }), + this.db.getListenerBeliefs({ userId, entityType: 'audio', limit: 50 }), + ]); + const allBeliefs = beliefGroups.flat(); // Fetch recent completed plays for anti-loop detection const recentPlaysRes = await this.db.pgClient.query( @@ -823,7 +884,7 @@ export class SessionDirector { const budgets = await this.getBudgets(userId); const arcType = this.pickArc(state); - const planSize = 20; + const planSize = PLAN_SIZE; const slots = this.getArcSlots(arcType, planSize); let seedArtistId: string | null = null; @@ -831,7 +892,16 @@ export class SessionDirector { seedArtistId = await this.resolveSeedArtistId(seedTrackId) ?? null; } - const recentExclusions: string[] = recentPlays.map(p => p.trackId); + // Every generator receives this as a SQL exclusion list. It is a hard + // boundary, not a score penalty: a served/skipped track cannot return in + // a replacement plan while its Vibe session is active. + const recentExclusionSet = new Set([ + ...recentPlays.map(p => p.trackId), + ...(options.excludedTrackIds ?? []), + ]); + if (seedTrackId) recentExclusionSet.add(seedTrackId); + const recentExclusions = [...recentExclusionSet]; + const excludedArtistIds = new Set(options.excludedArtistIds ?? []); const toleranceMap: Record = {}; const discoveryBeliefs = allBeliefs.filter(b => b.profile === 'discovery'); for (const b of discoveryBeliefs) { @@ -859,8 +929,24 @@ export class SessionDirector { } const repetitionState = await this.buildRepetitionState(userId); + const candidateArtistMap = await this.loadArtistMap( + [...new Set(allCandidates.map(c => c.trackId))] + ); + // Artist cooldown used to be a 0.1x score multiplier. In a small pool it + // still selected the same artist, so enforce the configured window before + // ranking instead. + const eligibleCandidates = allCandidates.filter(candidate => + !recentExclusionSet.has(candidate.trackId) && + !repetitionState.recentTrackIds.has(candidate.trackId) && + !repetitionState.recentArtistIds.has(candidateArtistMap.get(candidate.trackId) ?? '') && + !excludedArtistIds.has(candidateArtistMap.get(candidate.trackId) ?? '') + ); + + if (eligibleCandidates.length === 0) { + return []; + } const ranked = await this.rankCandidates( - allCandidates, fatigue, budgets, state, repetitionState + eligibleCandidates, fatigue, budgets, state, repetitionState ); const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays); @@ -928,10 +1014,21 @@ export class SessionDirector { sessionId: string, currentPlan: Candidate[], playedTrackIds: string[], - seedTrackId?: string + seedTrackId?: string, + options: PlanBuildOptions = {} ): Promise { - const remainingSlots = currentPlan.filter( - c => !playedTrackIds.includes(c.trackId) + const excludedTrackIds = new Set([ + ...playedTrackIds, + ...(options.excludedTrackIds ?? []), + ]); + // Corrupt/legacy Redis plans can contain duplicates. Clean those before + // deciding whether the tail needs a refill, and never reintroduce a track + // reported as played/skipped/disliked by the route. + const remainingSlots = mergeUniquePlan( + currentPlan, + [], + excludedTrackIds, + PLAN_SIZE ); if (remainingSlots.length >= 10 && currentPlan.length > 0) { @@ -973,19 +1070,35 @@ export class SessionDirector { const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays); if (loopDim) { - return this.buildPlan(userId, sessionId, seedTrackId); + const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, { + excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]), + excludedArtistIds: options.excludedArtistIds, + }); + return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE); } const planArtistMap = await this.loadArtistMap([...new Set(currentPlan.map(c => c.trackId))]); const entropy = this.computeEntropy(currentPlan, c => planArtistMap.get(c.trackId) ?? 'unknown'); if (Math.abs(entropy - 0.55) > 0.2) { - return this.buildPlan(userId, sessionId, seedTrackId); + const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, { + excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]), + excludedArtistIds: options.excludedArtistIds, + }); + return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE); } return remainingSlots; } - return this.buildPlan(userId, sessionId, seedTrackId); + // Build replacements against both session history and the queue tail. The + // old code rebuilt from completed plays only, then locally de-duped at the + // route — yielding an empty tail when every returned candidate was already + // queued. Keep the valid tail and append only fresh candidates. + const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, { + excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]), + excludedArtistIds: options.excludedArtistIds, + }); + return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE); } // --------------------------------------------------------------- diff --git a/backend/src/services/session-director.test.ts b/backend/src/services/session-director.test.ts index 6d99d97..02611c2 100644 --- a/backend/src/services/session-director.test.ts +++ b/backend/src/services/session-director.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { SessionDirector } from './session-director.service.js'; +import { mergeUniquePlan, SessionDirector } from './session-director.service.js'; import { DbService } from './db.service.js'; function makeMockDb(overrides: Record = {}): DbService { @@ -15,6 +15,74 @@ function makeMockDb(overrides: Record = {}): DbService { } describe('SessionDirector', () => { + const candidate = (trackId: string) => ({ + trackId, + generatorId: 'test', + relevance: 1, + explanation: [], + }); + + describe('session queue exclusions', () => { + it('keeps more than 100 unique selections without cycling', () => { + const source = Array.from({ length: 125 }, (_, index) => candidate(`track-${index}`)); + const plan = mergeUniquePlan([], source, [], 125); + + expect(plan).toHaveLength(125); + expect(new Set(plan.map(item => item.trackId)).size).toBe(125); + }); + + it('drops rapid skips and duplicate-only refill candidates', async () => { + const director = new SessionDirector(makeMockDb()); + const buildPlan = vi.spyOn(director, 'buildPlan').mockResolvedValue([ + candidate('skipped'), + candidate('already-queued'), + candidate('fresh'), + candidate('fresh'), + ]); + + const plan = await director.replan( + 'user-1', + 'session-1', + [candidate('skipped'), candidate('already-queued'), candidate('already-queued')], + ['skipped'], + undefined, + { excludedTrackIds: ['older-skip', 'skipped'] } + ); + + expect(plan.map(item => item.trackId)).toEqual(['already-queued', 'fresh']); + expect(buildPlan).toHaveBeenCalledWith( + 'user-1', + 'session-1', + undefined, + expect.objectContaining({ + excludedTrackIds: expect.any(Set), + }) + ); + const refillExclusions = (buildPlan.mock.calls[0][3] as any).excludedTrackIds as Set; + expect(refillExclusions).toEqual(new Set(['older-skip', 'skipped', 'already-queued'])); + }); + + it('does not append anything when a refill contains only queued or excluded tracks', async () => { + const director = new SessionDirector(makeMockDb()); + vi.spyOn(director, 'buildPlan').mockResolvedValue([ + candidate('queued'), + candidate('skipped'), + candidate('queued'), + ]); + + const plan = await director.replan( + 'user-1', + 'session-1', + [candidate('queued')], + ['skipped'], + undefined, + { excludedTrackIds: ['skipped'] } + ); + + expect(plan.map(item => item.trackId)).toEqual(['queued']); + }); + }); + describe('pickArc', () => { const director = new SessionDirector(makeMockDb()); diff --git a/backend/src/types/job.types.ts b/backend/src/types/job.types.ts index 3b1d2f9..2e187d1 100644 --- a/backend/src/types/job.types.ts +++ b/backend/src/types/job.types.ts @@ -5,7 +5,6 @@ export interface MetadataRefreshJob { export interface AudioAnalysisJob { trackId: string; - features: string[]; } export interface CleanupJob { @@ -24,4 +23,12 @@ export interface ReprocessArtistsJob { offset?: number; } -export type JobPayload = MetadataRefreshJob | AudioAnalysisJob | CleanupJob | LibraryScanJob | ReindexTracksJob | ReprocessArtistsJob; +/** + * Explicit, opt-in System E hand-off. The worker looks the candidate up again + * from Postgres; no URL or shell arguments travel through Redis. + */ +export interface AcquisitionJob { + candidateId: string; +} + +export type JobPayload = MetadataRefreshJob | AudioAnalysisJob | CleanupJob | LibraryScanJob | ReindexTracksJob | ReprocessArtistsJob | AcquisitionJob; diff --git a/docker-compose.yml b/docker-compose.yml index 8d21a38..eb5ccf4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,7 +57,11 @@ services: - backend worker: - build: ./workers + build: + context: ./workers + # Keep the downloader absent unless an operator intentionally opts in. + args: + INSTALL_YTDLP: "false" restart: unless-stopped network_mode: host environment: @@ -69,6 +73,13 @@ services: DISCOGS_TOKEN: ${DISCOGS_TOKEN} SOCKS_PROXY_URL: ${SOCKS_PROXY_URL} MUSIC_DIR: /music + # System E acquisition is intentionally disabled by default. To enable it + # an operator must build with INSTALL_YTDLP=true and set all three runtime + # gates below; no acquisition happens merely from graph discovery. + # MUZICK_ACQUISITION_ENABLED: "true" + # MUZICK_ACQUISITION_YTDLP_PATH: /usr/bin/yt-dlp + # MUZICK_ACQUISITION_ALLOWED_HOSTS: example.org + # MUZICK_ACQUISITION_DIR: .recommendations # Hard-deletion gates for the dislike lifecycle (invariant §C). All three # default to the safe value inside cleanup.service.ts; they are listed here # as documentation and are intentionally left unset. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a0ddbd2..8060fc4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,16 +18,28 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.31", "@types/react-dom": "^18.3.7", "@vitejs/plugin-react": "^4.2.0", "autoprefixer": "^10.5.0", + "jsdom": "^25.0.1", "postcss": "^8.5.15", "tailwindcss": "^3.4.19", "typescript": "^5.9.3", - "vite": "^5.2.0" + "vite": "^5.2.0", + "vitest": "^2.1.9" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -41,6 +53,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -275,6 +308,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -323,6 +366,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", @@ -2022,6 +2180,105 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.1.0.tgz", + "integrity": "sha512-Q2ToPvg0KsVL0ohND9A3zLJWcOXXcO8IDu3fj11KhNt0UlCWyFyvnCIBkd12tidB2lkiVRG8VFqdhcqhqnAQtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2123,6 +2380,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -2135,6 +2505,33 @@ "node": ">= 6.0.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -2163,6 +2560,26 @@ "dev": true, "license": "MIT" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2290,6 +2707,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2333,6 +2760,47 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2378,6 +2846,26 @@ "license": "MIT", "peer": true }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2413,6 +2901,13 @@ "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", "license": "MIT" }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2426,6 +2921,27 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2433,6 +2949,20 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/date-fns": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", @@ -2460,6 +2990,23 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2469,6 +3016,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2494,6 +3051,14 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2515,6 +3080,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2533,6 +3111,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -2609,6 +3194,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -2817,6 +3422,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2856,6 +3471,43 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -2869,6 +3521,29 @@ "node": ">= 6" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2931,6 +3606,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isbot": { "version": "5.1.41", "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.41.tgz", @@ -2956,6 +3638,71 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3002,6 +3749,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3014,6 +3768,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3033,6 +3794,27 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3087,6 +3869,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3226,6 +4018,13 @@ "node": ">=0.10.0" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3246,6 +4045,19 @@ "node": ">= 6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3253,6 +4065,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3455,6 +4284,36 @@ "dev": true, "license": "MIT" }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -3464,6 +4323,16 @@ "node": ">=10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3510,6 +4379,14 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3543,6 +4420,20 @@ "node": ">=8.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3621,6 +4512,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -3645,6 +4543,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -3745,6 +4663,13 @@ "node": ">=10" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3754,6 +4679,33 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -3801,6 +4753,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -3814,6 +4779,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -3875,6 +4847,20 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3923,6 +4909,56 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3936,6 +4972,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -4071,6 +5133,212 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 13363a4..3fd69f9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,8 @@ "dev": "vite", "build": "vite build", "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "preview": "vite preview" }, "dependencies": { @@ -21,13 +23,18 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.31", "@types/react-dom": "^18.3.7", "@vitejs/plugin-react": "^4.2.0", "autoprefixer": "^10.5.0", + "jsdom": "^25.0.1", "postcss": "^8.5.15", "tailwindcss": "^3.4.19", "typescript": "^5.9.3", - "vite": "^5.2.0" + "vite": "^5.2.0", + "vitest": "^2.1.9" } } diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index a879eea..0b50f7d 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -9,16 +9,14 @@ import { LyricsOverlay } from './LyricsOverlay'; import { Toaster } from './Toaster'; import { CommandPalette } from './CommandPalette'; import { KeyboardListener, useKeyboard } from '../hooks/useKeyboard'; -import { Inspector, type InspectorMode } from './Inspector'; export default function AppShell() { const [queueOpen, setQueueOpen] = useState(false); + const [navigationOpen, setNavigationOpen] = useState(false); const [lyricsOpen, setLyricsOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false); - const [inspector, setInspector] = useState<{ mode: InspectorMode; id: string } | null>(null); const togglePalette = useCallback(() => setPaletteOpen((p) => !p), []); - const closeInspector = useCallback(() => setInspector(null), []); // Ctrl+K — command palette (uses `code` so it works on any keyboard layout) useKeyboard({ @@ -39,24 +37,28 @@ export default function AppShell() { handler: () => window.history.forward(), }); - // Esc — closes inspector, palette, etc. useKeyboard({ code: 'Escape', handler: () => { - if (inspector) closeInspector(); + if (navigationOpen) setNavigationOpen(false); + else if (queueOpen) setQueueOpen(false); + else if (lyricsOpen) setLyricsOpen(false); }, }); return ( -
+
- + setNavigationOpen((open) => !open)} + navigationOpen={navigationOpen} + />
- -
+ setNavigationOpen(false)} /> +
- {inspector && } {queueOpen && setQueueOpen(false)} />} {lyricsOpen && setLyricsOpen(false)} />}
diff --git a/frontend/src/components/ArtistLinks.tsx b/frontend/src/components/ArtistLinks.tsx index b6bd9e3..53f8c86 100644 --- a/frontend/src/components/ArtistLinks.tsx +++ b/frontend/src/components/ArtistLinks.tsx @@ -24,13 +24,9 @@ function deduplicateArtists(artists: TrackArtist[]): TrackArtist[] { map.set(a.id, a); } } - // Preserve original order, skipping duplicates. - const seen = new Set(); - return artists.filter((a) => { - if (seen.has(a.id)) return false; - seen.add(a.id); - return true; - }); + // Keep the position of the selected credit and omit every duplicate. This + // makes the documented main-over-featured preference real. + return artists.filter((a) => map.get(a.id) === a); } /** diff --git a/frontend/src/components/AudioEngine.tsx b/frontend/src/components/AudioEngine.tsx index ddef83a..1d85399 100644 --- a/frontend/src/components/AudioEngine.tsx +++ b/frontend/src/components/AudioEngine.tsx @@ -123,7 +123,7 @@ export const AudioEngine = () => { // this track — don't also record the implicit transition. } else { try { - void vibeService.feedback(prevId, completed ? 'completed' : 'skipped').catch(() => {}); + void vibeService.feedback(prevId, completed ? 'completed' : 'skipped', useVibeStore.getState().activeSessionId ?? undefined).catch(() => {}); } catch { /* best-effort */ } diff --git a/frontend/src/components/Inspector.tsx b/frontend/src/components/Inspector.tsx deleted file mode 100644 index 5ea66b0..0000000 --- a/frontend/src/components/Inspector.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { X, Play } from 'lucide-react'; -import { useQuery } from '@tanstack/react-query'; -import { Link } from '@tanstack/react-router'; -import type { AlbumWithTracks, ArtistWithAlbums } from '../types'; -import { albumService } from '../services/albumService'; -import { artistService } from '../services/artistService'; -import { Artwork } from './Artwork'; -import { Button } from './ethos/Button'; -import { TrackRow } from './TrackRow'; -import { usePlaybackStore } from '../store/usePlaybackStore'; - -type InspectorMode = 'album' | 'artist' | 'track'; - -interface InspectorProps { - mode: InspectorMode; - id: string; - onClose: () => void; -} - -function AlbumInspector({ id, onClose }: { id: string; onClose: () => void }) { - const { setQueue, playTrack } = usePlaybackStore(); - const { data, isLoading } = useQuery({ - queryKey: ['album', id], - queryFn: () => albumService.getAlbum(id), - }); - - if (isLoading) { - return ( -
-
-
-
-
- ); - } - if (!data) return null; - - const tracks = data.tracks ?? []; - - return ( -
- {/* Header */} -
- Album - -
- -
- {/* Artwork + meta */} -
-
- -
-
-

{data.title}

-

{data.artist_name || 'Unknown artist'}{data.year ? ` · ${data.year}` : ''}

-

{tracks.length} tracks

-
- - -
- - {/* Track list */} -
-
Tracks
-
- {tracks.map((t, i) => ( - - ))} -
-
-
-
- ); -} - -function ArtistInspector({ id, onClose }: { id: string; onClose: () => void }) { - const { data, isLoading } = useQuery({ - queryKey: ['artist', id], - queryFn: () => artistService.getArtist(id), - }); - - if (isLoading) { - return ( -
-
-
-
- ); - } - if (!data) return null; - - const albums = data.albums ?? []; - - return ( -
-
- Artist - -
- -
-
-
- -
-
-

{data.name}

-

{albums.length} albums

-
-
- - {albums.length > 0 && ( -
-
Albums
-
- {albums.map((album) => ( - -
- -
- {album.title} - - ))} -
-
- )} -
-
- ); -} - -export type { InspectorMode }; - -/** - * Inspector panel — right-side detail view for albums, artists, and tracks. - * Replaces full-page navigations with a slide-in panel per Ethos conventions. - */ -export function Inspector({ mode, id, onClose }: InspectorProps) { - return ( - - ); -} diff --git a/frontend/src/components/NavRail.tsx b/frontend/src/components/NavRail.tsx index fefde6e..e2051a1 100644 --- a/frontend/src/components/NavRail.tsx +++ b/frontend/src/components/NavRail.tsx @@ -1,10 +1,11 @@ import { Link } from '@tanstack/react-router'; +import { useEffect, useRef, useState } from 'react'; import type { LucideIcon } from 'lucide-react'; import { Home, Music, Disc3, Users, Tag, Compass, Terminal, ShieldAlert, Zap, - Settings, Sparkles, + Settings, Sparkles, X, } from 'lucide-react'; interface NavItem { @@ -59,15 +60,86 @@ const active = 'bg-accent/10 text-accent font-medium ' + "before:content-[''] before:absolute before:left-0 before:top-1 before:bottom-1 before:w-0.5 before:rounded-full before:bg-accent"; -export function NavRail() { +interface NavRailProps { + open: boolean; + onClose: () => void; +} + +export function NavRail({ open, onClose }: NavRailProps) { + const [desktop, setDesktop] = useState(false); + const drawerRef = useRef(null); + const closeRef = useRef(null); + useEffect(() => { + const query = window.matchMedia('(min-width: 1024px)'); + const update = () => setDesktop(query.matches); + update(); + query.addEventListener('change', update); + return () => query.removeEventListener('change', update); + }, []); + useEffect(() => { + if (open && !desktop) closeRef.current?.focus(); + }, [open, desktop]); + useEffect(() => { + if (!open || desktop || !drawerRef.current) return; + const previous = document.activeElement as HTMLElement | null; + const trap = (event: KeyboardEvent) => { + if (event.key !== 'Tab' || !drawerRef.current) return; + const focusable = [...drawerRef.current.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])' + )].filter((element) => !element.hasAttribute('hidden')); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); first.focus(); + } + }; + document.addEventListener('keydown', trap); + return () => { + document.removeEventListener('keydown', trap); + previous?.focus(); + }; + }, [open, desktop]); + + // Do not leave an off-screen mobile drawer in the tab order. The desktop + // rail remains mounted independently of the drawer state. + if (!desktop && !open) return null; return ( -
{/* Navigation */} @@ -85,6 +157,7 @@ export function NavRail() { activeOptions={{ exact: exact ?? false }} activeProps={{ className: `${base} ${active}` }} inactiveProps={{ className: `${base} ${inactive}` }} + onClick={onClose} > {label} @@ -100,6 +173,7 @@ export function NavRail() {
muzick · v0.1
- + + ); } diff --git a/frontend/src/components/NowPlayingPanel.test.tsx b/frontend/src/components/NowPlayingPanel.test.tsx new file mode 100644 index 0000000..cefd66b --- /dev/null +++ b/frontend/src/components/NowPlayingPanel.test.tsx @@ -0,0 +1,27 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { usePlaybackStore } from '../store/usePlaybackStore'; +import { NowPlayingPanel } from './NowPlayingPanel'; + +describe('NowPlayingPanel', () => { + it('announces itself as a modal and keeps keyboard focus inside', async () => { + usePlaybackStore.setState({ currentTrack: null, queue: [], currentIndex: -1, isPlaying: false }); + const user = userEvent.setup(); + render( + + undefined} /> + + ); + + const dialog = screen.getByRole('dialog', { name: 'Now playing queue' }); + const close = screen.getByRole('button', { name: 'Close now playing' }); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(close).toHaveFocus(); + await user.tab({ shift: true }); + expect(screen.getByRole('button', { name: 'Next' })).toHaveFocus(); + await user.tab(); + expect(close).toHaveFocus(); + }); +}); diff --git a/frontend/src/components/NowPlayingPanel.tsx b/frontend/src/components/NowPlayingPanel.tsx index cb81d20..e09119b 100644 --- a/frontend/src/components/NowPlayingPanel.tsx +++ b/frontend/src/components/NowPlayingPanel.tsx @@ -1,4 +1,5 @@ import { X, Play, Pause, SkipBack, SkipForward, Disc3 } from 'lucide-react'; +import { useEffect, useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; import { usePlaybackStore } from '../store/usePlaybackStore'; @@ -8,6 +9,31 @@ import { TrackRow, formatDuration } from './TrackRow'; import { albumService } from '../services/albumService'; export function NowPlayingPanel({ onClose }: { onClose: () => void }) { + const panelRef = useRef(null); + const closeRef = useRef(null); + useEffect(() => { closeRef.current?.focus(); }, []); + useEffect(() => { + const previous = document.activeElement as HTMLElement | null; + const trap = (event: KeyboardEvent) => { + if (event.key !== 'Tab' || !panelRef.current) return; + const focusable = [...panelRef.current.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])' + )].filter((element) => !element.hasAttribute('hidden')); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); first.focus(); + } + }; + document.addEventListener('keydown', trap); + return () => { + document.removeEventListener('keydown', trap); + previous?.focus(); + }; + }, []); const { currentTrack, queue, isPlaying, position, duration, play, pause, next, prev, setPosition } = usePlaybackStore(); const currentIdx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1; @@ -22,25 +48,25 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) { const artwork = albumQ.data?.artwork_id ?? currentTrack?.artwork_id ?? null; return ( -