feat: enhance discovery, vibe sessions, and library enrichment
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled

This commit is contained in:
kami
2026-08-01 14:40:48 +04:00
parent a0c9f42a89
commit 4c48d11e9d
54 changed files with 4136 additions and 521 deletions
+1 -1
View File
@@ -160,7 +160,7 @@ export async function buildApp(config: AppConfig) {
const sessionDirector = new SessionDirector(dbService); const sessionDirector = new SessionDirector(dbService);
fastify.register(v2Routes, { prefix: '/api', dbService, sessionDirector }); 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 — // ponytail: /api/test/enqueue-job (manual job-enqueue test endpoint) removed —
// nothing in the deployed app or its tests called it, and deployment never // 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. // sets NODE_ENV so an env gate would've stayed live in prod anyway.
+22
View File
@@ -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)');
});
});
+134
View File
@@ -510,4 +510,138 @@ export const MIGRATIONS: Migration[] = [
ON pending_file_deletions(requested_at); 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();
`,
},
]; ];
+81 -1
View File
@@ -130,6 +130,17 @@ CREATE TABLE IF NOT EXISTS tracks (
-- Add normalized columns as generated columns for existing databases where the -- 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). -- 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 DO $$ BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
@@ -140,6 +151,49 @@ DO $$ BEGIN
END $$; END $$;
CREATE INDEX IF NOT EXISTS idx_tracks_release_date ON tracks (release_date) WHERE release_date IS NOT NULL; 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 DO $$ BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
@@ -306,12 +360,20 @@ CREATE TABLE IF NOT EXISTS track_audio_features (
key TEXT, key TEXT,
energy REAL, energy REAL,
danceability REAL, danceability REAL,
-- Reserved legacy columns: Vibe readers tolerate these as NULL. The local
-- analyzer intentionally does not claim to infer them.
valence REAL, valence REAL,
acousticness REAL, acousticness REAL,
instrumentalness REAL, instrumentalness REAL,
liveness REAL, liveness REAL,
valence_score 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 ( 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; 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_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_genres', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('enrich_lyrics', '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.'), ('cover_art_archive', 0.85, 'Cover Art Archive, MB-backed.'),
('discogs', 0.75, 'Discogs release/artist credits.'), ('discogs', 0.75, 'Discogs release/artist credits.'),
('lastfm', 0.50, 'Last.fm tags + similar. Noisy; used as weak signal.'), ('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.'), ('listener_behavior', 0.40, 'Derived from observed play patterns. User-keyed.'),
('tag', 0.30, 'File-tag-derived via scanner heuristic. Lowest trust.') ('tag', 0.30, 'File-tag-derived via scanner heuristic. Lowest trust.')
ON CONFLICT (key) DO NOTHING; ON CONFLICT (key) DO NOTHING;
@@ -487,9 +557,19 @@ CREATE TABLE IF NOT EXISTS discovery_candidates (
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_eval_at TIMESTAMPTZ, last_eval_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'candidate', 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) 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. -- Probation status for acquired tracks.
DO $$ BEGIN DO $$ BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
+28
View File
@@ -16,6 +16,8 @@ export interface Album {
artist_id: string; artist_id: string;
title: string; title: string;
year?: number | null; year?: number | null;
/** Canonical MusicBrainz release-group first-release-date (YYYY-MM-DD). */
release_date?: string | null;
artwork_id?: string | null; artwork_id?: string | null;
} }
@@ -38,6 +40,8 @@ export interface Track {
skip_count: number; skip_count: number;
dislike_count: number; dislike_count: number;
last_played_at?: Date | null; last_played_at?: Date | null;
/** Denormalised from albums.release_date by a database trigger. */
release_date?: string | null;
mtime?: number | null; mtime?: number | null;
source_type: string; source_type: string;
artists?: TrackArtist[]; artists?: TrackArtist[];
@@ -136,6 +140,30 @@ export interface ListenerBelief {
last_decayed_at: Date; 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 { export interface ClaimEdge {
subjectType: string; subjectType: string;
subjectId: string; subjectId: string;
+260 -10
View File
@@ -2,6 +2,87 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { JobService } from '../services/job.service.js'; import { JobService } from '../services/job.service.js';
import { DbService } from '../services/db.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 }) { export default async function adminRoutes(fastify: FastifyInstance, options: { jobService: JobService; dbService: DbService }) {
const { jobService, dbService } = options; const { jobService, dbService } = options;
@@ -24,6 +105,60 @@ export default async function adminRoutes(fastify: FastifyInstance, options: { j
return { status: 'Artist reprocessing job enqueued' }; 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) => { fastify.post('/dedup-albums', async (_request: FastifyRequest, reply: FastifyReply) => {
// Merge duplicate album rows directly (synchronous — it's just SQL, no // Merge duplicate album rows directly (synchronous — it's just SQL, no
// external API calls). Returns the number of albums merged away. // 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 { 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<Record<keyof ReenrichmentScope, string>> = {};
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) => { fastify.post('/reenrich-tracks', async (_request: FastifyRequest, reply: FastifyReply) => {
// Re-enqueue metadata_refresh for every LIBRARY track without re-reading return reply.code(410).send({
// files from disk. This re-runs the MusicBrainz canonicalisation (artist error: 'Deprecated endpoint. Use POST /admin/reenrichment (preview first; confirm=true to queue).',
// 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 };
}); });
fastify.get('/queue-stats', async () => { fastify.get('/queue-stats', async () => {
+14 -1
View File
@@ -2,9 +2,11 @@ import { FastifyInstance } from 'fastify';
import { DbService } from '../services/db.service.js'; import { DbService } from '../services/db.service.js';
import { DiscoveryService } from '../services/discovery.service.js'; import { DiscoveryService } from '../services/discovery.service.js';
import { ImageEnrichmentService } from '../services/image-enrichment.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 { dbService } = options;
const { jobService } = options;
const discovery = new DiscoveryService(dbService); const discovery = new DiscoveryService(dbService);
const images = new ImageEnrichmentService(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) => { fastify.post('/discovery/eval', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const results = await discovery.evalCandidates(userId); 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 }); return reply.send({ evaluated: results.length, results });
}); });
+1
View File
@@ -3,6 +3,7 @@ import { DbService } from '../services/db.service.js';
const SETTING_KEYS = [ const SETTING_KEYS = [
'enrich_metadata', 'enrich_metadata',
'enrich_artist_images',
'enrich_cover_art', 'enrich_cover_art',
'enrich_genres', 'enrich_genres',
'enrich_lyrics', 'enrich_lyrics',
+114 -12
View File
@@ -9,14 +9,42 @@ interface ActivePlan {
sessionId: string; sessionId: string;
plan: Candidate[]; plan: Candidate[];
seedTrackId: string | null; 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; 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 { function planKey(userId: string): string {
return `v2:plan:${userId}`; 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 }) { export default async function v2Routes(fastify: FastifyInstance, options: { dbService: DbService; sessionDirector: SessionDirector }) {
const { dbService, sessionDirector: director } = options; 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 // 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. // 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 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<T>(userId: string, fn: () => Promise<T>): Promise<T> { async function withPlanLock<T>(userId: string, fn: () => Promise<T>): Promise<T> {
const lockKey = `v2:planlock:${userId}`; const lockKey = `v2:planlock:${userId}`;
@@ -54,16 +83,20 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
const deadline = Date.now() + 5000; const deadline = Date.now() + 5000;
let acquired = false; let acquired = false;
while (Date.now() < deadline) { 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; } if (res) { acquired = true; break; }
await new Promise((r) => setTimeout(r, 20 + Math.random() * 30)); await new Promise((r) => setTimeout(r, 20 + Math.random() * 30));
} }
if (!acquired) { if (!acquired) {
throw new Error('Timed out waiting for active-plan lock'); 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 { try {
return await fn(); return await fn();
} finally { } finally {
clearInterval(renewal);
await redisClient.eval(RELEASE_LOCK_LUA, { keys: [lockKey], arguments: [token] }); 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 { seedTrackId } = request.body as { seedTrackId?: string };
const sessionId = await dbService.createSessionState(userId, undefined, { energy: 0.5, novelty_hunger: 0.3 }); 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) }); 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) => { fastify.get('/v2/vibe/next', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; 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 result = await withPlanLock(userId, async () => {
const active = await getActivePlan(userId); const active = await getActivePlan(userId);
if (!active || active.plan.length === 0) { if (!active) {
return null; 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()!; 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 // Enrich with track details
const track = await dbService.getTrackById(next.trackId); const track = await dbService.getTrackById(next.trackId);
// Replan if running low // Replan if running low
if (active.plan.length < 5) { 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; active.plan = refill;
} }
await setActivePlan(userId, active); 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.' }); 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) => { fastify.post('/v2/vibe/feedback', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; 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) { if (!trackId || !action) {
return reply.code(400).send({ error: 'trackId and action are required' }); 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 // Route to existing handlers for evidence wiring
if (action === 'completed') { if (action === 'completed') {
@@ -148,8 +230,28 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
const planRemaining = await withPlanLock(userId, async () => { const planRemaining = await withPlanLock(userId, async () => {
const active = await getActivePlan(userId); const active = await getActivePlan(userId);
if (!active) return 0; if (!active) return 0;
const playedTrackIds = [trackId]; if (!sessionId || active.sessionId !== sessionId) return 0;
const refill = await director.replan(userId, active.sessionId, active.plan, playedTrackIds, active.seedTrackId ?? undefined); // 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; active.plan = refill;
await setActivePlan(userId, active); await setActivePlan(userId, active);
return active.plan.length; return active.plan.length;
+85
View File
@@ -8,6 +8,22 @@ function makeService(): { service: DbService; mockQuery: ReturnType<typeof vi.fn
} }
describe('DbService v2 methods', () => { describe('DbService v2 methods', () => {
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', () => { describe('upsertClaim', () => {
it('calls INSERT ... ON CONFLICT with correct parameters', async () => { it('calls INSERT ... ON CONFLICT with correct parameters', async () => {
const { service, mockQuery } = makeService(); 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', () => { describe('getFusedTrackArtists', () => {
it('reads from claim_fusion view', async () => { it('reads from claim_fusion view', async () => {
const { service, mockQuery } = makeService(); const { service, mockQuery } = makeService();
+251 -27
View File
@@ -34,7 +34,7 @@ import type {
DiversityBudget, DiversityBudget,
RepetitionRule, RepetitionRule,
} from '../db/types.js'; } 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 * from '../db/types.js';
export class DbService { export class DbService {
@@ -372,11 +372,11 @@ export class DbService {
); );
}); });
// Write evidence: hidden → negative profile (only on success) // Write evidence: hidden → negative profile (only on success), then carry
await this.recordEvidence({ // that signal through the track's artist/genre/audio identities.
await this.recordTrackEvidence({
user_id: userId, user_id: userId,
entity_type: 'track', track_id: trackId,
entity_id: trackId,
signal: 'hidden', signal: 'hidden',
profile: 'negative', profile: 'negative',
weight: -0.60, weight: -0.60,
@@ -462,10 +462,9 @@ export class DbService {
); );
// 3. Write evidence: playback_completed → longterm affinity // 3. Write evidence: playback_completed → longterm affinity
await this.recordEvidence({ await this.recordTrackEvidence({
user_id: userId, user_id: userId,
entity_type: 'track', track_id: trackId,
entity_id: trackId,
signal: 'playback_completed', signal: 'playback_completed',
profile: 'longterm', profile: 'longterm',
weight: 0.10, weight: 0.10,
@@ -480,18 +479,16 @@ export class DbService {
[userId, trackId] [userId, trackId]
); );
if ((recentPlays.rows[0]?.cnt as number) > 1) { if ((recentPlays.rows[0]?.cnt as number) > 1) {
await this.recordEvidence({ await this.recordTrackEvidence({
user_id: userId, user_id: userId,
entity_type: 'track', track_id: trackId,
entity_id: trackId,
signal: 'replay_within_24h', signal: 'replay_within_24h',
profile: 'longterm', profile: 'longterm',
weight: 0.25, weight: 0.25,
}, client); }, client);
await this.recordEvidence({ await this.recordTrackEvidence({
user_id: userId, user_id: userId,
entity_type: 'track', track_id: trackId,
entity_id: trackId,
signal: 'replay_within_24h', signal: 'replay_within_24h',
profile: 'obsession', profile: 'obsession',
weight: 0.40, weight: 0.40,
@@ -591,10 +588,9 @@ export class DbService {
[userId, trackId] [userId, trackId]
); );
// Write evidence: skip_quick → negative profile // Write evidence: skip_quick → negative profile
await this.recordEvidence({ await this.recordTrackEvidence({
user_id: userId, user_id: userId,
entity_type: 'track', track_id: trackId,
entity_id: trackId,
signal: 'skip_quick', signal: 'skip_quick',
profile: 'negative', profile: 'negative',
weight: -0.20, weight: -0.20,
@@ -613,19 +609,17 @@ export class DbService {
// Also write evidence for promoted/disliked signals // Also write evidence for promoted/disliked signals
if (action === 'promoted') { if (action === 'promoted') {
await this.recordEvidence({ await this.recordTrackEvidence({
user_id: userId, user_id: userId,
entity_type: 'track', track_id: trackId,
entity_id: trackId,
signal: 'add_to_favorites', signal: 'add_to_favorites',
profile: 'longterm', profile: 'longterm',
weight: 0.60, weight: 0.60,
}); });
} else if (action === 'disliked') { } else if (action === 'disliked') {
await this.recordEvidence({ await this.recordTrackEvidence({
user_id: userId, user_id: userId,
entity_type: 'track', track_id: trackId,
entity_id: trackId,
signal: 'hidden', signal: 'hidden',
profile: 'negative', profile: 'negative',
weight: -0.60, weight: -0.60,
@@ -688,8 +682,9 @@ export class DbService {
async createAlbum(data: Album): Promise<Album> { async createAlbum(data: Album): Promise<Album> {
const res = await this.pgClient.query( const res = await this.pgClient.query(
'INSERT INTO albums (artist_id, title, year, artwork_id) VALUES ($1, $2, $3, $4) RETURNING *', `INSERT INTO albums (artist_id, title, year, release_date, artwork_id)
[data.artist_id, data.title, data.year, data.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]; return res.rows[0];
} }
@@ -1102,6 +1097,123 @@ export class DbService {
// v2 — System B: Listener Model // 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<Array<{
entity_type: 'artist' | 'genre' | 'audio';
entity_id: string;
factor: number;
context: Record<string, unknown>;
}>> {
const queryable = client ?? this.pgClient;
const targets: Array<{
entity_type: 'artist' | 'genre' | 'audio';
entity_id: string;
factor: number;
context: Record<string, unknown>;
}> = [];
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<string, unknown>;
}, client?: Queryable): Promise<string> {
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. * 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 // which feeds 'novelty_tolerance'. Each new evidence row must also
// upsert the matching listener_belief (spec §B.4) — otherwise evidence // upsert the matching listener_belief (spec §B.4) — otherwise evidence
// accumulates but beliefs never materialise. // 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({ await this.updateListenerBelief({
user_id: evidence.user_id, user_id: evidence.user_id,
profile: evidence.profile, 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. * Get listener beliefs for a user, optionally filtered by profile/entity.
*/ */
@@ -1395,4 +1620,3 @@ export class DbService {
return res.rowCount ?? 0; return res.rowCount ?? 0;
} }
} }
@@ -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'");
});
});
+55 -6
View File
@@ -83,12 +83,17 @@ export class DiscoveryService {
const dcId = dcRes.rows[0].id; const dcId = dcRes.rows[0].id;
const relevance = Math.min(belief.value, 0.8); 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({ await this.db.upsertClaim({
subject_type: 'track', subject_type: 'discovery_candidate',
subject_id: dcId, subject_id: dcId,
predicate: 'discovery_candidate', predicate: 'discovery_candidate',
object_type: 'artist', object_type: 'artist',
object_id: row.candidate_artist_id, 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', source: 'graph_exploration',
confidence: relevance, confidence: relevance,
raw: { raw: {
@@ -134,7 +139,7 @@ export class DiscoveryService {
const claimRes = await this.db.pgClient.query<{ object_id: string; fused_value: number }>( const claimRes = await this.db.pgClient.query<{ object_id: string; fused_value: number }>(
`SELECT object_id, fused_value `SELECT object_id, fused_value
FROM claim_fusion 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' AND predicate = 'discovery_candidate'
LIMIT 1`, LIMIT 1`,
[row.id] [row.id]
@@ -143,6 +148,24 @@ export class DiscoveryService {
const relevance = claimRes.rows[0]?.fused_value ?? 0; const relevance = claimRes.rows[0]?.fused_value ?? 0;
const candidateArtistId = claimRes.rows[0]?.object_id; 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({ const noveltyBeliefs = await this.db.getListenerBeliefs({
userId, userId,
profile: 'discovery', profile: 'discovery',
@@ -160,6 +183,7 @@ export class DiscoveryService {
FROM discovery_candidates dc FROM discovery_candidates dc
JOIN claims c ON c.subject_id = dc.id JOIN claims c ON c.subject_id = dc.id
WHERE dc.status = 'acquiring' WHERE dc.status = 'acquiring'
AND c.subject_type = 'discovery_candidate'
AND c.predicate = 'discovery_candidate' AND c.predicate = 'discovery_candidate'
AND c.object_id = $1::uuid`, AND c.object_id = $1::uuid`,
[candidateArtistId] [candidateArtistId]
@@ -200,6 +224,16 @@ export class DiscoveryService {
return results; return results;
} }
/** Undo the state transition when Redis rejected an acquisition enqueue. */
async markEnqueueFailed(candidateId: string, reason: string): Promise<void> {
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 // E.4 — Probation lifecycle
// --------------------------------------------------------------- // ---------------------------------------------------------------
@@ -226,13 +260,18 @@ export class DiscoveryService {
if (completedPlays >= 3) { if (completedPlays >= 3) {
await this.db.pgClient.query( 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] [trackId]
); );
const claimRes = await this.db.pgClient.query<{ source: string }>( const claimRes = await this.db.pgClient.query<{ source: string }>(
`SELECT source FROM claims `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`, LIMIT 1`,
[trackId] [trackId]
); );
@@ -252,7 +291,9 @@ export class DiscoveryService {
if (completedPlays === 0 && skips >= 3 && daysSinceProbation > 7) { if (completedPlays === 0 && skips >= 3 && daysSinceProbation > 7) {
await this.db.pgClient.query( 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] [trackId]
); );
return 'retired'; return 'retired';
@@ -286,7 +327,7 @@ export class DiscoveryService {
`SELECT c.source, COUNT(*)::int AS cnt `SELECT c.source, COUNT(*)::int AS cnt
FROM claims c FROM claims c
JOIN tracks t ON t.id = c.subject_id JOIN tracks t ON t.id = c.subject_id
WHERE c.predicate = 'discovery_candidate' WHERE c.predicate = 'acquired_from'
AND t.probation_status = 'retained' AND t.probation_status = 'retained'
GROUP BY c.source GROUP BY c.source
ORDER BY cnt DESC` ORDER BY cnt DESC`
@@ -295,3 +336,11 @@ export class DiscoveryService {
console.log('[MetaLearning] Discovery source retention counts:', JSON.stringify(res.rows)); console.log('[MetaLearning] Discovery source retention counts:', JSON.stringify(res.rows));
} }
} }
function safeJson(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return null;
}
}
+8 -8
View File
@@ -68,7 +68,7 @@ async function comfortGenerator(db: DbService, ctx: GeneratorContext): Promise<C
AND cf.object_type = 'artist' AND cf.object_type = 'artist'
AND cf.object_id = ANY($1::uuid[]) AND cf.object_id = ANY($1::uuid[])
AND (cf.user_id = $2 OR cf.user_id = $3) 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[])) AND NOT (t.id = ANY($4::uuid[]))
) sub ) sub
WHERE sub.rn <= 2 WHERE sub.rn <= 2
@@ -141,7 +141,7 @@ async function adjacentGenerator(db: DbService, ctx: GeneratorContext): Promise<
AND cf.object_type = 'artist' AND cf.object_type = 'artist'
AND cf.object_id = ANY($1::uuid[]) AND cf.object_id = ANY($1::uuid[])
AND (cf.user_id = $2 OR cf.user_id = $3) 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[])) AND NOT (t.id = ANY($4::uuid[]))
) sub ) sub
ORDER BY RANDOM() ORDER BY RANDOM()
@@ -209,7 +209,7 @@ async function discoveryGenerator(db: DbService, ctx: GeneratorContext): Promise
AND cf.predicate IN ('credited_main_on', 'featured_on') AND cf.predicate IN ('credited_main_on', 'featured_on')
AND cf.object_type = 'artist' AND cf.object_type = 'artist'
AND cf.object_id = ANY($1::uuid[]) AND cf.object_id = ANY($1::uuid[])
WHERE t.state = 'LIBRARY' WHERE (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
AND NOT (t.id = ANY($2::uuid[])) AND NOT (t.id = ANY($2::uuid[]))
ORDER BY t.id, cf.fused_value DESC NULLS LAST ORDER BY t.id, cf.fused_value DESC NULLS LAST
) sub ) sub
@@ -273,7 +273,7 @@ async function deepDiveGenerator(db: DbService, ctx: GeneratorContext): Promise<
ROW_NUMBER() OVER (PARTITION BY t.album_id ORDER BY t.title ASC) AS rn ROW_NUMBER() OVER (PARTITION BY t.album_id ORDER BY t.title ASC) AS rn
FROM tracks t FROM tracks t
WHERE t.album_id = ANY($1::uuid[]) WHERE t.album_id = ANY($1::uuid[])
AND t.state = 'LIBRARY' AND (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
AND NOT (t.id = ANY($2::uuid[])) AND NOT (t.id = ANY($2::uuid[]))
) sub ) sub
WHERE sub.rn <= 5 WHERE sub.rn <= 5
@@ -333,7 +333,7 @@ async function revivalGenerator(db: DbService, ctx: GeneratorContext): Promise<C
AND cf.object_type = 'artist' AND cf.object_type = 'artist'
AND cf.object_id = ANY($1::uuid[]) AND cf.object_id = ANY($1::uuid[])
AND (cf.user_id = $2 OR cf.user_id = $3) 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[])) AND NOT (t.id = ANY($4::uuid[]))
ORDER BY t.id, cf.fused_value DESC NULLS LAST ORDER BY t.id, cf.fused_value DESC NULLS LAST
) sub ) sub
@@ -385,7 +385,7 @@ async function noveltyGenerator(db: DbService, ctx: GeneratorContext): Promise<C
AND cf_track.object_id = cf_edge.object_id AND cf_track.object_id = cf_edge.object_id
WHERE t.release_date IS NOT NULL WHERE t.release_date IS NOT NULL
AND t.release_date >= NOW() - INTERVAL '60 days' AND t.release_date >= 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[])) AND NOT (t.id = ANY($1::uuid[]))
) sub ) sub
ORDER BY release_date DESC ORDER BY release_date DESC
@@ -435,7 +435,7 @@ async function experimentalGenerator(db: DbService, ctx: GeneratorContext): Prom
FROM tracks t FROM tracks t
JOIN track_genre tg ON tg.track_id = t.id JOIN track_genre tg ON tg.track_id = t.id
JOIN unfamiliar_genres ug ON ug.id = tg.genre_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[])) AND NOT (t.id = ANY($2::uuid[]))
LIMIT 30 LIMIT 30
) )
@@ -491,7 +491,7 @@ async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promis
AND cf.object_type = 'artist' AND cf.object_type = 'artist'
AND cf.object_id = ANY($1::uuid[]) AND cf.object_id = ANY($1::uuid[])
AND (cf.user_id = $2 OR cf.user_id = $3) 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[])) AND NOT (t.id = ANY($4::uuid[]))
ORDER BY t.id, cf.fused_value DESC NULLS LAST ORDER BY t.id, cf.fused_value DESC NULLS LAST
) sub ) sub
+31 -4
View File
@@ -53,6 +53,19 @@ describe('generators', () => {
expect(results[0].explanation.length).toBeGreaterThanOrEqual(1); 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 () => { it('returns empty when no high-affinity artists', async () => {
const db = makeMockDb(); const db = makeMockDb();
const ctx = makeCtx({ beliefs: [ { entity_type: 'artist', entity_id: 'a1', value: 0.3, profile: 'longterm', dimension: 'affinity' } as any ] }); 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', () => { 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(); const db = makeMockDb();
(db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1' }] }); (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1' }] });
const ctx = makeCtx({ const ctx = makeCtx({
beliefs: [ { entity_type: 'artist', entity_id: 'trusted-1', value: 0.5, profile: 'longterm', dimension: 'affinity' } as any ], beliefs: [ { entity_type: 'artist', entity_id: 'trusted-1', value: 0.5, profile: 'longterm', dimension: 'affinity' } as any ],
}); });
const results = await generatorByName.novelty(db, ctx); const results = await generatorByName.novelty(db, ctx);
if (results.length > 0) { expect(results).toHaveLength(1);
expect(results[0].generatorId).toBe('novelty'); 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'");
}); });
}); });
+161 -27
View File
@@ -1,5 +1,12 @@
import { Queue, Job } from 'bullmq'; 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 { export interface JobServiceConfig {
redisUrl: string; redisUrl: string;
@@ -26,6 +33,37 @@ export interface JobHistoryEntry {
returnvalue?: unknown; 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<string, number>;
recentFailures: Array<{
id: string;
name: string;
data: Record<string, unknown>;
failedReason?: string;
timestamp: number;
finishedOn?: number;
}>;
}
export class JobService { export class JobService {
private queue: Queue; private queue: Queue;
@@ -42,9 +80,12 @@ export class JobService {
await this.queue.add('metadata_refresh', payload); await this.queue.add('metadata_refresh', payload);
} }
async enqueueAudioAnalysis(trackId: string, features: string[]) { async enqueueAudioAnalysis(trackId: string) {
const payload: AudioAnalysisJob = { trackId, features }; const payload: AudioAnalysisJob = { trackId };
await this.queue.add('audio_analysis', payload); await this.queue.add('audio_analysis', payload, {
jobId: `audio-${trackId}`,
...AUDIO_ANALYSIS_JOB_OPTIONS,
});
} }
async enqueueCleanup(reason: 'expired' | 'manual', targetFiles: string[]) { async enqueueCleanup(reason: 'expired' | 'manual', targetFiles: string[]) {
@@ -67,30 +108,123 @@ export class JobService {
await this.queue.add('reprocess_artists', payload); await this.queue.add('reprocess_artists', payload);
} }
/** async enqueueDiscoveryAcquisition(candidateId: string): Promise<void> {
* Enqueue metadata_refresh jobs for a batch of track IDs. Used by the const payload: AcquisitionJob = { candidateId };
* /admin/reenrich-tracks endpoint to re-canonicalize metadata (artist names, const jobId = `acquire-${candidateId}`;
* album titles, MBIDs, cover art) without re-scanning files from disk. // BullMQ returns a retained completed/failed job for the same id without
* // executing it. Remove terminal attempts before retrying so a candidate
* Each job is deduped by `jobId: meta-<trackId>` so re-running the endpoint // cannot be stranded in `acquiring` for the retention window.
* doesn't stack duplicate jobs. Old completed/failed jobs with the same ID const existing = await this.queue.getJob(jobId);
* are removed first so re-enrichment actually works (BullMQ otherwise treats if (existing) {
* existing jobIds as duplicates and silently skips them). const state = await existing.getState();
*/ if (state === 'completed' || state === 'failed') await existing.remove();
async enqueueMetadataRefreshBatch(trackIds: string[]): Promise<number> {
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++;
} }
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<ReenrichmentQueueResult> {
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<string, unknown>,
jobIdFor: (id: string) => string,
): Promise<EnqueueResult> {
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<EnrichmentQueueDiagnostics> {
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<string, number> = {
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<string, unknown>,
failedReason: job.failedReason,
timestamp: job.timestamp,
finishedOn: job.finishedOn,
}));
return { pending, recentFailures };
} }
async getQueueStats(): Promise<QueueStats> { async getQueueStats(): Promise<QueueStats> {
+125 -12
View File
@@ -1,5 +1,6 @@
import { DbService, ListenerBelief } from './db.service.js'; import { DbService, ListenerBelief } from './db.service.js';
import { Candidate, GeneratorContext, Generator, ALL_GENERATORS } from './generators.service.js'; import { Candidate, GeneratorContext, Generator, ALL_GENERATORS } from './generators.service.js';
import { AUDIO_PREFERENCE_BUCKETS } from '../db/types.js';
export interface FatigueState { export interface FatigueState {
artist: Map<string, number>; artist: Map<string, number>;
@@ -33,11 +34,43 @@ export interface RepetitionState {
recentArtistIds: Set<string>; recentArtistIds: Set<string>;
} }
export interface PlanBuildOptions {
/** Tracks already exposed during this Vibe session; they are ineligible. */
excludedTrackIds?: Iterable<string>;
/** Main artists already served or rejected in this Vibe session. */
excludedArtistIds?: Iterable<string>;
}
const W_ENJOY = 1.0; const W_ENJOY = 1.0;
const W_FATIGUE = 0.4; const W_FATIGUE = 0.4;
const W_DIVERSITY = 0.3; const W_DIVERSITY = 0.3;
const W_ENTROPY = 0.2; const W_ENTROPY = 0.2;
const W_REPETITION = 0.5; 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<string> = [],
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 { export class SessionDirector {
constructor(private db: DbService) {} constructor(private db: DbService) {}
@@ -98,7 +131,22 @@ export class SessionDirector {
WHERE taf.energy IS NOT NULL`, WHERE taf.energy IS NOT NULL`,
[userId] [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 // Read novelty hunger from discovery profile
const noveltyRes = await this.db.pgClient.query( const noveltyRes = await this.db.pgClient.query(
@@ -783,8 +831,21 @@ export class SessionDirector {
// --------------------------------------------------------------- // ---------------------------------------------------------------
// D.9 — Plan + replan loop // D.9 — Plan + replan loop
// --------------------------------------------------------------- // ---------------------------------------------------------------
async buildPlan(userId: string, sessionId: string, seedTrackId?: string): Promise<Candidate[]> { async buildPlan(
const allBeliefs = await this.db.getListenerBeliefs({ userId, limit: 200 }); userId: string,
sessionId: string,
seedTrackId?: string,
options: PlanBuildOptions = {}
): Promise<Candidate[]> {
// 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 // Fetch recent completed plays for anti-loop detection
const recentPlaysRes = await this.db.pgClient.query( const recentPlaysRes = await this.db.pgClient.query(
@@ -823,7 +884,7 @@ export class SessionDirector {
const budgets = await this.getBudgets(userId); const budgets = await this.getBudgets(userId);
const arcType = this.pickArc(state); const arcType = this.pickArc(state);
const planSize = 20; const planSize = PLAN_SIZE;
const slots = this.getArcSlots(arcType, planSize); const slots = this.getArcSlots(arcType, planSize);
let seedArtistId: string | null = null; let seedArtistId: string | null = null;
@@ -831,7 +892,16 @@ export class SessionDirector {
seedArtistId = await this.resolveSeedArtistId(seedTrackId) ?? null; 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<string>([
...recentPlays.map(p => p.trackId),
...(options.excludedTrackIds ?? []),
]);
if (seedTrackId) recentExclusionSet.add(seedTrackId);
const recentExclusions = [...recentExclusionSet];
const excludedArtistIds = new Set(options.excludedArtistIds ?? []);
const toleranceMap: Record<string, number> = {}; const toleranceMap: Record<string, number> = {};
const discoveryBeliefs = allBeliefs.filter(b => b.profile === 'discovery'); const discoveryBeliefs = allBeliefs.filter(b => b.profile === 'discovery');
for (const b of discoveryBeliefs) { for (const b of discoveryBeliefs) {
@@ -859,8 +929,24 @@ export class SessionDirector {
} }
const repetitionState = await this.buildRepetitionState(userId); 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( const ranked = await this.rankCandidates(
allCandidates, fatigue, budgets, state, repetitionState eligibleCandidates, fatigue, budgets, state, repetitionState
); );
const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays); const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays);
@@ -928,10 +1014,21 @@ export class SessionDirector {
sessionId: string, sessionId: string,
currentPlan: Candidate[], currentPlan: Candidate[],
playedTrackIds: string[], playedTrackIds: string[],
seedTrackId?: string seedTrackId?: string,
options: PlanBuildOptions = {}
): Promise<Candidate[]> { ): Promise<Candidate[]> {
const remainingSlots = currentPlan.filter( const excludedTrackIds = new Set<string>([
c => !playedTrackIds.includes(c.trackId) ...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) { if (remainingSlots.length >= 10 && currentPlan.length > 0) {
@@ -973,19 +1070,35 @@ export class SessionDirector {
const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays); const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays);
if (loopDim) { 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 planArtistMap = await this.loadArtistMap([...new Set(currentPlan.map(c => c.trackId))]);
const entropy = this.computeEntropy(currentPlan, c => planArtistMap.get(c.trackId) ?? 'unknown'); const entropy = this.computeEntropy(currentPlan, c => planArtistMap.get(c.trackId) ?? 'unknown');
if (Math.abs(entropy - 0.55) > 0.2) { 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 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);
} }
// --------------------------------------------------------------- // ---------------------------------------------------------------
+69 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest'; 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'; import { DbService } from './db.service.js';
function makeMockDb(overrides: Record<string, any> = {}): DbService { function makeMockDb(overrides: Record<string, any> = {}): DbService {
@@ -15,6 +15,74 @@ function makeMockDb(overrides: Record<string, any> = {}): DbService {
} }
describe('SessionDirector', () => { 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<string>;
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', () => { describe('pickArc', () => {
const director = new SessionDirector(makeMockDb()); const director = new SessionDirector(makeMockDb());
+9 -2
View File
@@ -5,7 +5,6 @@ export interface MetadataRefreshJob {
export interface AudioAnalysisJob { export interface AudioAnalysisJob {
trackId: string; trackId: string;
features: string[];
} }
export interface CleanupJob { export interface CleanupJob {
@@ -24,4 +23,12 @@ export interface ReprocessArtistsJob {
offset?: number; 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;
+12 -1
View File
@@ -57,7 +57,11 @@ services:
- backend - backend
worker: worker:
build: ./workers build:
context: ./workers
# Keep the downloader absent unless an operator intentionally opts in.
args:
INSTALL_YTDLP: "false"
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
environment: environment:
@@ -69,6 +73,13 @@ services:
DISCOGS_TOKEN: ${DISCOGS_TOKEN} DISCOGS_TOKEN: ${DISCOGS_TOKEN}
SOCKS_PROXY_URL: ${SOCKS_PROXY_URL} SOCKS_PROXY_URL: ${SOCKS_PROXY_URL}
MUSIC_DIR: /music 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 # Hard-deletion gates for the dislike lifecycle (invariant §C). All three
# default to the safe value inside cleanup.service.ts; they are listed here # default to the safe value inside cleanup.service.ts; they are listed here
# as documentation and are intentionally left unset. # as documentation and are intentionally left unset.
+1269 -1
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -6,6 +6,8 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -21,13 +23,18 @@
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "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": "^18.3.31",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.2.0", "@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"jsdom": "^25.0.1",
"postcss": "^8.5.15", "postcss": "^8.5.15",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^5.2.0" "vite": "^5.2.0",
"vitest": "^2.1.9"
} }
} }
+12 -10
View File
@@ -9,16 +9,14 @@ import { LyricsOverlay } from './LyricsOverlay';
import { Toaster } from './Toaster'; import { Toaster } from './Toaster';
import { CommandPalette } from './CommandPalette'; import { CommandPalette } from './CommandPalette';
import { KeyboardListener, useKeyboard } from '../hooks/useKeyboard'; import { KeyboardListener, useKeyboard } from '../hooks/useKeyboard';
import { Inspector, type InspectorMode } from './Inspector';
export default function AppShell() { export default function AppShell() {
const [queueOpen, setQueueOpen] = useState(false); const [queueOpen, setQueueOpen] = useState(false);
const [navigationOpen, setNavigationOpen] = useState(false);
const [lyricsOpen, setLyricsOpen] = useState(false); const [lyricsOpen, setLyricsOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false);
const [inspector, setInspector] = useState<{ mode: InspectorMode; id: string } | null>(null);
const togglePalette = useCallback(() => setPaletteOpen((p) => !p), []); const togglePalette = useCallback(() => setPaletteOpen((p) => !p), []);
const closeInspector = useCallback(() => setInspector(null), []);
// Ctrl+K — command palette (uses `code` so it works on any keyboard layout) // Ctrl+K — command palette (uses `code` so it works on any keyboard layout)
useKeyboard({ useKeyboard({
@@ -39,24 +37,28 @@ export default function AppShell() {
handler: () => window.history.forward(), handler: () => window.history.forward(),
}); });
// Esc — closes inspector, palette, etc.
useKeyboard({ useKeyboard({
code: 'Escape', code: 'Escape',
handler: () => { handler: () => {
if (inspector) closeInspector(); if (navigationOpen) setNavigationOpen(false);
else if (queueOpen) setQueueOpen(false);
else if (lyricsOpen) setLyricsOpen(false);
}, },
}); });
return ( return (
<div className="flex flex-col h-screen bg-bg0 text-text overflow-hidden"> <div className="flex h-screen h-[100dvh] flex-col overflow-hidden bg-bg0 text-text">
<KeyboardListener /> <KeyboardListener />
<TopBar onToggleCommandPalette={togglePalette} /> <TopBar
onToggleCommandPalette={togglePalette}
onToggleNavigation={() => setNavigationOpen((open) => !open)}
navigationOpen={navigationOpen}
/>
<div className="relative flex flex-1 overflow-hidden"> <div className="relative flex flex-1 overflow-hidden">
<NavRail /> <NavRail open={navigationOpen} onClose={() => setNavigationOpen(false)} />
<main className="flex-1 overflow-y-auto p-4 pb-8"> <main className="min-w-0 flex-1 overflow-y-auto p-3 pb-6 sm:p-4 sm:pb-8">
<Outlet /> <Outlet />
</main> </main>
{inspector && <Inspector mode={inspector.mode} id={inspector.id} onClose={closeInspector} />}
{queueOpen && <NowPlayingPanel onClose={() => setQueueOpen(false)} />} {queueOpen && <NowPlayingPanel onClose={() => setQueueOpen(false)} />}
{lyricsOpen && <LyricsOverlay onClose={() => setLyricsOpen(false)} />} {lyricsOpen && <LyricsOverlay onClose={() => setLyricsOpen(false)} />}
</div> </div>
+3 -7
View File
@@ -24,13 +24,9 @@ function deduplicateArtists(artists: TrackArtist[]): TrackArtist[] {
map.set(a.id, a); map.set(a.id, a);
} }
} }
// Preserve original order, skipping duplicates. // Keep the position of the selected credit and omit every duplicate. This
const seen = new Set<string>(); // makes the documented main-over-featured preference real.
return artists.filter((a) => { return artists.filter((a) => map.get(a.id) === a);
if (seen.has(a.id)) return false;
seen.add(a.id);
return true;
});
} }
/** /**
+1 -1
View File
@@ -123,7 +123,7 @@ export const AudioEngine = () => {
// this track — don't also record the implicit transition. // this track — don't also record the implicit transition.
} else { } else {
try { try {
void vibeService.feedback(prevId, completed ? 'completed' : 'skipped').catch(() => {}); void vibeService.feedback(prevId, completed ? 'completed' : 'skipped', useVibeStore.getState().activeSessionId ?? undefined).catch(() => {});
} catch { } catch {
/* best-effort */ /* best-effort */
} }
-169
View File
@@ -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<AlbumWithTracks>({
queryKey: ['album', id],
queryFn: () => albumService.getAlbum(id),
});
if (isLoading) {
return (
<div className="p-4 space-y-3">
<div className="skeleton h-40 w-full rounded" />
<div className="skeleton h-4 w-2/3" />
<div className="skeleton h-3 w-1/3" />
</div>
);
}
if (!data) return null;
const tracks = data.tracks ?? [];
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
<span className="text-xs font-semibold uppercase tracking-wider text-muted">Album</span>
<button onClick={onClose} className="text-muted hover:text-text p-0.5 rounded">
<X size={14} />
</button>
</div>
<div className="overflow-y-auto flex-1">
{/* Artwork + meta */}
<div className="p-4 space-y-3">
<div className="w-full aspect-square rounded-md overflow-hidden">
<Artwork seed={data.title} src={data.artwork_id} className="w-full h-full" rounded="md" eager />
</div>
<div>
<h2 className="text-sm font-semibold text-text truncate">{data.title}</h2>
<p className="text-xs text-secondary">{data.artist_name || 'Unknown artist'}{data.year ? ` · ${data.year}` : ''}</p>
<p className="text-xs text-muted mt-0.5">{tracks.length} tracks</p>
</div>
<Button
variant="primary"
size="sm"
icon={<Play size={14} fill="currentColor" />}
onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }}
disabled={!tracks.length}
className="w-full"
>
Play album
</Button>
</div>
{/* Track list */}
<div className="border-t border-border">
<div className="px-4 py-2 text-[10px] font-semibold uppercase tracking-wider text-muted">Tracks</div>
<div className="space-y-0.5 px-2 pb-3">
{tracks.map((t, i) => (
<TrackRow key={t.id} track={t} queue={tracks} index={i} showActions={false} />
))}
</div>
</div>
</div>
</div>
);
}
function ArtistInspector({ id, onClose }: { id: string; onClose: () => void }) {
const { data, isLoading } = useQuery<ArtistWithAlbums>({
queryKey: ['artist', id],
queryFn: () => artistService.getArtist(id),
});
if (isLoading) {
return (
<div className="p-4 space-y-3">
<div className="skeleton h-32 w-32 rounded-full mx-auto" />
<div className="skeleton h-4 w-1/2 mx-auto" />
</div>
);
}
if (!data) return null;
const albums = data.albums ?? [];
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
<span className="text-xs font-semibold uppercase tracking-wider text-muted">Artist</span>
<button onClick={onClose} className="text-muted hover:text-text p-0.5 rounded">
<X size={14} />
</button>
</div>
<div className="overflow-y-auto flex-1">
<div className="p-4 space-y-3 text-center">
<div className="w-24 h-24 rounded-full overflow-hidden mx-auto ring-2 ring-border">
<Artwork seed={data.name} src={data.image_path} className="w-full h-full" rounded="full" eager />
</div>
<div>
<h2 className="text-sm font-semibold text-text">{data.name}</h2>
<p className="text-xs text-muted">{albums.length} albums</p>
</div>
</div>
{albums.length > 0 && (
<div className="border-t border-border">
<div className="px-4 py-2 text-[10px] font-semibold uppercase tracking-wider text-muted">Albums</div>
<div className="grid grid-cols-3 gap-2 p-2">
{albums.map((album) => (
<Link
key={album.id}
to="/albums/$albumId"
params={{ albumId: album.id }}
className="flex flex-col gap-1 rounded-md p-1.5 hover:bg-surface0 transition-colors"
>
<div className="aspect-square rounded-sm overflow-hidden">
<Artwork seed={album.title} src={album.artwork_id} className="w-full h-full" />
</div>
<span className="text-xs text-text truncate">{album.title}</span>
</Link>
))}
</div>
</div>
)}
</div>
</div>
);
}
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 (
<aside className="w-80 flex flex-col border-l border-border bg-bg1 overflow-hidden shrink-0 animate-slide-in">
{mode === 'album' && <AlbumInspector id={id} onClose={onClose} />}
{mode === 'artist' && <ArtistInspector id={id} onClose={onClose} />}
{mode === 'track' && (
<div className="p-4 text-sm text-muted text-center py-10">
Track inspector coming soon
</div>
)}
</aside>
);
}
+78 -4
View File
@@ -1,10 +1,11 @@
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { useEffect, useRef, useState } from 'react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { import {
Home, Music, Disc3, Users, Tag, Compass, Home, Music, Disc3, Users, Tag, Compass,
Terminal, ShieldAlert, Terminal, ShieldAlert,
Zap, Zap,
Settings, Sparkles, Settings, Sparkles, X,
} from 'lucide-react'; } from 'lucide-react';
interface NavItem { interface NavItem {
@@ -59,15 +60,86 @@ const active =
'bg-accent/10 text-accent font-medium ' + '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"; "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<HTMLElement>(null);
const closeRef = useRef<HTMLButtonElement>(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<HTMLElement>(
'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 ( return (
<aside className="w-48 flex flex-col bg-bg1 border-r border-border shrink-0 overflow-y-auto"> <>
{open && (
<button
type="button"
className="absolute inset-0 z-30 bg-black/60 lg:hidden"
aria-label="Close navigation"
onClick={onClose}
/>
)}
<aside
ref={drawerRef}
aria-label="Main navigation"
aria-modal={!desktop || undefined}
role={desktop ? undefined : 'dialog'}
className={`absolute inset-y-0 left-0 z-40 flex w-72 max-w-[85vw] flex-col overflow-y-auto border-r border-border bg-bg1 shadow-2xl transition-transform duration-200 lg:relative lg:z-auto lg:w-48 lg:max-w-none lg:translate-x-0 lg:shadow-none ${
open ? 'translate-x-0' : '-translate-x-full'
}`}
>
{/* App branding */} {/* App branding */}
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border"> <div className="flex items-center gap-2 px-3 py-2.5 border-b border-border">
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-accent text-on-accent"> <span className="flex h-6 w-6 items-center justify-center rounded-md bg-accent text-on-accent">
<Sparkles size={14} /> <Sparkles size={14} />
</span> </span>
<span className="text-sm font-semibold text-text tracking-tight">muzick</span> <span className="text-sm font-semibold text-text tracking-tight">muzick</span>
<button
ref={closeRef}
type="button"
onClick={onClose}
className="ml-auto rounded-md p-2 text-muted hover:bg-surface0 hover:text-text lg:hidden"
aria-label="Close navigation"
>
<X size={18} />
</button>
</div> </div>
{/* Navigation */} {/* Navigation */}
@@ -85,6 +157,7 @@ export function NavRail() {
activeOptions={{ exact: exact ?? false }} activeOptions={{ exact: exact ?? false }}
activeProps={{ className: `${base} ${active}` }} activeProps={{ className: `${base} ${active}` }}
inactiveProps={{ className: `${base} ${inactive}` }} inactiveProps={{ className: `${base} ${inactive}` }}
onClick={onClose}
> >
<Icon size={15} className="flex-none transition-transform group-hover:scale-110" /> <Icon size={15} className="flex-none transition-transform group-hover:scale-110" />
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
@@ -100,6 +173,7 @@ export function NavRail() {
<div className="px-3 py-2 text-[10px] text-disabled border-t border-border"> <div className="px-3 py-2 text-[10px] text-disabled border-t border-border">
muzick · v0.1 muzick · v0.1
</div> </div>
</aside> </aside>
</>
); );
} }
@@ -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(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<NowPlayingPanel onClose={() => undefined} />
</QueryClientProvider>
);
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();
});
});
+34 -8
View File
@@ -1,4 +1,5 @@
import { X, Play, Pause, SkipBack, SkipForward, Disc3 } from 'lucide-react'; import { X, Play, Pause, SkipBack, SkipForward, Disc3 } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { usePlaybackStore } from '../store/usePlaybackStore'; import { usePlaybackStore } from '../store/usePlaybackStore';
@@ -8,6 +9,31 @@ import { TrackRow, formatDuration } from './TrackRow';
import { albumService } from '../services/albumService'; import { albumService } from '../services/albumService';
export function NowPlayingPanel({ onClose }: { onClose: () => void }) { export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
const panelRef = useRef<HTMLElement>(null);
const closeRef = useRef<HTMLButtonElement>(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<HTMLElement>(
'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 { currentTrack, queue, isPlaying, position, duration, play, pause, next, prev, setPosition } = usePlaybackStore();
const currentIdx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1; 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; const artwork = albumQ.data?.artwork_id ?? currentTrack?.artwork_id ?? null;
return ( return (
<aside className="w-96 flex flex-col border-l border-border/70 bg-bg1/80 backdrop-blur-sm overflow-hidden shrink-0"> <aside ref={panelRef} role="dialog" aria-modal="true" aria-label="Now playing queue" className="absolute inset-0 z-30 flex w-full flex-col overflow-hidden border-l border-border/70 bg-bg1 backdrop-blur-sm animate-slide-in sm:left-auto sm:w-96 lg:relative lg:z-auto lg:bg-bg1/80">
<div className="flex items-center justify-between px-4 py-3 border-b border-border/70"> <div className="flex items-center justify-between px-4 py-3 border-b border-border/70">
<span className="text-sm font-semibold text-text">Now Playing</span> <span className="text-sm font-semibold text-text">Now Playing</span>
<button onClick={onClose} className="text-muted hover:text-text p-1 rounded"> <button ref={closeRef} onClick={onClose} aria-label="Close now playing" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface1">
<X size={16} /> <X size={16} />
</button> </button>
</div> </div>
<div className="p-4 space-y-4"> <div className="p-3 space-y-3 sm:p-4 sm:space-y-4">
{currentTrack?.album_id ? ( {currentTrack?.album_id ? (
<Link to="/albums/$albumId" params={{ albumId: currentTrack.album_id }} <Link to="/albums/$albumId" params={{ albumId: currentTrack.album_id }}
className="group block aspect-square rounded-xl overflow-hidden relative shadow-lg shadow-black/40" title="Go to album"> className="group mx-auto block aspect-square w-full max-w-sm rounded-xl overflow-hidden relative shadow-lg shadow-black/40" title="Go to album">
<Artwork seed={`${currentTrack.title} ${currentTrack.artist}`} src={artwork} className="w-full h-full transition-transform group-hover:scale-105" rounded="xl" /> <Artwork seed={`${currentTrack.title} ${currentTrack.artist}`} src={artwork} className="w-full h-full transition-transform group-hover:scale-105" rounded="xl" />
<div className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/40 transition-colors"> <div className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/40 transition-colors">
<Disc3 size={28} className="text-on-accent opacity-0 group-hover:opacity-100 transition-opacity" /> <Disc3 size={28} className="text-on-accent opacity-0 group-hover:opacity-100 transition-opacity" />
</div> </div>
</Link> </Link>
) : ( ) : (
<div className="aspect-square rounded-xl overflow-hidden shadow-lg shadow-black/40"> <div className="mx-auto aspect-square w-full max-w-sm rounded-xl overflow-hidden shadow-lg shadow-black/40">
<Artwork seed={currentTrack ? `${currentTrack.title} ${currentTrack.artist}` : 'empty'} src={artwork} className="w-full h-full" rounded="xl" /> <Artwork seed={currentTrack ? `${currentTrack.title} ${currentTrack.artist}` : 'empty'} src={artwork} className="w-full h-full" rounded="xl" />
</div> </div>
)} )}
@@ -74,8 +100,8 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
</div> </div>
</div> </div>
<div className="flex items-center justify-center gap-6"> <div className="flex items-center justify-center gap-4 sm:gap-6">
<button onClick={prev} className="text-muted hover:text-text"><SkipBack size={20} /></button> <button onClick={prev} aria-label="Previous" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0"><SkipBack size={20} /></button>
<button <button
onClick={() => isPlaying ? pause() : play()} onClick={() => isPlaying ? pause() : play()}
disabled={!currentTrack} disabled={!currentTrack}
@@ -83,7 +109,7 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
> >
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />} {isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
</button> </button>
<button onClick={next} className="text-muted hover:text-text"><SkipForward size={20} /></button> <button onClick={next} aria-label="Next" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0"><SkipForward size={20} /></button>
</div> </div>
</div> </div>
+2 -2
View File
@@ -6,7 +6,7 @@ interface PanelHeaderProps {
className?: string; className?: string;
/** /**
* Title styling intent: * Title styling intent:
* - `'panel'` (default) — text-xs uppercase muted (Inspector, LyricsOverlay) * - `'panel'` (default) — text-xs uppercase muted (side panels, LyricsOverlay)
* - `'heading'` — text-sm semibold text-text (NowPlayingPanel) * - `'heading'` — text-sm semibold text-text (NowPlayingPanel)
*/ */
intent?: 'panel' | 'heading'; intent?: 'panel' | 'heading';
@@ -19,7 +19,7 @@ const TITLE_CLASSES = {
/** /**
* Overlay/panel header bar — title label with an optional close button. * Overlay/panel header bar — title label with an optional close button.
* Standardizes the pattern that was hand-rolled in Inspector (×2), * Standardizes the pattern used by side panels,
* NowPlayingPanel, LyricsOverlay, CommandPalette, and more. * NowPlayingPanel, LyricsOverlay, CommandPalette, and more.
*/ */
export function PanelHeader({ title, onClose, className = '', intent = 'panel' }: PanelHeaderProps) { export function PanelHeader({ title, onClose, className = '', intent = 'panel' }: PanelHeaderProps) {
+17 -15
View File
@@ -23,9 +23,10 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
}; };
return ( return (
<div className="glass h-20 border-t border-border/70 px-4 flex items-center gap-4 shrink-0 z-20"> <div className="glass border-t border-border/70 px-3 py-2 shrink-0 z-20 sm:h-20 sm:px-4 sm:py-0">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 sm:flex-nowrap sm:gap-4">
{/* Track info */} {/* Track info */}
<div className="flex items-center gap-3 w-64 min-w-0 shrink-0"> <div className="flex min-w-0 flex-1 items-center gap-2.5 sm:w-64 sm:flex-none sm:gap-3">
{currentTrack ? ( {currentTrack ? (
<> <>
{currentTrack.album_id ? ( {currentTrack.album_id ? (
@@ -52,7 +53,7 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
<button <button
onClick={handleDislike} onClick={handleDislike}
title="Dislike (sends to quarantine)" title="Dislike (sends to quarantine)"
className="flex-none rounded-md p-1.5 text-muted hover:bg-surface1 hover:text-red-400 transition-colors" className="flex-none rounded-md p-2 text-muted hover:bg-surface1 hover:text-red-400 transition-colors"
aria-label="Dislike — move to quarantine" aria-label="Dislike — move to quarantine"
> >
<ThumbsDown size={16} /> <ThumbsDown size={16} />
@@ -64,17 +65,17 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
</div> </div>
{/* Controls + scrubber */} {/* Controls + scrubber */}
<div className="flex-1 flex flex-col items-center gap-1"> <div className="order-3 flex basis-full flex-col items-center gap-1 sm:order-none sm:flex-1 sm:basis-auto">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<button <button
onClick={toggleShuffle} onClick={toggleShuffle}
className={`p-1.5 rounded-md transition-colors ${shuffle ? 'text-accent' : 'text-muted hover:text-text'}`} className={`rounded-md p-2 transition-colors ${shuffle ? 'text-accent' : 'text-muted hover:text-text'}`}
aria-label={shuffle ? 'Disable shuffle' : 'Enable shuffle'} aria-label={shuffle ? 'Disable shuffle' : 'Enable shuffle'}
title={shuffle ? 'Shuffle on' : 'Shuffle off'} title={shuffle ? 'Shuffle on' : 'Shuffle off'}
> >
<Shuffle size={18} /> <Shuffle size={18} />
</button> </button>
<button onClick={prev} className="text-muted hover:text-text" aria-label="Previous"> <button onClick={prev} className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0" aria-label="Previous">
<SkipBack size={20} /> <SkipBack size={20} />
</button> </button>
<button <button
@@ -85,19 +86,19 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
> >
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />} {isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
</button> </button>
<button onClick={next} className="text-muted hover:text-text" aria-label="Next"> <button onClick={next} className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0" aria-label="Next">
<SkipForward size={20} /> <SkipForward size={20} />
</button> </button>
<button <button
onClick={cycleRepeat} onClick={cycleRepeat}
className={`p-1.5 rounded-md transition-colors ${repeat !== 'none' ? 'text-accent' : 'text-muted hover:text-text'}`} className={`rounded-md p-2 transition-colors ${repeat !== 'none' ? 'text-accent' : 'text-muted hover:text-text'}`}
aria-label={`Repeat: ${repeat}`} aria-label={`Repeat: ${repeat}`}
title={repeat === 'none' ? 'Repeat off' : repeat === 'all' ? 'Repeat all' : 'Repeat one'} title={repeat === 'none' ? 'Repeat off' : repeat === 'all' ? 'Repeat all' : 'Repeat one'}
> >
{repeat === 'one' ? <Repeat1 size={18} /> : <Repeat size={18} />} {repeat === 'one' ? <Repeat1 size={18} /> : <Repeat size={18} />}
</button> </button>
</div> </div>
<div className="flex w-full max-w-lg items-center gap-2"> <div className="flex w-full max-w-lg items-center gap-1.5 sm:gap-2">
<span className="text-xs text-muted w-9 text-right tabular-nums">{formatDuration(position)}</span> <span className="text-xs text-muted w-9 text-right tabular-nums">{formatDuration(position)}</span>
<input <input
type="range" min={0} max={Math.max(duration, 0.1)} step={0.1} type="range" min={0} max={Math.max(duration, 0.1)} step={0.1}
@@ -112,18 +113,18 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
</div> </div>
{/* Volume + panel toggle */} {/* Volume + panel toggle */}
<div className="flex items-center gap-3 w-48 justify-end shrink-0"> <div className="order-2 flex items-center gap-1 justify-end shrink-0 sm:order-none sm:w-48 sm:gap-3">
<Volume2 size={18} className="text-muted flex-none" /> <Volume2 size={18} className="hidden text-muted flex-none sm:block" />
<input <input
type="range" min={0} max={1} step={0.01} value={volume} type="range" min={0} max={1} step={0.01} value={volume}
onChange={(e) => setVolume(Number(e.target.value))} onChange={(e) => setVolume(Number(e.target.value))}
className="w-20 h-1 cursor-pointer" className="hidden w-20 h-1 cursor-pointer sm:block"
aria-label="Volume" aria-label="Volume"
/> />
<button <button
onClick={onToggleLyrics} onClick={onToggleLyrics}
disabled={!currentTrack} disabled={!currentTrack}
className={`p-2 rounded-md transition-colors disabled:opacity-30 ${lyricsOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text'}`} className={`rounded-md p-2 transition-colors disabled:opacity-30 ${lyricsOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text hover:bg-surface0'}`}
aria-label="Toggle lyrics" aria-label="Toggle lyrics"
title="Lyrics" title="Lyrics"
> >
@@ -131,13 +132,14 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
</button> </button>
<button <button
onClick={onToggleQueue} onClick={onToggleQueue}
className={`p-2 rounded-md transition-colors ${queueOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text'}`} className={`rounded-md p-2 transition-colors ${queueOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text hover:bg-surface0'}`}
aria-label="Toggle queue panel" aria-label="Toggle queue panel"
title="Up Next" title="Up Next"
> >
<ListMusic size={18} /> <ListMusic size={18} />
</button> </button>
</div> </div>
</div>
</div> </div>
); );
} }
+18 -7
View File
@@ -1,11 +1,13 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Search, X, Command, ChevronRight, Wifi, WifiOff } from 'lucide-react'; import { Search, X, Command, ChevronRight, Wifi, WifiOff, Menu } from 'lucide-react';
import { useNavigate, useRouterState } from '@tanstack/react-router'; import { useNavigate, useRouterState } from '@tanstack/react-router';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { fetchHealthStatus } from '../services/healthService'; import { fetchHealthStatus } from '../services/healthService';
interface TopBarProps { interface TopBarProps {
onToggleCommandPalette: () => void; onToggleCommandPalette: () => void;
onToggleNavigation: () => void;
navigationOpen: boolean;
} }
/** Page title map for breadcrumbs */ /** Page title map for breadcrumbs */
@@ -86,7 +88,7 @@ function ConnectionStatus() {
); );
} }
export function TopBar({ onToggleCommandPalette }: TopBarProps) { export function TopBar({ onToggleCommandPalette, onToggleNavigation, navigationOpen }: TopBarProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const { pathname, urlQuery } = useRouterState({ const { pathname, urlQuery } = useRouterState({
@@ -136,14 +138,23 @@ export function TopBar({ onToggleCommandPalette }: TopBarProps) {
}; };
return ( return (
<header className="glass h-12 border-b border-border flex items-center px-3 gap-3 shrink-0 z-20"> <header className="glass h-12 border-b border-border flex items-center px-2.5 gap-2 sm:px-3 sm:gap-3 shrink-0 z-20">
<button
type="button"
onClick={onToggleNavigation}
className="rounded-md p-2 text-muted hover:bg-surface0 hover:text-text lg:hidden"
aria-label={navigationOpen ? 'Close navigation' : 'Open navigation'}
aria-expanded={navigationOpen}
>
{navigationOpen ? <X size={18} /> : <Menu size={18} />}
</button>
{/* Breadcrumbs */} {/* Breadcrumbs */}
<div className="flex items-center min-w-0 flex-none max-w-[200px]"> <div className="hidden sm:flex items-center min-w-0 flex-none max-w-[200px]">
<Breadcrumbs pathname={pathname} /> <Breadcrumbs pathname={pathname} />
</div> </div>
{/* Universal search */} {/* Universal search */}
<form onSubmit={handleSubmit} className="flex-1 max-w-md"> <form onSubmit={handleSubmit} className="min-w-0 flex-1 max-w-md">
<div className="relative group"> <div className="relative group">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted pointer-events-none transition-colors group-focus-within:text-accent" /> <Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted pointer-events-none transition-colors group-focus-within:text-accent" />
<input <input
@@ -172,7 +183,7 @@ export function TopBar({ onToggleCommandPalette }: TopBarProps) {
</form> </form>
{/* Right section */} {/* Right section */}
<div className="flex items-center gap-2 flex-none"> <div className="flex items-center gap-1.5 sm:gap-2 flex-none">
{/* Connection status */} {/* Connection status */}
<ConnectionStatus /> <ConnectionStatus />
@@ -184,7 +195,7 @@ export function TopBar({ onToggleCommandPalette }: TopBarProps) {
> >
<Command size={12} /> <Command size={12} />
<span className="hidden sm:inline">Commands</span> <span className="hidden sm:inline">Commands</span>
<kbd className="rounded border border-border bg-bg2 px-1 text-[10px] text-muted"> <kbd className="hidden md:inline rounded border border-border bg-bg2 px-1 text-[10px] text-muted">
Ctrl+K Ctrl+K
</kbd> </kbd>
</button> </button>
+33 -14
View File
@@ -3,7 +3,7 @@ import { Link, useRouter } from '@tanstack/react-router';
import type { Track } from '../types'; import type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore'; import { usePlaybackStore } from '../store/usePlaybackStore';
import { useDislikeTrack } from '../hooks/useDislikeTrack'; import { useDislikeTrack } from '../hooks/useDislikeTrack';
import { vibeService } from '../services/vibeService'; import { startVibeSession } from '../services/vibeSession';
import { Artwork } from './Artwork'; import { Artwork } from './Artwork';
import { ArtistLinks } from './ArtistLinks'; import { ArtistLinks } from './ArtistLinks';
@@ -24,9 +24,11 @@ interface TrackRowProps {
variant?: TrackRowVariant; variant?: TrackRowVariant;
/** Show a "Vibe by track" button that starts a vibe session seeded from this track. */ /** Show a "Vibe by track" button that starts a vibe session seeded from this track. */
showVibe?: boolean; showVibe?: boolean;
/** Override ordinary queue playback, for contextual actions such as Vibe seed rows. */
onSelect?: (track: Track) => void;
} }
export function TrackRow({ track, queue, showActions = true, variant = 'default', showVibe = false }: TrackRowProps) { export function TrackRow({ track, queue, showActions = true, variant = 'default', showVibe = false, onSelect }: TrackRowProps) {
const { setQueue, playTrack, play, pause, currentTrack, isPlaying } = usePlaybackStore(); const { setQueue, playTrack, play, pause, currentTrack, isPlaying } = usePlaybackStore();
const dislikeTrack = useDislikeTrack(); const dislikeTrack = useDislikeTrack();
const router = useRouter(); const router = useRouter();
@@ -34,6 +36,10 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
const compact = variant === 'compact'; const compact = variant === 'compact';
const handlePlay = () => { const handlePlay = () => {
if (onSelect) {
onSelect(track);
return;
}
if (isCurrent) { isPlaying ? pause() : play(); return; } if (isCurrent) { isPlaying ? pause() : play(); return; }
// Queue the whole list and start at this track, so Previous can walk back // Queue the whole list and start at this track, so Previous can walk back
// into the tracks before it. // into the tracks before it.
@@ -41,6 +47,10 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
playTrack(track); playTrack(track);
}; };
const playLabel = isCurrent && isPlaying
? `Pause ${track.title || 'track'}`
: `Play ${track.title || 'track'}`;
const handleDislike = (e: React.MouseEvent) => { const handleDislike = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
dislikeTrack(track.id); dislikeTrack(track.id);
@@ -48,8 +58,8 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
const handleVibe = (e: React.MouseEvent) => { const handleVibe = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
// Start a vibe session then navigate to the vibe page. // Start and hydrate the V2 plan before showing the Vibe page.
vibeService.start(track.id).then(() => { startVibeSession(track).then(() => {
router.navigate({ to: '/vibe' }); router.navigate({ to: '/vibe' });
}).catch(() => { }).catch(() => {
// Session failed — still navigate so the user can try manually. // Session failed — still navigate so the user can try manually.
@@ -59,8 +69,7 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
return ( return (
<div <div
onClick={handlePlay} className={`group flex w-full items-center gap-3 rounded-lg border transition-colors ${
className={`group flex w-full cursor-pointer items-center gap-3 rounded-lg border transition-colors ${
compact ? 'p-2' : 'p-2.5' compact ? 'p-2' : 'p-2.5'
} ${ } ${
isCurrent isCurrent
@@ -69,22 +78,31 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
}`} }`}
> >
{/* Artwork + play overlay */} {/* Artwork + play overlay */}
<div className={`relative flex flex-none items-center justify-center rounded overflow-hidden ${ <button
type="button"
onClick={handlePlay}
aria-label={playLabel}
className={`relative flex flex-none items-center justify-center rounded overflow-hidden focus-visible:z-30 ${
compact ? 'h-9 w-9' : 'h-10 w-10' compact ? 'h-9 w-9' : 'h-10 w-10'
}`}> }`}>
<Artwork seed={`${track.title} ${track.artist}`} src={track.artwork_id} className="absolute inset-0 w-full h-full" /> <Artwork seed={`${track.title} ${track.artist}`} src={track.artwork_id} className="absolute inset-0 w-full h-full" />
{isCurrent && isPlaying ? ( {isCurrent && isPlaying ? (
<Pause size={compact ? 14 : 18} className="absolute z-20 text-text opacity-100" /> <Pause size={compact ? 14 : 18} className="absolute z-20 text-text opacity-100" />
) : ( ) : (
<Play size={compact ? 14 : 18} className="absolute z-20 text-text opacity-0 group-hover:opacity-100" /> <Play size={compact ? 14 : 18} className="absolute z-20 text-text opacity-70 transition-opacity group-hover:opacity-100" />
)} )}
</div> </button>
{/* Title + artist */} {/* Title + artist */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className={`truncate font-medium ${isCurrent ? 'text-accent' : 'text-text'} ${compact ? 'text-sm' : 'text-sm'}`}> <button
type="button"
onClick={handlePlay}
className={`block max-w-full truncate rounded text-left font-medium hover:underline ${isCurrent ? 'text-accent' : 'text-text'} ${compact ? 'text-sm' : 'text-sm'}`}
aria-label={playLabel}
>
{track.title || 'Untitled'} {track.title || 'Untitled'}
</div> </button>
<ArtistLinks <ArtistLinks
artists={track.artists} artists={track.artists}
fallback={track.artist} fallback={track.artist}
@@ -95,9 +113,9 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
{/* Actions (vibe → album link → dislike) */} {/* Actions (vibe → album link → dislike) */}
{showActions && !compact && ( {showActions && !compact && (
<div className="flex flex-none items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div className="track-row-actions flex flex-none items-center gap-1">
{showVibe && ( {showVibe && (
<button onClick={handleVibe} title="Vibe by track" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-accent"> <button type="button" onClick={handleVibe} aria-label="Start a Vibe from this track" title="Vibe by track" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-accent">
<Sparkles size={16} /> <Sparkles size={16} />
</button> </button>
)} )}
@@ -106,13 +124,14 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
to="/albums/$albumId" to="/albums/$albumId"
params={{ albumId: track.album_id }} params={{ albumId: track.album_id }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
aria-label="Go to album"
title="Go to album" title="Go to album"
className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-text" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-text"
> >
<Disc3 size={16} /> <Disc3 size={16} />
</Link> </Link>
)} )}
<button onClick={handleDislike} title="Dislike" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-red-400"> <button type="button" onClick={handleDislike} aria-label={`Dislike ${track.title || 'track'}`} title="Dislike" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-red-400">
<ThumbsDown size={16} /> <ThumbsDown size={16} />
</button> </button>
</div> </div>
+53
View File
@@ -100,6 +100,25 @@ body {
#root { #root {
position: relative; position: relative;
z-index: 1; z-index: 1;
min-height: 100dvh;
}
button,
a,
input[type='range'] {
-webkit-tap-highlight-color: transparent;
}
@media (pointer: coarse) {
input[type='range']::-webkit-slider-thumb {
width: 18px;
height: 18px;
margin-top: -7px;
}
input[type='range']::-moz-range-thumb {
width: 18px;
height: 18px;
}
} }
/* ── Focus ring: 2px accent, for keyboard users only ──────────────────────── */ /* ── Focus ring: 2px accent, for keyboard users only ──────────────────────── */
@@ -217,6 +236,40 @@ html { scroll-behavior: smooth; }
} }
.group:hover .play-overlay-btn { transform: translateY(0); } .group:hover .play-overlay-btn { transform: translateY(0); }
/* Settings library actions use a compact, stateful button rather than an
unstyled native control. */
.admin-btn {
display: inline-flex;
min-height: 36px;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid var(--ethos-border-hi);
border-radius: 8px;
padding: 8px 12px;
background: var(--ethos-surface1);
color: var(--ethos-text);
font-size: 13px;
font-weight: 500;
transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
}
.admin-btn:hover:not(:disabled) {
background: var(--ethos-surface2);
border-color: color-mix(in srgb, var(--ethos-accent) 50%, transparent);
}
.admin-btn:disabled { cursor: wait; opacity: 0.65; }
.admin-btn--done { border-color: color-mix(in srgb, var(--ethos-green) 60%, transparent); color: var(--ethos-green); }
.admin-btn--error { border-color: color-mix(in srgb, var(--ethos-red) 60%, transparent); color: var(--ethos-red); }
/* Keep row actions available on touch. On pointer-and-hover devices, reveal
them when the row is hovered or any of its controls receives focus. */
.track-row-actions { opacity: 1; transition: opacity 150ms ease; }
@media (hover: hover) and (pointer: fine) {
.track-row-actions { opacity: 0; }
.group:hover .track-row-actions,
.group:focus-within .track-row-actions { opacity: 1; }
}
/* Transport button */ /* Transport button */
.transport-btn { .transport-btn {
width: 40px; width: 40px;
+21 -16
View File
@@ -1,18 +1,18 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Disc3, Sparkles } from 'lucide-react'; import { Disc3, Sparkles } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import { genreService } from '../services/genreService'; import { genreService } from '../services/genreService';
import { vibeService } from '../services/vibeService'; import { startVibeSession } from '../services/vibeSession';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import { TrackRow } from '../components/TrackRow'; import { TrackRow } from '../components/TrackRow';
import { PageContainer } from '../components/PageContainer'; import { PageContainer } from '../components/PageContainer';
import type { Genre, Track } from '../types'; import type { Genre, Track } from '../types';
export default function Discover() { export default function Discover() {
const [selected, setSelected] = useState<Genre | null>(null); const [selected, setSelected] = useState<Genre | null>(null);
const { setQueue, playTrack } = usePlaybackStore(); const [startingVibe, setStartingVibe] = useState(false);
const { setActiveSession, setSeedTrackId, setBuffer } = useVibeStore(); const [vibeError, setVibeError] = useState<string | null>(null);
const navigate = useNavigate();
const genres = useQuery<Genre[]>({ const genres = useQuery<Genre[]>({
queryKey: ['genres'], queryKey: ['genres'],
@@ -27,18 +27,17 @@ export default function Discover() {
const startGenreVibe = async () => { const startGenreVibe = async () => {
const tracks = genreTracks.data; const tracks = genreTracks.data;
if (!tracks || tracks.length === 0) return; if (!tracks || tracks.length === 0 || startingVibe) return;
const seed = tracks[0]; const seed = tracks[0];
setStartingVibe(true);
setVibeError(null);
try { try {
const { sessionId } = await vibeService.start(seed.id); await startVibeSession(seed);
setActiveSession({ sessionId, seedTrackId: seed.id }); await navigate({ to: '/vibe' });
setSeedTrackId(seed.id);
setBuffer(tracks);
setQueue(tracks);
playTrack(tracks[0]);
} catch { } catch {
setQueue(tracks); setVibeError('Could not start a Vibe from this genre. Please try again.');
playTrack(tracks[0]); } finally {
setStartingVibe(false);
} }
}; };
@@ -86,14 +85,20 @@ export default function Discover() {
<h2 className="text-xl font-semibold text-text">{selected.name}</h2> <h2 className="text-xl font-semibold text-text">{selected.name}</h2>
<button <button
onClick={() => void startGenreVibe()} onClick={() => void startGenreVibe()}
disabled={!genreTracks.data || genreTracks.data.length === 0} disabled={startingVibe || !genreTracks.data || genreTracks.data.length === 0}
className="flex items-center gap-2 rounded-lg border border-accent/60 bg-accent/10 px-3 py-1.5 text-sm font-medium text-accent transition-colors hover:bg-accent/20 disabled:opacity-50" className="flex items-center gap-2 rounded-lg border border-accent/60 bg-accent/10 px-3 py-1.5 text-sm font-medium text-accent transition-colors hover:bg-accent/20 disabled:opacity-50"
> >
<Sparkles size={16} /> <Sparkles size={16} />
Start a vibe {startingVibe ? 'Starting' : 'Start a vibe'}
</button> </button>
</div> </div>
{vibeError && (
<p className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
{vibeError}
</p>
)}
{genreTracks.isLoading ? ( {genreTracks.isLoading ? (
<p className="text-sm text-muted">Loading tracks…</p> <p className="text-sm text-muted">Loading tracks…</p>
) : genreTracks.isError ? ( ) : genreTracks.isError ? (
+40 -3
View File
@@ -21,15 +21,29 @@ const ENRICH_LABELS: Record<EnrichSettingKey, { label: string; desc: string }> =
function EnrichToggles() { function EnrichToggles() {
const [settings, setSettings] = useState<EnrichSettings | null>(null); const [settings, setSettings] = useState<EnrichSettings | null>(null);
const [saving, setSaving] = useState<EnrichSettingKey | null>(null); const [saving, setSaving] = useState<EnrichSettingKey | null>(null);
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading');
const [updateError, setUpdateError] = useState<string | null>(null);
const loadSettings = async () => {
setLoadState('loading');
setUpdateError(null);
try {
setSettings(await settingsService.load());
setLoadState('ready');
} catch {
setLoadState('error');
}
};
useEffect(() => { useEffect(() => {
settingsService.load().then(setSettings).catch(() => {}); void loadSettings();
}, []); }, []);
const toggle = async (key: EnrichSettingKey) => { const toggle = async (key: EnrichSettingKey) => {
if (!settings || saving) return; if (!settings || saving) return;
const next = !settings[key]; const next = !settings[key];
setSaving(key); setSaving(key);
setUpdateError(null);
// Optimistic update. // Optimistic update.
setSettings((prev) => prev ? { ...prev, [key]: next } : prev); setSettings((prev) => prev ? { ...prev, [key]: next } : prev);
try { try {
@@ -37,12 +51,28 @@ function EnrichToggles() {
} catch { } catch {
// Revert on failure. // Revert on failure.
setSettings((prev) => prev ? { ...prev, [key]: !next } : prev); setSettings((prev) => prev ? { ...prev, [key]: !next } : prev);
setUpdateError(`Could not update ${ENRICH_LABELS[key].label}. Your previous setting was restored.`);
} finally { } finally {
setSaving(null); setSaving(null);
} }
}; };
if (!settings) return null; if (loadState === 'loading') {
return <p className="pt-2 text-xs text-muted" role="status">Loading enrichment settings</p>;
}
if (loadState === 'error' || !settings) {
return (
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
<p className="text-xs text-red-400" role="alert">Couldn&apos;t load enrichment settings.</p>
<button type="button" onClick={() => void loadSettings()} className="rounded-lg border border-border px-3 py-1.5 text-xs text-text hover:bg-surface1">
Retry
</button>
</div>
);
}
const enabledCount = Object.values(settings).filter(Boolean).length;
return ( return (
<div className="space-y-3 pt-2 border-t border-border"> <div className="space-y-3 pt-2 border-t border-border">
@@ -53,11 +83,16 @@ function EnrichToggles() {
Which external services to query during library scan. Changes apply to Which external services to query during library scan. Changes apply to
the <strong>next scan</strong>. the <strong>next scan</strong>.
</p> </p>
<p className="text-xs text-muted" role="status" aria-live="polite">
{saving ? `Saving ${ENRICH_LABELS[saving].label}` : `${enabledCount} of ${Object.keys(ENRICH_LABELS).length} enrichment jobs enabled.`}
</p>
{updateError && <p className="text-xs text-red-400" role="alert">{updateError}</p>}
{Object.entries(ENRICH_LABELS).map(([key, { label, desc }]) => { {Object.entries(ENRICH_LABELS).map(([key, { label, desc }]) => {
const k = key as EnrichSettingKey; const k = key as EnrichSettingKey;
const on = settings[k]; const on = settings[k];
return ( return (
<button key={k} onClick={() => toggle(k)} disabled={saving === k} <button type="button" key={k} onClick={() => void toggle(k)} disabled={saving !== null}
role="switch" aria-checked={on}
className="w-full flex items-center justify-between gap-3 rounded-lg border border-border/70 px-4 py-3 text-left transition-colors hover:bg-surface1 disabled:opacity-50"> className="w-full flex items-center justify-between gap-3 rounded-lg border border-border/70 px-4 py-3 text-left transition-colors hover:bg-surface1 disabled:opacity-50">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-medium text-text">{label}</div> <div className="text-sm font-medium text-text">{label}</div>
@@ -110,9 +145,11 @@ function AdminAction({
return ( return (
<button <button
type="button"
onClick={handleClick} onClick={handleClick}
disabled={state === 'loading'} disabled={state === 'loading'}
className={`admin-btn ${state === 'done' ? 'admin-btn--done' : state === 'error' ? 'admin-btn--error' : ''}`} className={`admin-btn ${state === 'done' ? 'admin-btn--done' : state === 'error' ? 'admin-btn--error' : ''}`}
aria-live="polite"
> >
<Icon size={16} className={state === 'loading' ? 'animate-spin' : ''} /> <Icon size={16} className={state === 'loading' ? 'animate-spin' : ''} />
{state === 'loading' ? busyLabel {state === 'loading' ? busyLabel
+87 -37
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Heart, Loader2, Play, Shuffle, ThumbsDown, Sparkles, Square } from 'lucide-react'; import { Heart, Loader2, Play, Shuffle, ThumbsDown, Sparkles, Square } from 'lucide-react';
import { vibeService, fetchNextBatch } from '../services/vibeService'; import { vibeService, fetchNextBatch } from '../services/vibeService';
import { startVibeSession } from '../services/vibeSession';
import { trackService } from '../services/trackService'; import { trackService } from '../services/trackService';
import { usePlaybackStore } from '../store/usePlaybackStore'; import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore'; import { useVibeStore } from '../store/useVibeStore';
@@ -12,22 +13,29 @@ import { VibeTimeline } from '../components/VibeTimeline';
import { suppressAutoFeedback } from '../components/AudioEngine'; import { suppressAutoFeedback } from '../components/AudioEngine';
import { toast } from '../store/useToastStore'; import { toast } from '../store/useToastStore';
const INITIAL_BATCH_SIZE = 5;
const PREFETCH_THRESHOLD = 3; const PREFETCH_THRESHOLD = 3;
const PREFETCH_BATCH_SIZE = 3; const PREFETCH_BATCH_SIZE = 3;
const SEED_LIST_SIZE = 50;
function bestEffort(p: Promise<unknown>): void { function bestEffort(p: Promise<unknown>): void {
void p.catch(() => undefined); void p.catch(() => undefined);
} }
function sampleTracks(tracks: Track[], count: number): Track[] {
const sampled = [...tracks];
for (let index = sampled.length - 1; index > 0; index--) {
const pick = Math.floor(Math.random() * (index + 1));
[sampled[index], sampled[pick]] = [sampled[pick], sampled[index]];
}
return sampled.slice(0, count);
}
export default function Vibe() { export default function Vibe() {
const { currentTrack, queue, setQueue, playTrack, next: playNext, pause, setCurrentTrack } = usePlaybackStore(); const { currentTrack, queue, setQueue, next: playNext, pause, setCurrentTrack } = usePlaybackStore();
const { const {
activeSessionId, activeSessionId,
buffer, buffer,
setActiveSession, initialBatchStatus,
setSeedTrackId,
setCenterTrack,
setBuffer, setBuffer,
appendBuffer, appendBuffer,
reset, reset,
@@ -37,42 +45,44 @@ export default function Vibe() {
const [prefetching, setPrefetching] = useState(false); const [prefetching, setPrefetching] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [empty, setEmpty] = useState(false); const [empty, setEmpty] = useState(false);
const [refillStatus, setRefillStatus] = useState<'idle' | 'exhausted' | 'failed'>('idle');
const [refillAttempt, setRefillAttempt] = useState(0);
const prefetchingRef = useRef(false); const prefetchingRef = useRef(false);
const startingRef = useRef(false);
const { data: libraryTracks = [], isLoading: libraryLoading } = useQuery<Track[]>({ const { data: libraryTracks = [], isLoading: libraryLoading } = useQuery<Track[]>({
queryKey: ['library-seed'], queryKey: ['library-seed'],
queryFn: () => trackService.listTracks({ limit: 50, sort_by: 'play_count', order: 'DESC' }), // Fetch the eligible library once so Surprise me is not restricted to the
// most-played 50 tracks. The backend excludes hidden/deleted tracks.
queryFn: () => trackService.listTracks({ limit: 5000, sort_by: 'title', order: 'ASC' }),
enabled: !activeSessionId, enabled: !activeSessionId,
}); });
const seedTracks = useMemo(() => sampleTracks(libraryTracks, SEED_LIST_SIZE), [libraryTracks]);
const startSession = useCallback( const startSession = useCallback(
async (seed: Track) => { async (seed: Track) => {
if (startingRef.current) return;
startingRef.current = true;
setStarting(true); setStarting(true);
setError(null); setError(null);
setEmpty(false); setEmpty(false);
setRefillStatus('idle');
try { try {
const { sessionId } = await vibeService.start(seed.id); const result = await startVibeSession(seed);
setActiveSession({ sessionId, seedTrackId: seed.id }); if (result.tracks.length === 0) {
setSeedTrackId(seed.id);
setCenterTrack(seed);
const chunk = await fetchNextBatch(INITIAL_BATCH_SIZE);
if (chunk.length === 0) {
setBuffer([]);
setEmpty(true); setEmpty(true);
return; if (result.status === 'failed') {
setError('Could not load recommendations for this vibe. Please try another seed.');
}
} }
setBuffer(chunk);
setQueue(chunk);
playTrack(chunk[0]);
} catch { } catch {
setError('Could not start a vibe session. Please try again.'); setError('Could not start a vibe session. Please try again.');
reset();
} finally { } finally {
startingRef.current = false;
setStarting(false); setStarting(false);
} }
}, },
[setActiveSession, setSeedTrackId, setCenterTrack, setBuffer, setQueue, playTrack, reset] []
); );
const startFromCurrent = useCallback(() => { const startFromCurrent = useCallback(() => {
@@ -91,26 +101,36 @@ export default function Vibe() {
useEffect(() => { useEffect(() => {
if (!activeSessionId || prefetchingRef.current) return; if (!activeSessionId || prefetchingRef.current) return;
if (initialBatchStatus === 'loading') return;
if (refillStatus !== 'idle') return;
if (remaining > PREFETCH_THRESHOLD) return; if (remaining > PREFETCH_THRESHOLD) return;
prefetchingRef.current = true; prefetchingRef.current = true;
setPrefetching(true); setPrefetching(true);
fetchNextBatch(PREFETCH_BATCH_SIZE) fetchNextBatch(PREFETCH_BATCH_SIZE, activeSessionId)
.then((chunk) => { .then((result) => {
if (chunk.length > 0) { if (result.tracks.length > 0) {
appendBuffer(chunk);
const current = usePlaybackStore.getState().queue; const current = usePlaybackStore.getState().queue;
const currentIds = new Set(current.map((t) => t.id)); const currentIds = new Set(current.map((t) => t.id));
const fresh = chunk.filter((t) => !currentIds.has(t.id)); const fresh = result.tracks.filter((t) => !currentIds.has(t.id));
if (fresh.length > 0) setQueue([...current, ...fresh]); if (fresh.length > 0) {
appendBuffer(fresh);
setQueue([...current, ...fresh]);
}
if (result.status === 'exhausted' || fresh.length === 0) {
setRefillStatus('exhausted');
}
} else if (result.status === 'exhausted') {
setRefillStatus('exhausted');
} else {
setRefillStatus('failed');
} }
}) })
.catch(() => undefined)
.finally(() => { .finally(() => {
prefetchingRef.current = false; prefetchingRef.current = false;
setPrefetching(false); setPrefetching(false);
}); });
}, [activeSessionId, remaining, appendBuffer, setQueue]); }, [activeSessionId, initialBatchStatus, remaining, appendBuffer, refillAttempt, refillStatus, setQueue]);
// Trim buffer to prevent unbounded growth — keep only from currentTrack onward. // Trim buffer to prevent unbounded growth — keep only from currentTrack onward.
useEffect(() => { useEffect(() => {
@@ -123,14 +143,14 @@ export default function Vibe() {
const handleKeep = useCallback(() => { const handleKeep = useCallback(() => {
if (currentTrack) { if (currentTrack) {
bestEffort(vibeService.feedback(currentTrack.id, 'promoted')); bestEffort(vibeService.feedback(currentTrack.id, 'promoted', activeSessionId ?? undefined));
toast.success(`Kept "${currentTrack.title}"`); toast.success(`Kept "${currentTrack.title}"`);
} }
}, [currentTrack]); }, [currentTrack]);
const handleDislike = useCallback(() => { const handleDislike = useCallback(() => {
if (currentTrack) { if (currentTrack) {
bestEffort(vibeService.feedback(currentTrack.id, 'disliked')); bestEffort(vibeService.feedback(currentTrack.id, 'disliked', activeSessionId ?? undefined));
// AudioEngine would otherwise also record a 'skipped' on the track // AudioEngine would otherwise also record a 'skipped' on the track
// change caused by playNext() below — suppress that duplicate. // change caused by playNext() below — suppress that duplicate.
suppressAutoFeedback(currentTrack.id); suppressAutoFeedback(currentTrack.id);
@@ -146,8 +166,14 @@ export default function Vibe() {
reset(); reset();
setEmpty(false); setEmpty(false);
setError(null); setError(null);
setRefillStatus('idle');
}, [reset, pause, setQueue, setCurrentTrack]); }, [reset, pause, setQueue, setCurrentTrack]);
const retryRefill = useCallback(() => {
setRefillStatus('idle');
setRefillAttempt((attempt) => attempt + 1);
}, []);
const upcoming = currentTrack const upcoming = currentTrack
? (() => { ? (() => {
const idx = buffer.findIndex((t) => t.id === currentTrack.id); const idx = buffer.findIndex((t) => t.id === currentTrack.id);
@@ -210,24 +236,25 @@ export default function Vibe() {
? 'Loading your library…' ? 'Loading your library…'
: libraryTracks.length === 0 : libraryTracks.length === 0
? 'No library tracks available to seed a vibe.' ? 'No library tracks available to seed a vibe.'
: 'Start from a random track in your library.'} : `Start from a random track across ${libraryTracks.length.toLocaleString()} library tracks.`}
</div> </div>
</div> </div>
{starting && <Loader2 size={18} className="animate-spin text-muted" />} {starting && <Loader2 size={18} className="animate-spin text-muted" />}
</button> </button>
</div> </div>
{!libraryLoading && libraryTracks.length > 0 && ( {!libraryLoading && seedTracks.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-sm font-semibold text-muted">Or pick a seed track</h2> <h2 className="text-sm font-semibold text-muted">Or pick a seed track</h2>
<ul className="max-h-72 space-y-1 overflow-y-auto"> <ul className="max-h-72 space-y-1 overflow-y-auto">
{libraryTracks.map((track, index) => ( {seedTracks.map((track, index) => (
<li key={track.id}> <li key={track.id}>
<TrackRow <TrackRow
track={track} track={track}
queue={libraryTracks} queue={seedTracks}
index={index} index={index}
showActions={false} showActions={false}
onSelect={startSession}
/> />
</li> </li>
))} ))}
@@ -256,9 +283,32 @@ export default function Vibe() {
</button> </button>
</header> </header>
{empty && ( {(empty || initialBatchStatus === 'exhausted' || initialBatchStatus === 'failed') && (
<div className="rounded-lg border border-border bg-surface0 p-4 text-sm text-muted"> <div className="rounded-lg border border-border bg-surface0 p-4 text-sm text-muted">
No recommendations came back for this seed yet. Try ending and starting a different vibe. {initialBatchStatus === 'failed'
? 'Could not load recommendations for this Vibe. Try starting a different one.'
: 'No recommendations came back for this seed yet. Try ending and starting a different vibe.'}
</div>
)}
{error && (
<div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
{error}
</div>
)}
{refillStatus === 'exhausted' && !empty && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-200">
This Vibe has no new recommendations to add. Playback will stop when the current queue ends; start a new Vibe to continue.
</div>
)}
{refillStatus === 'failed' && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
<span>Couldn&apos;t refresh the Vibe recommendations. Playback will stop when the current queue ends.</span>
<button onClick={retryRefill} className="flex-none rounded border border-red-400/50 px-2 py-1 text-xs hover:bg-red-500/10">
Try again
</button>
</div> </div>
)} )}
+32
View File
@@ -0,0 +1,32 @@
import { AxiosError } from 'axios';
import { describe, expect, it, vi } from 'vitest';
import type { Track } from '../types';
import { fetchNextBatch, vibeService } from './vibeService';
const track = (id: string): Track => ({
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist',
album_id: 'album', duration: 180, state: 'LIBRARY', source_type: 'MANUAL',
play_count: 0, skip_count: 0, dislike_count: 0,
});
function responseError(status: number, code?: string) {
return new AxiosError('request failed', undefined, undefined, undefined, {
data: code ? { code } : {}, status, statusText: 'error', headers: {}, config: {} as never,
});
}
describe('fetchNextBatch', () => {
it('uses the supplied session id and treats VIBE_PLAN_EXHAUSTED as terminal', async () => {
const next = vi.spyOn(vibeService, 'next')
.mockResolvedValueOnce({ track: track('one'), explanation: null, planRemaining: 0 })
.mockRejectedValueOnce(responseError(409, 'VIBE_PLAN_EXHAUSTED'));
await expect(fetchNextBatch(3, 'session-a')).resolves.toEqual({ tracks: [track('one')], status: 'exhausted' });
expect(next).toHaveBeenCalledWith('session-a');
});
it('does not disguise a missing or replaced session as normal exhaustion', async () => {
vi.spyOn(vibeService, 'next').mockRejectedValue(responseError(404));
await expect(fetchNextBatch(1, 'expired-session')).resolves.toEqual({ tracks: [], status: 'failed' });
});
});
+25 -10
View File
@@ -1,4 +1,5 @@
import api from './api'; import api from './api';
import axios from 'axios';
import type { Track } from '../types'; import type { Track } from '../types';
// A candidate from the v2 recommendation plan. The plan is stored server-side // A candidate from the v2 recommendation plan. The plan is stored server-side
@@ -21,6 +22,13 @@ export interface VibeNextResponse {
planRemaining: number; planRemaining: number;
} }
export type VibeBatchStatus = 'complete' | 'exhausted' | 'failed';
export interface VibeBatchResult {
tracks: Track[];
status: VibeBatchStatus;
}
export type VibeFeedbackAction = 'completed' | 'skipped' | 'promoted' | 'disliked'; export type VibeFeedbackAction = 'completed' | 'skipped' | 'promoted' | 'disliked';
// V2 recommendation engine. The backend stores the plan in Redis (2h TTL) and // V2 recommendation engine. The backend stores the plan in Redis (2h TTL) and
@@ -35,16 +43,16 @@ export const vibeService = {
// GET /api/v2/vibe/next -> { track, explanation, planRemaining } // GET /api/v2/vibe/next -> { track, explanation, planRemaining }
// Returns one track at a time, shifting the server-side plan. // Returns one track at a time, shifting the server-side plan.
// 404 if no active plan — caller should handle gracefully. // 404 if no active plan — caller should handle gracefully.
async next(): Promise<VibeNextResponse> { async next(sessionId: string): Promise<VibeNextResponse> {
const res = await api.get<VibeNextResponse>('/v2/vibe/next'); const res = await api.get<VibeNextResponse>('/v2/vibe/next', { params: { sessionId } });
return res.data; return res.data;
}, },
// POST /api/v2/vibe/feedback { trackId, action } -> { status, planRemaining } // POST /api/v2/vibe/feedback { trackId, action } -> { status, planRemaining }
// Action 'promoted' also calls addFavorite; 'disliked' also calls dislikeTrack. // Action 'promoted' also calls addFavorite; 'disliked' also calls dislikeTrack.
// Triggers replan of the remaining plan. // Triggers replan of the remaining plan.
async feedback(trackId: string, action: VibeFeedbackAction): Promise<{ status: string; planRemaining: number }> { async feedback(trackId: string, action: VibeFeedbackAction, sessionId?: string): Promise<{ status: string; planRemaining: number }> {
const res = await api.post<{ status: string; planRemaining: number }>('/v2/vibe/feedback', { trackId, action }); const res = await api.post<{ status: string; planRemaining: number }>('/v2/vibe/feedback', { trackId, action, sessionId });
return res.data; return res.data;
}, },
@@ -58,16 +66,23 @@ export const vibeService = {
// Fetch N tracks from the v2 plan sequentially. Each call to /next shifts the // Fetch N tracks from the v2 plan sequentially. Each call to /next shifts the
// server-side plan, so calls must be sequential (not parallel). Stops early on // server-side plan, so calls must be sequential (not parallel). Stops early on
// 404 (plan exhausted or expired). // 409/VIBE_PLAN_EXHAUSTED is a normal terminal condition. A missing/replaced
export async function fetchNextBatch(count: number): Promise<Track[]> { // session is intentionally reported as a failure so callers can preserve the
// current playback state rather than pretending the plan completed cleanly.
export async function fetchNextBatch(count: number, sessionId: string): Promise<VibeBatchResult> {
const tracks: Track[] = []; const tracks: Track[] = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
try { try {
const { track } = await vibeService.next(); const { track } = await vibeService.next(sessionId);
tracks.push(track); tracks.push(track);
} catch { } catch (error) {
break; return {
tracks,
status: axios.isAxiosError(error) && error.response?.status === 409 &&
(error.response.data as { code?: string } | undefined)?.code === 'VIBE_PLAN_EXHAUSTED'
? 'exhausted' : 'failed',
};
} }
} }
return tracks; return { tracks, status: 'complete' };
} }
+56
View File
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
const { next, start } = vi.hoisted(() => ({ next: vi.fn(), start: vi.fn() }));
vi.mock('./vibeService', () => ({
vibeService: { start, next },
fetchNextBatch: async (count: number, sessionId: string) => {
const tracks: Track[] = [];
for (let index = 0; index < count; index++) {
try { tracks.push((await next(sessionId)).track); } catch { return { tracks, status: 'failed' as const }; }
}
return { tracks, status: 'complete' as const };
},
}));
import { startVibeSession } from './vibeSession';
const track = (id: string): Track => ({
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist',
album_id: 'album', duration: 180, state: 'LIBRARY', source_type: 'MANUAL',
play_count: 0, skip_count: 0, dislike_count: 0,
});
describe('startVibeSession', () => {
beforeEach(() => {
vi.clearAllMocks();
useVibeStore.getState().reset();
usePlaybackStore.setState({ currentTrack: null, queue: [], currentIndex: -1, isPlaying: false });
});
it('does not replace a working Vibe when the new plan cannot hydrate', async () => {
const old = track('old');
useVibeStore.getState().setActiveSession({ sessionId: 'old-session', seedTrackId: old.id });
usePlaybackStore.getState().setQueue([old]);
usePlaybackStore.getState().playTrack(old);
start.mockResolvedValue({ sessionId: 'new-session', plan: [] });
next.mockRejectedValue(new Error('missing session'));
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ tracks: [], status: 'failed' });
expect(useVibeStore.getState().activeSessionId).toBe('old-session');
expect(usePlaybackStore.getState().currentTrack?.id).toBe('old');
});
it('serializes rapid starts and hydrates only one session', async () => {
const recommended = track('recommended');
start.mockResolvedValue({ sessionId: 'session-a', plan: [] });
next.mockResolvedValue({ track: recommended });
await Promise.all([startVibeSession(track('seed-a')), startVibeSession(track('seed-b'))]);
expect(start).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledWith('session-a');
expect(useVibeStore.getState().activeSessionId).toBe('session-a');
});
});
+51
View File
@@ -0,0 +1,51 @@
import type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import { fetchNextBatch, vibeService, type VibeBatchStatus } from './vibeService';
export const INITIAL_VIBE_BATCH_SIZE = 5;
export interface StartedVibeSession {
status: VibeBatchStatus;
tracks: Track[];
}
let startInFlight: Promise<StartedVibeSession> | null = null;
/**
* Start a V2 plan and immediately hand its first recommendations to playback.
* Keeping this in one place prevents entry points from accidentally replacing a
* generated Vibe queue with a normal browse queue.
*/
export async function startVibeSession(seed: Track): Promise<StartedVibeSession> {
if (startInFlight) return startInFlight;
startInFlight = beginVibeSession(seed);
try {
return await startInFlight;
} finally {
startInFlight = null;
}
}
async function beginVibeSession(seed: Track): Promise<StartedVibeSession> {
const { sessionId } = await vibeService.start(seed.id);
const vibe = useVibeStore.getState();
// Do not replace a working Vibe until the new session has produced a usable
// initial batch. This also keeps the page prefetcher attached to the old
// session while this request is in flight.
const result = await fetchNextBatch(INITIAL_VIBE_BATCH_SIZE, sessionId);
if (result.tracks.length === 0) return result;
vibe.setInitialBatchStatus('loading');
vibe.setActiveSession({ sessionId, seedTrackId: seed.id });
vibe.setSeedTrackId(seed.id);
vibe.setCenterTrack(seed);
vibe.setBuffer(result.tracks);
vibe.setInitialBatchStatus('idle');
const playback = usePlaybackStore.getState();
playback.setQueue(result.tracks);
playback.playTrack(result.tracks[0]);
return result;
}
+5
View File
@@ -9,11 +9,14 @@ interface VibeState {
seedTrackId: string | null; seedTrackId: string | null;
centerTrack: Track | null; centerTrack: Track | null;
buffer: Track[]; // lookahead buffer of upcoming recommended tracks buffer: Track[]; // lookahead buffer of upcoming recommended tracks
/** Outcome of the first V2 batch, including sessions initiated from Discover. */
initialBatchStatus: 'idle' | 'loading' | 'exhausted' | 'failed';
setActiveSession: (session: VibeSession | null) => void; setActiveSession: (session: VibeSession | null) => void;
setSeedTrackId: (seedTrackId: string | null) => void; setSeedTrackId: (seedTrackId: string | null) => void;
setCenterTrack: (track: Track | null) => void; setCenterTrack: (track: Track | null) => void;
setBuffer: (buffer: Track[]) => void; setBuffer: (buffer: Track[]) => void;
setInitialBatchStatus: (status: VibeState['initialBatchStatus']) => void;
appendBuffer: (tracks: Track[]) => void; appendBuffer: (tracks: Track[]) => void;
shiftBuffer: () => Track | undefined; shiftBuffer: () => Track | undefined;
reset: () => void; reset: () => void;
@@ -24,6 +27,7 @@ const initialState = {
seedTrackId: null as string | null, seedTrackId: null as string | null,
centerTrack: null as Track | null, centerTrack: null as Track | null,
buffer: [] as Track[], buffer: [] as Track[],
initialBatchStatus: 'idle' as const,
}; };
export const useVibeStore = create<VibeState>((set, get) => ({ export const useVibeStore = create<VibeState>((set, get) => ({
@@ -39,6 +43,7 @@ export const useVibeStore = create<VibeState>((set, get) => ({
setSeedTrackId: (seedTrackId) => set({ seedTrackId }), setSeedTrackId: (seedTrackId) => set({ seedTrackId }),
setCenterTrack: (centerTrack) => set({ centerTrack }), setCenterTrack: (centerTrack) => set({ centerTrack }),
setBuffer: (buffer) => set({ buffer }), setBuffer: (buffer) => set({ buffer }),
setInitialBatchStatus: (initialBatchStatus) => set({ initialBatchStatus }),
appendBuffer: (tracks) => set((state) => ({ buffer: [...state.buffer, ...tracks] })), appendBuffer: (tracks) => set((state) => ({ buffer: [...state.buffer, ...tracks] })),
shiftBuffer: () => { shiftBuffer: () => {
+5
View File
@@ -0,0 +1,5 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => cleanup());
+5
View File
@@ -3,6 +3,11 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
clearMocks: true,
},
server: { server: {
proxy: { proxy: {
'/api': 'http://localhost:3000', '/api': 'http://localhost:3000',
+6 -1
View File
@@ -1,5 +1,10 @@
FROM node:20-slim FROM node:20-slim
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/* # yt-dlp is intentionally opt-in. Enabling its image build alone does not make
# acquisition live: the worker additionally requires explicit runtime gates.
ARG INSTALL_YTDLP=false
RUN apt-get update && apt-get install -y ffmpeg \
&& if [ "$INSTALL_YTDLP" = "true" ]; then apt-get install -y yt-dlp; fi \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install --legacy-peer-deps RUN npm install --legacy-peer-deps
+289
View File
@@ -0,0 +1,289 @@
import { access, mkdir, realpath, stat } from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import { spawn } from 'node:child_process';
import path from 'node:path';
import type { Pool } from 'pg';
import { ScannerService } from './scanner.service.js';
type CandidateRow = {
id: string;
source: string;
notes: unknown;
status: string;
};
type AcquisitionSpec = {
url: string;
expectedTitle?: string;
expectedArtist?: string;
};
export type AcquisitionResult =
| { status: 'acquired'; trackId: string }
| { status: 'disabled'; reason: string }
| { status: 'failed'; reason: string };
export interface AcquisitionConfig {
enabled: boolean;
ytDlpPath: string;
musicDir: string;
destinationDir: string;
allowedHosts: Set<string>;
timeoutMs: number;
maxFileBytes: number;
}
/**
* System E is intentionally off unless every gate is configured. In
* particular, a bare `yt-dlp` command is not accepted: an absolute executable
* path avoids PATH surprises in a long-running, network-enabled worker.
*/
export function acquisitionConfigFromEnv(env = process.env): AcquisitionConfig {
const musicDir = path.resolve(env.MUSIC_DIR || '/music');
const requestedDestination = env.MUZICK_ACQUISITION_DIR || '.recommendations';
const destinationDir = path.resolve(musicDir, requestedDestination);
return {
enabled: env.MUZICK_ACQUISITION_ENABLED === 'true',
ytDlpPath: env.MUZICK_ACQUISITION_YTDLP_PATH || '',
musicDir,
destinationDir,
allowedHosts: new Set(
(env.MUZICK_ACQUISITION_ALLOWED_HOSTS || '')
.split(',').map((value) => value.trim().toLowerCase()).filter(Boolean)
),
timeoutMs: Math.max(10_000, Math.min(Number(env.MUZICK_ACQUISITION_TIMEOUT_MS || 120_000), 15 * 60_000)),
maxFileBytes: Math.max(1_000_000, Math.min(Number(env.MUZICK_ACQUISITION_MAX_FILE_BYTES || 250 * 1024 * 1024), 2 * 1024 * 1024 * 1024)),
};
}
function isWithin(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
}
function normalizedMetadata(value: string): string {
return value.toLowerCase().normalize('NFKD').replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
}
function matchesExpected(actual: string, expected: string | undefined): boolean {
if (!expected) return true;
const left = normalizedMetadata(actual);
const right = normalizedMetadata(expected);
return left === right || left.includes(right) || right.includes(left);
}
/** Parse only a deliberately supplied HTTPS source URL; never accept argv/query strings. */
export function parseAcquisitionSpec(notes: unknown, allowedHosts: Set<string>): AcquisitionSpec {
const notesValue = typeof notes === 'string' ? JSON.parse(notes) : notes;
const candidate = (notesValue as { acquisition?: unknown } | null)?.acquisition;
if (!candidate || typeof candidate !== 'object') {
throw new Error('candidate has no resolved acquisition source');
}
const { url, expectedTitle, expectedArtist } = candidate as Record<string, unknown>;
if (typeof url !== 'string') throw new Error('acquisition source URL is required');
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error('acquisition source URL is invalid');
}
if (parsed.protocol !== 'https:') throw new Error('acquisition source URL must use HTTPS');
if (parsed.username || parsed.password) throw new Error('acquisition source URL must not contain credentials');
if (!allowedHosts.has(parsed.hostname.toLowerCase())) {
throw new Error(`acquisition host is not allow-listed: ${parsed.hostname}`);
}
return {
url: parsed.toString(),
expectedTitle: typeof expectedTitle === 'string' ? expectedTitle.slice(0, 500) : undefined,
expectedArtist: typeof expectedArtist === 'string' ? expectedArtist.slice(0, 500) : undefined,
};
}
async function runDownloader(executable: string, args: string[], timeoutMs: number): Promise<string> {
return await new Promise((resolve, reject) => {
const child = spawn(executable, args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs);
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
child.once('error', (err) => {
clearTimeout(timer);
reject(err);
});
child.once('close', (code, signal) => {
clearTimeout(timer);
if (code === 0) return resolve(stdout);
const detail = (stderr || `exit=${code ?? 'null'} signal=${signal ?? 'none'}`).trim().slice(0, 1000);
reject(new Error(`downloader failed: ${detail}`));
});
});
}
export class AcquisitionService {
private readonly config: AcquisitionConfig;
constructor(
private readonly pgPool: Pool,
private readonly scanner: ScannerService,
config = acquisitionConfigFromEnv(),
) {
this.config = config;
}
private async disabledReason(): Promise<string | null> {
if (!this.config.enabled) return 'MUZICK_ACQUISITION_ENABLED is not true';
if (!path.isAbsolute(this.config.ytDlpPath)) return 'MUZICK_ACQUISITION_YTDLP_PATH must be an absolute executable path';
if (this.config.allowedHosts.size === 0) return 'MUZICK_ACQUISITION_ALLOWED_HOSTS is empty';
if (!isWithin(this.config.musicDir, this.config.destinationDir)) return 'MUZICK_ACQUISITION_DIR must stay inside MUSIC_DIR';
try {
await access(this.config.ytDlpPath, fsConstants.X_OK);
} catch {
return `downloader is not executable: ${this.config.ytDlpPath}`;
}
return null;
}
private async setStatus(candidateId: string, status: string, error: string | null): Promise<void> {
await this.pgPool.query(
`UPDATE discovery_candidates
SET status = $2, last_eval_at = NOW(), last_error = $3
WHERE id = $1`,
[candidateId, status, error?.slice(0, 2000) ?? null]
);
}
async acquire(candidateId: string): Promise<AcquisitionResult> {
const rowResult = await this.pgPool.query<CandidateRow>(
`SELECT id, source, notes, status FROM discovery_candidates WHERE id = $1`, [candidateId]
);
const candidate = rowResult.rows[0];
if (!candidate) return { status: 'failed', reason: 'candidate does not exist' };
if (candidate.status !== 'acquiring') {
return { status: 'failed', reason: `candidate is not acquiring (status=${candidate.status})` };
}
const disabled = await this.disabledReason();
if (disabled) {
await this.setStatus(candidate.id, 'acquisition_disabled', disabled);
return { status: 'disabled', reason: disabled };
}
let spec: AcquisitionSpec;
try {
spec = parseAcquisitionSpec(candidate.notes, this.config.allowedHosts);
} catch (err) {
const reason = err instanceof Error ? err.message : 'invalid acquisition source';
await this.setStatus(candidate.id, 'awaiting_resolution', reason);
return { status: 'failed', reason };
}
const sourceTrust = await this.pgPool.query<{ key: string }>(
'SELECT key FROM source_trust WHERE key = $1', [candidate.source]
);
if (sourceTrust.rows.length === 0) {
const reason = `candidate source is not registered in source_trust: ${candidate.source}`;
await this.setStatus(candidate.id, 'failed', reason);
return { status: 'failed', reason };
}
const candidateDir = path.join(this.config.destinationDir, candidate.id);
if (!isWithin(this.config.destinationDir, candidateDir)) {
const reason = 'candidate destination escaped acquisition directory';
await this.setStatus(candidate.id, 'failed', reason);
return { status: 'failed', reason };
}
try {
await mkdir(candidateDir, { recursive: true });
const resolvedDestination = await realpath(this.config.destinationDir);
const resolvedCandidateDir = await realpath(candidateDir);
if (!isWithin(resolvedDestination, resolvedCandidateDir)) {
throw new Error('resolved candidate destination escaped acquisition directory');
}
await this.pgPool.query(
`UPDATE discovery_candidates
SET status = 'downloading', acquisition_attempts = acquisition_attempts + 1,
last_eval_at = NOW(), last_error = NULL
WHERE id = $1`, [candidate.id]
);
// Arguments are fixed by us. The sole untrusted value is the validated URL
// and spawn() is invoked with shell:false, so no command interpolation is
// possible. One URL / one item is deliberate: playlists are out of scope.
const outputTemplate = path.join(candidateDir, '%(id)s.%(ext)s');
const stdout = await runDownloader(this.config.ytDlpPath, [
'--no-playlist', '--no-progress', '--restrict-filenames',
'--extract-audio', '--audio-format', 'mp3', '--audio-quality', '5',
'--output', outputTemplate,
'--print', 'after_move:filepath',
'--', spec.url,
], this.config.timeoutMs);
const reportedPaths = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
if (reportedPaths.length !== 1) throw new Error('downloader did not report exactly one output file');
const outputPath = path.resolve(reportedPaths[0]);
if (!isWithin(resolvedCandidateDir, outputPath) || path.extname(outputPath).toLowerCase() !== '.mp3') {
throw new Error('downloader reported an unsafe or unsupported output path');
}
await access(outputPath, fsConstants.R_OK);
const outputStat = await stat(outputPath);
if (!outputStat.isFile() || outputStat.size <= 0 || outputStat.size > this.config.maxFileBytes) {
throw new Error(`downloaded file violates size limit (${this.config.maxFileBytes} bytes)`);
}
await this.setStatus(candidate.id, 'scanning', null);
const scan = await this.scanner.scanDirectory(candidateDir, {
sourceType: 'RECOMMENDATION', probationStatus: 'probation', candidateId: candidate.id,
});
if (scan.trackIds.length !== 1) {
throw new Error(`scanner created ${scan.trackIds.length} tracks; expected exactly one`);
}
const trackId = scan.trackIds[0];
const metadata = await this.pgPool.query<{ title: string; artist: string }>(
'SELECT title, artist FROM tracks WHERE id = $1', [trackId]
);
const scanned = metadata.rows[0];
if (!scanned || !matchesExpected(scanned.title, spec.expectedTitle) || !matchesExpected(scanned.artist, spec.expectedArtist)) {
await this.pgPool.query(
`UPDATE tracks SET state = 'HIDDEN', probation_status = 'retired'
WHERE id = $1`, [trackId]
);
throw new Error('downloaded metadata does not match the vetted candidate');
}
const client = await this.pgPool.connect();
try {
await client.query('BEGIN');
await client.query(
`UPDATE discovery_candidates
SET status = 'acquired', acquired_track_id = $2, acquired_at = NOW(),
last_eval_at = NOW(), last_error = NULL
WHERE id = $1`, [candidate.id, trackId]
);
await client.query(
`INSERT INTO claims (
subject_type, subject_id, predicate, object_type, object_id,
source, confidence, raw
) VALUES ('track', $1::uuid, 'acquired_from', 'discovery_candidate', $2::uuid,
$3, 1.0, $4::jsonb)
ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
DO UPDATE SET confidence = EXCLUDED.confidence,
last_reinforced_at = NOW(), raw = EXCLUDED.raw`,
[trackId, candidate.id, candidate.source, JSON.stringify({ expectedTitle: spec.expectedTitle, expectedArtist: spec.expectedArtist })]
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
return { status: 'acquired', trackId };
} catch (err) {
const reason = err instanceof Error ? err.message : 'acquisition failed';
await this.setStatus(candidate.id, 'failed', reason);
return { status: 'failed', reason };
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
AUDIO_ANALYSIS_JOB_OPTIONS,
AUDIO_ANALYSIS_VERSION,
audioAnalysisJobId,
normaliseDanceability,
normaliseMeanSquareEnergy,
validBpm,
validKey,
} from './audio-analysis.js';
test('maps mean-square energy through a dBFS range instead of saturating mastered tracks', () => {
assert.equal(normaliseMeanSquareEnergy(-1), null);
assert.equal(normaliseMeanSquareEnergy(0), 0);
assert.ok(Math.abs((normaliseMeanSquareEnergy(0.0001) ?? 0) - 8 / 42) < 0.000001); // -40 dBFS
assert.ok(Math.abs((normaliseMeanSquareEnergy(0.01) ?? 0) - 28 / 42) < 0.000001); // -20 dBFS
assert.equal(normaliseMeanSquareEnergy(0.26), 1); // above the -6 dBFS ceiling
});
test('only accepts values that are safe for Vibe consumers and database constraints', () => {
assert.equal(validBpm(29.9), null);
assert.equal(validBpm(128.04), 128);
assert.equal(validBpm(300.1), null);
assert.equal(validKey(' 8A '), '8A');
assert.equal(validKey(' '.repeat(33)), null);
assert.equal(normaliseDanceability(-0.1), null);
assert.equal(normaliseDanceability(1.5), 0.5);
assert.equal(normaliseDanceability(9), 1);
});
test('uses a versioned, retryable, deduplicated job contract', () => {
assert.equal(AUDIO_ANALYSIS_VERSION, 2);
assert.equal(audioAnalysisJobId('track-1'), 'audio-track-1');
assert.equal(AUDIO_ANALYSIS_JOB_OPTIONS.attempts, 3);
assert.deepEqual(AUDIO_ANALYSIS_JOB_OPTIONS.backoff, { type: 'exponential', delay: 5_000 });
});
+64
View File
@@ -0,0 +1,64 @@
/**
* Shared audio-analysis contract.
*
* Values in track_audio_features are deliberately modest: they are inputs to
* recommendation heuristics, not a claim that we can infer every Spotify-like
* descriptor from local DSP. Keep the persisted set restricted to fields we
* actually compute and validate before it reaches the database.
*/
export const AUDIO_ANALYSIS_VERSION = 2;
export const AUDIO_ANALYSIS_FEATURES = ['bpm', 'key', 'energy', 'danceability'] as const;
export type AudioAnalysisFeature = typeof AUDIO_ANALYSIS_FEATURES[number];
export 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;
const ENERGY_FLOOR_DBFS = -48;
const ENERGY_CEILING_DBFS = -6;
export function clampUnit(value: number): number | null {
return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : null;
}
/**
* Convert Essentia's mean-square sample energy to a perceptually useful 0..1
* RMS level. A linear multiplier makes normal mastered music saturate at 1;
* dBFS preserves the distinction between quiet and loud recordings.
*/
export function normaliseMeanSquareEnergy(meanSquare: number): number | null {
if (!Number.isFinite(meanSquare) || meanSquare < 0) return null;
if (meanSquare === 0) return 0;
const rms = Math.sqrt(meanSquare);
const dbfs = 20 * Math.log10(rms);
return clampUnit((dbfs - ENERGY_FLOOR_DBFS) / (ENERGY_CEILING_DBFS - ENERGY_FLOOR_DBFS));
}
export function normaliseDanceability(value: number): number | null {
if (!Number.isFinite(value) || value < 0) return null;
// Essentia Danceability is normally 0..3. Values outside that range are
// clamped rather than allowed to poison downstream scoring.
return clampUnit(value / 3);
}
export function validBpm(value: number | null | undefined): number | null {
if (!Number.isFinite(value) || value == null || value < 30 || value > 300) return null;
return Math.round(value * 10) / 10;
}
export function validKey(value: string | null | undefined): string | null {
if (!value) return null;
const key = value.trim();
// Keep tag conventions such as "8A" intact, but reject malformed or
// unexpectedly large values before persisting them.
return key.length > 0 && key.length <= 32 ? key : null;
}
export function audioAnalysisJobId(trackId: string): string {
return `audio-${trackId}`;
}
+71 -42
View File
@@ -16,6 +16,13 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import mm from 'music-metadata'; import mm from 'music-metadata';
import type { Queryable } from './db.js'; import type { Queryable } from './db.js';
import {
AUDIO_ANALYSIS_VERSION,
normaliseDanceability,
normaliseMeanSquareEnergy,
validBpm,
validKey,
} from './audio-analysis.js';
// Lazy WASM singleton — heavy to load (~2.4 MB), so we initialise once and // Lazy WASM singleton — heavy to load (~2.4 MB), so we initialise once and
// reuse across all enrichment jobs within the same worker process. // reuse across all enrichment jobs within the same worker process.
@@ -39,13 +46,24 @@ async function getEssentia() {
return essentiaReady; return essentiaReady;
} }
/** Decode any audio file to a mono Float32Array at 44100 Hz via ffmpeg. */ const MAX_DECODE_SECONDS = Math.max(30, Math.min(Number(process.env.MUZICK_AUDIO_MAX_SECONDS || 20 * 60), 2 * 60 * 60));
const MAX_PCM_BYTES = Math.max(4 * 1024 * 1024, Math.min(Number(process.env.MUZICK_AUDIO_MAX_PCM_BYTES || 96 * 1024 * 1024), 256 * 1024 * 1024));
/** Decode a bounded audio prefix to mono Float32Array at 44100 Hz via ffmpeg. */
function decodeAudioToFloat32(filePath: string): Promise<Float32Array> { function decodeAudioToFloat32(filePath: string): Promise<Float32Array> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
let bytes = 0;
let settled = false;
const fail = (error: Error) => {
if (settled) return;
settled = true;
reject(error);
};
const ff = spawn('ffmpeg', [ const ff = spawn('ffmpeg', [
'-i', filePath, '-i', filePath,
'-vn', '-vn',
'-t', String(MAX_DECODE_SECONDS),
'-acodec', 'pcm_f32le', '-acodec', 'pcm_f32le',
'-ar', '44100', '-ar', '44100',
'-ac', '1', '-ac', '1',
@@ -53,16 +71,22 @@ function decodeAudioToFloat32(filePath: string): Promise<Float32Array> {
'pipe:1', 'pipe:1',
], { stdio: ['ignore', 'pipe', 'ignore'] }); ], { stdio: ['ignore', 'pipe', 'ignore'] });
ff.stdout.on('data', (chunk: Buffer) => chunks.push(chunk)); ff.stdout.on('data', (chunk: Buffer) => {
ff.stdout.on('end', () => { bytes += chunk.length;
const buf = Buffer.concat(chunks); if (bytes > MAX_PCM_BYTES) {
resolve(new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4)); ff.kill('SIGTERM');
}); fail(new Error(`decoded audio exceeds ${MAX_PCM_BYTES} byte safety limit`));
ff.on('error', reject); return;
ff.on('close', (code) => {
if (code !== 0 && chunks.length === 0) {
reject(new Error(`ffmpeg exited with code ${code} for ${filePath}`));
} }
chunks.push(chunk);
});
ff.on('error', (error) => fail(error));
ff.on('close', (code) => {
if (settled) return;
if (code !== 0) return fail(new Error(`ffmpeg exited with code ${code} for ${filePath}`));
const buf = Buffer.concat(chunks);
settled = true;
resolve(new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4));
}); });
}); });
} }
@@ -72,7 +96,6 @@ export interface AudioFeatures {
key: string | null; key: string | null;
energy: number | null; energy: number | null;
danceability: number | null; danceability: number | null;
dynamicComplexity: number | null;
} }
export class AudioFeaturesService { export class AudioFeaturesService {
@@ -91,9 +114,21 @@ export class AudioFeaturesService {
instrumentalness REAL, instrumentalness REAL,
liveness REAL, liveness REAL,
valence_score REAL, valence_score REAL,
tempo REAL tempo REAL,
analysis_version SMALLINT NOT NULL DEFAULT 0,
source_hash TEXT,
analyzed_at TIMESTAMPTZ
)` )`
); );
// CREATE TABLE IF NOT EXISTS does not evolve an existing library. Keep the
// worker safe when it starts before the backend has had a chance to apply
// the corresponding migration.
await this.pgClient.query(
`ALTER TABLE track_audio_features
ADD COLUMN IF NOT EXISTS analysis_version SMALLINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS source_hash TEXT,
ADD COLUMN IF NOT EXISTS analyzed_at TIMESTAMPTZ`
);
} }
// ── Pass 1: embedded tags ────────────────────────────────────────────────── // ── Pass 1: embedded tags ──────────────────────────────────────────────────
@@ -101,8 +136,8 @@ export class AudioFeaturesService {
private async readEmbeddedTags(filePath: string): Promise<{ bpm: number | null; key: string | null; replayGainDb: number | null }> { private async readEmbeddedTags(filePath: string): Promise<{ bpm: number | null; key: string | null; replayGainDb: number | null }> {
try { try {
const { common } = await mm.parseFile(filePath, { duration: false }); const { common } = await mm.parseFile(filePath, { duration: false });
const bpm = common.bpm && Number.isFinite(common.bpm) && common.bpm > 0 ? common.bpm : null; const bpm = validBpm(common.bpm);
const key = common.key?.trim() || null; const key = validKey(common.key);
const rgRaw = (common as any).replaygain_track_gain; const rgRaw = (common as any).replaygain_track_gain;
let replayGainDb: number | null = null; let replayGainDb: number | null = null;
if (rgRaw != null) { if (rgRaw != null) {
@@ -118,7 +153,7 @@ export class AudioFeaturesService {
// ── Pass 2: essentia.js DSP ──────────────────────────────────────────────── // ── Pass 2: essentia.js DSP ────────────────────────────────────────────────
private async analyseWithEssentia(filePath: string, needBpm: boolean, needKey: boolean): Promise<AudioFeatures> { private async analyseWithEssentia(filePath: string, needBpm: boolean, needKey: boolean): Promise<AudioFeatures> {
const result: AudioFeatures = { bpm: null, key: null, energy: null, danceability: null, dynamicComplexity: null }; const result: AudioFeatures = { bpm: null, key: null, energy: null, danceability: null };
const { essentia } = await getEssentia(); const { essentia } = await getEssentia();
const signal = await decodeAudioToFloat32(filePath); const signal = await decodeAudioToFloat32(filePath);
@@ -129,7 +164,7 @@ export class AudioFeaturesService {
if (needBpm) { if (needBpm) {
try { try {
const rhythm = essentia.RhythmExtractor2013(vec); const rhythm = essentia.RhythmExtractor2013(vec);
if (rhythm.bpm > 0) result.bpm = Math.round(rhythm.bpm * 10) / 10; result.bpm = validBpm(rhythm.bpm);
} catch (err) { } catch (err) {
console.warn('[AudioFeatures] RhythmExtractor2013 failed:', (err as Error).message); console.warn('[AudioFeatures] RhythmExtractor2013 failed:', (err as Error).message);
} }
@@ -138,7 +173,7 @@ export class AudioFeaturesService {
if (needKey) { if (needKey) {
try { try {
const keyResult = essentia.KeyExtractor(vec); const keyResult = essentia.KeyExtractor(vec);
if (keyResult.key) result.key = `${keyResult.key} ${keyResult.scale}`; if (keyResult.key) result.key = validKey(`${keyResult.key} ${keyResult.scale}`);
} catch (err) { } catch (err) {
console.warn('[AudioFeatures] KeyExtractor failed:', (err as Error).message); console.warn('[AudioFeatures] KeyExtractor failed:', (err as Error).message);
} }
@@ -147,31 +182,21 @@ export class AudioFeaturesService {
// Energy and danceability are only available from signal analysis — always compute. // Energy and danceability are only available from signal analysis — always compute.
try { try {
const energy = essentia.Energy(vec); const energy = essentia.Energy(vec);
// Essentia Energy returns the sum of squared samples, normalised to [0,1] // Energy is the sum of squared samples. Convert it to a mean-square
// by dividing by signal length. Map to a sensible 0-1 display value. // level, then use the shared dBFS normalization rather than an arbitrary
const rawEnergy = energy.energy / signal.length; // linear multiplier that saturates mastered music near 1.0.
result.energy = Math.min(1, rawEnergy * 1000); // typical values << 0.001 result.energy = normaliseMeanSquareEnergy(energy.energy / signal.length);
} catch (err) { } catch (err) {
console.warn('[AudioFeatures] Energy failed:', (err as Error).message); console.warn('[AudioFeatures] Energy failed:', (err as Error).message);
} }
try { try {
const dance = essentia.Danceability(vec); const dance = essentia.Danceability(vec);
// Danceability output range is 0-3; normalise to 0-1. result.danceability = normaliseDanceability(dance.danceability);
result.danceability = Math.min(1, dance.danceability / 3);
} catch (err) { } catch (err) {
console.warn('[AudioFeatures] Danceability failed:', (err as Error).message); console.warn('[AudioFeatures] Danceability failed:', (err as Error).message);
} }
try {
const dynComp = essentia.DynamicComplexity(vec);
// DynamicComplexity is a measure of loudness variation (0 = flat/compressed,
// higher = dynamic). Scale to 0-1 (typical max ~10 dB).
result.dynamicComplexity = Math.min(1, dynComp.dynamicComplexity / 10);
} catch (err) {
console.warn('[AudioFeatures] DynamicComplexity failed:', (err as Error).message);
}
vec.delete(); // free WASM memory vec.delete(); // free WASM memory
return result; return result;
} }
@@ -179,8 +204,8 @@ export class AudioFeaturesService {
// ── Public entry point ───────────────────────────────────────────────────── // ── Public entry point ─────────────────────────────────────────────────────
async extractAndPersist(trackId: string): Promise<void> { async extractAndPersist(trackId: string): Promise<void> {
const pathRes = await this.pgClient.query<{ path: string }>( const pathRes = await this.pgClient.query<{ path: string; hash: string }>(
'SELECT path FROM tracks WHERE id = $1', 'SELECT path, hash FROM tracks WHERE id = $1',
[trackId] [trackId]
); );
const row = pathRes.rows[0]; const row = pathRes.rows[0];
@@ -210,15 +235,19 @@ export class AudioFeaturesService {
const tempo = bpm; const tempo = bpm;
await this.pgClient.query( await this.pgClient.query(
`INSERT INTO track_audio_features (track_id, bpm, key, energy, danceability, tempo) `INSERT INTO track_audio_features
VALUES ($1, $2, $3, $4, $5, $6) (track_id, bpm, key, energy, danceability, tempo, analysis_version, source_hash, analyzed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
ON CONFLICT (track_id) DO UPDATE SET ON CONFLICT (track_id) DO UPDATE SET
bpm = COALESCE(EXCLUDED.bpm, track_audio_features.bpm), bpm = EXCLUDED.bpm,
key = COALESCE(EXCLUDED.key, track_audio_features.key), key = EXCLUDED.key,
energy = COALESCE(EXCLUDED.energy, track_audio_features.energy), energy = EXCLUDED.energy,
danceability = COALESCE(EXCLUDED.danceability, track_audio_features.danceability), danceability = EXCLUDED.danceability,
tempo = COALESCE(EXCLUDED.tempo, track_audio_features.tempo)`, tempo = EXCLUDED.tempo,
[trackId, bpm, key, energy, danceability, tempo] analysis_version = EXCLUDED.analysis_version,
source_hash = EXCLUDED.source_hash,
analyzed_at = EXCLUDED.analyzed_at`,
[trackId, bpm, key, energy, danceability, tempo, AUDIO_ANALYSIS_VERSION, row.hash]
); );
const parts: string[] = []; const parts: string[] = [];
+51 -36
View File
@@ -13,7 +13,6 @@ import {
DeezerClient, DeezerClient,
upscaleITunesArtwork, upscaleITunesArtwork,
} from './integrations/index.js'; } from './integrations/index.js';
import { AudioFeaturesService } from './audio-features.service.js';
import { MbSpineWriter } from './mb-spine-writer.js'; import { MbSpineWriter } from './mb-spine-writer.js';
import { import {
normalizeForMatching, normalizeForMatching,
@@ -43,6 +42,13 @@ interface TrackRow {
artist_id: string | null; artist_id: string | null;
} }
/** Stored as BullMQ's job return value so a completed job never masquerades
* as an enrichment hit when a toggle was off or every provider had no match. */
export interface EnrichmentJobOutcome {
outcome: 'updated' | 'unchanged' | 'skipped' | 'not_found' | 'no_result';
detail?: string;
}
/** /**
* Wires the external-integration clients into real metadata enrichment. * Wires the external-integration clients into real metadata enrichment.
* *
@@ -63,11 +69,7 @@ export class EnrichmentService {
private readonly theaudiodb = new TheAudioDbClient(); private readonly theaudiodb = new TheAudioDbClient();
private readonly itunes = new ITunesClient(); private readonly itunes = new ITunesClient();
private readonly deezer = new DeezerClient(); private readonly deezer = new DeezerClient();
private readonly audioFeatures: AudioFeaturesService; constructor(private pgClient: Queryable) {}
constructor(private pgClient: Queryable) {
this.audioFeatures = new AudioFeaturesService(pgClient);
}
/** /**
* Self-provision the enrichment-specific schema additions. Idempotent; mirrors * Self-provision the enrichment-specific schema additions. Idempotent; mirrors
@@ -540,15 +542,22 @@ export class EnrichmentService {
* provider that returns nothing or throws is logged and skipped without * provider that returns nothing or throws is logged and skipped without
* affecting the others. Safe to re-run (stable, no duplicate rows). * affecting the others. Safe to re-run (stable, no duplicate rows).
*/ */
async enrichTrack(trackId: string): Promise<void> { async enrichTrack(trackId: string): Promise<EnrichmentJobOutcome> {
const track = await this.loadTrack(trackId); const track = await this.loadTrack(trackId);
if (!track) { if (!track) {
console.warn(`[Enrich] track not found: ${trackId}`); console.warn(`[Enrich] track not found: ${trackId}`);
return; return { outcome: 'not_found', detail: 'Track no longer exists.' };
} }
// Load enrichment settings. Default all to true (best-effort). // Load enrichment settings. Default all to true (best-effort).
const settings = await this.loadSettings(); const settings = await this.loadSettings();
const trackEnrichmentEnabled = settings.enrich_metadata
|| settings.enrich_genres
|| settings.enrich_lyrics;
if (!trackEnrichmentEnabled) {
console.log(`[Enrich] track ${trackId}: skipped (all track enrichment toggles disabled)`);
return { outcome: 'skipped', detail: 'All track enrichment toggles are disabled.' };
}
const summary: string[] = []; const summary: string[] = [];
const album = track.album_title ?? undefined; const album = track.album_title ?? undefined;
@@ -810,22 +819,14 @@ export class EnrichmentService {
// NOTE: Album cover art is fetched by the separate `album_cover` job (Discogs // NOTE: Album cover art is fetched by the separate `album_cover` job (Discogs
// + Cover Art Archive), not inline here. See refreshAlbumCover(). // + Cover Art Archive), not inline here. See refreshAlbumCover().
// --- g. Audio features from embedded tags --------------------------------
if (settings.enrich_audio_analysis) {
try {
await this.audioFeatures.ensureSchema();
await this.audioFeatures.extractAndPersist(trackId);
summary.push('audio_features');
} catch (err) {
console.warn('[Enrich] Audio features step failed:', (err as Error).message);
}
} // enrich_audio_analysis
console.log( console.log(
`[Enrich] track ${trackId} enriched: ${ `[Enrich] track ${trackId} enriched: ${
summary.length > 0 ? summary.join(', ') : 'nothing' summary.length > 0 ? summary.join(', ') : 'nothing'
}` }`
); );
return summary.length > 0
? { outcome: 'updated', detail: summary.join(', ') }
: { outcome: 'no_result', detail: 'No provider returned usable metadata.' };
} }
/** /**
@@ -838,6 +839,7 @@ export class EnrichmentService {
*/ */
private async loadSettings(): Promise<{ private async loadSettings(): Promise<{
enrich_metadata: boolean; enrich_metadata: boolean;
enrich_artist_images: boolean;
enrich_genres: boolean; enrich_genres: boolean;
enrich_cover_art: boolean; enrich_cover_art: boolean;
enrich_lyrics: boolean; enrich_lyrics: boolean;
@@ -846,7 +848,7 @@ export class EnrichmentService {
}> { }> {
const rows = await this.pgClient.query( const rows = await this.pgClient.query(
`SELECT key, value FROM settings `SELECT key, value FROM settings
WHERE key IN ('enrich_metadata','enrich_genres','enrich_cover_art', WHERE key IN ('enrich_metadata','enrich_artist_images','enrich_genres','enrich_cover_art',
'enrich_lyrics','enrich_artist_similarity','enrich_audio_analysis')` 'enrich_lyrics','enrich_artist_similarity','enrich_audio_analysis')`
); );
const map: Record<string, boolean> = {}; const map: Record<string, boolean> = {};
@@ -855,6 +857,9 @@ export class EnrichmentService {
} }
return { return {
enrich_metadata: map.enrich_metadata ?? true, enrich_metadata: map.enrich_metadata ?? true,
// This migration is intentionally opt-in: image cleanup must never be
// followed by an unrequested external API fan-out on the next scan.
enrich_artist_images: map.enrich_artist_images ?? false,
enrich_genres: map.enrich_genres ?? true, enrich_genres: map.enrich_genres ?? true,
enrich_cover_art: map.enrich_cover_art ?? true, enrich_cover_art: map.enrich_cover_art ?? true,
enrich_lyrics: map.enrich_lyrics ?? true, enrich_lyrics: map.enrich_lyrics ?? true,
@@ -867,25 +872,32 @@ export class EnrichmentService {
* Fetch/refresh a single artist's image via the fallback chain. Runs as the * Fetch/refresh a single artist's image via the fallback chain. Runs as the
* dedicated `artist_image` job so image lookups don't run inline with track * dedicated `artist_image` job so image lookups don't run inline with track
* enrichment. Best-effort and idempotent — getArtistImage() short-circuits * enrichment. Best-effort and idempotent — getArtistImage() short-circuits
* when a good image already exists. Gated by enrich_metadata (same toggle the * when a good image already exists. Gated by the dedicated
* inline step used). * enrich_artist_images toggle, independently of structural metadata.
*/ */
async refreshArtistImage(artistId: string): Promise<void> { async refreshArtistImage(artistId: string): Promise<EnrichmentJobOutcome> {
const settings = await this.loadSettings(); const settings = await this.loadSettings();
if (!settings.enrich_metadata) return; if (!settings.enrich_artist_images) {
console.log(`[Enrich] artist image ${artistId}: skipped (enrich_artist_images disabled)`);
return { outcome: 'skipped', detail: 'enrich_artist_images is disabled.' };
}
const res = await this.pgClient.query<{ name: string; canonical_name: string | null; mbid: string | null }>( const res = await this.pgClient.query<{ name: string; canonical_name: string | null; mbid: string | null; image_path: string | null }>(
`SELECT name, canonical_name, mbid FROM artists WHERE id = $1`, `SELECT name, canonical_name, mbid, image_path FROM artists WHERE id = $1`,
[artistId] [artistId]
); );
const artist = res.rows[0]; const artist = res.rows[0];
if (!artist) { if (!artist) {
console.warn(`[Enrich] artist not found for image: ${artistId}`); console.warn(`[Enrich] artist not found for image: ${artistId}`);
return; return { outcome: 'not_found', detail: 'Artist no longer exists.' };
} }
if (artist.image_path) return { outcome: 'unchanged', detail: 'Artist already has an image.' };
const url = await this.getArtistImage(artistId, artist.mbid, artist.canonical_name ?? artist.name); const url = await this.getArtistImage(artistId, artist.mbid, artist.canonical_name ?? artist.name);
console.log(`[Enrich] artist image ${artistId}: ${url ? 'set' : 'none'}`); console.log(`[Enrich] artist image ${artistId}: ${url ? 'set' : 'none'}`);
return url
? { outcome: 'updated', detail: 'Artist image was set.' }
: { outcome: 'no_result', detail: 'No verified artist image was found.' };
} }
/** /**
@@ -906,9 +918,11 @@ export class EnrichmentService {
* release-group cover. * release-group cover.
* Each step short-circuits on the first hit. * Each step short-circuits on the first hit.
*/ */
async refreshAlbumCover(albumId: string): Promise<void> { async refreshAlbumCover(albumId: string): Promise<EnrichmentJobOutcome> {
const settings = await this.loadSettings(); const settings = await this.loadSettings();
if (!settings.enrich_cover_art) return; if (!settings.enrich_cover_art) {
return { outcome: 'skipped', detail: 'enrich_cover_art is disabled.' };
}
const albumRes = await this.pgClient.query<{ const albumRes = await this.pgClient.query<{
title: string; title: string;
@@ -922,9 +936,9 @@ export class EnrichmentService {
const album = albumRes.rows[0]; const album = albumRes.rows[0];
if (!album) { if (!album) {
console.warn(`[Enrich] album not found for cover: ${albumId}`); console.warn(`[Enrich] album not found for cover: ${albumId}`);
return; return { outcome: 'not_found', detail: 'Album no longer exists.' };
} }
if (album.artwork_id) return; // already has cover — nothing to do if (album.artwork_id) return { outcome: 'unchanged', detail: 'Album already has artwork.' };
let artistName = ''; let artistName = '';
if (album.artist_id) { if (album.artist_id) {
@@ -947,7 +961,7 @@ export class EnrichmentService {
[coverUrl, albumId] [coverUrl, albumId]
); );
console.log(`[Enrich] album cover ${albumId}: caa-release-group`); console.log(`[Enrich] album cover ${albumId}: caa-release-group`);
return; return { outcome: 'updated', detail: 'Cover Art Archive release-group.' };
} }
} catch (err) { } catch (err) {
console.warn('[Enrich] album cover CAA release-group step failed:', (err as Error).message); console.warn('[Enrich] album cover CAA release-group step failed:', (err as Error).message);
@@ -965,7 +979,7 @@ export class EnrichmentService {
[coverUrl, albumId] [coverUrl, albumId]
); );
console.log(`[Enrich] album cover ${albumId}: itunes`); console.log(`[Enrich] album cover ${albumId}: itunes`);
return; return { outcome: 'updated', detail: 'iTunes artwork.' };
} }
} catch (err) { } catch (err) {
console.warn('[Enrich] album cover iTunes step failed:', (err as Error).message); console.warn('[Enrich] album cover iTunes step failed:', (err as Error).message);
@@ -982,7 +996,7 @@ export class EnrichmentService {
[deezerAlbum.coverXl, albumId] [deezerAlbum.coverXl, albumId]
); );
console.log(`[Enrich] album cover ${albumId}: deezer`); console.log(`[Enrich] album cover ${albumId}: deezer`);
return; return { outcome: 'updated', detail: 'Deezer artwork.' };
} }
} catch (err) { } catch (err) {
console.warn('[Enrich] album cover Deezer step failed:', (err as Error).message); console.warn('[Enrich] album cover Deezer step failed:', (err as Error).message);
@@ -1006,7 +1020,7 @@ export class EnrichmentService {
); );
} }
console.log(`[Enrich] album cover ${albumId}: discogs`); console.log(`[Enrich] album cover ${albumId}: discogs`);
return; return { outcome: 'updated', detail: 'Discogs artwork.' };
} }
} catch (err) { } catch (err) {
console.warn('[Enrich] album cover Discogs step failed:', (err as Error).message); console.warn('[Enrich] album cover Discogs step failed:', (err as Error).message);
@@ -1032,7 +1046,7 @@ export class EnrichmentService {
[coverUrl, albumId] [coverUrl, albumId]
); );
console.log(`[Enrich] album cover ${albumId}: caa-release`); console.log(`[Enrich] album cover ${albumId}: caa-release`);
return; return { outcome: 'updated', detail: 'Cover Art Archive release.' };
} }
} }
} }
@@ -1041,6 +1055,7 @@ export class EnrichmentService {
} }
console.log(`[Enrich] album cover ${albumId}: none`); console.log(`[Enrich] album cover ${albumId}: none`);
return { outcome: 'no_result', detail: 'No cover provider returned artwork.' };
} }
async refreshArtistSimilarity(artistId: string): Promise<void> { async refreshArtistSimilarity(artistId: string): Promise<void> {
-1
View File
@@ -7,7 +7,6 @@ declare module 'essentia.js' {
KeyExtractor(audio: any, averageDetuningCorrection?: boolean, frameSize?: number, hopSize?: number): { key: string; scale: string; strength: number }; KeyExtractor(audio: any, averageDetuningCorrection?: boolean, frameSize?: number, hopSize?: number): { key: string; scale: string; strength: number };
Energy(signal: any): { energy: number }; Energy(signal: any): { energy: number };
Danceability(signal: any, maxTau?: number, minTau?: number, sampleRate?: number): { danceability: number; dfa: number[] }; Danceability(signal: any, maxTau?: number, minTau?: number, sampleRate?: number): { danceability: number; dfa: number[] };
DynamicComplexity(signal: any, frameSize?: number, sampleRate?: number): { dynamicComplexity: number; loudness: number };
} }
export { EssentiaWASM, Essentia }; export { EssentiaWASM, Essentia };
} }
+112 -10
View File
@@ -1,6 +1,6 @@
import { Worker, Job } from 'bullmq'; import { Worker, Job } from 'bullmq';
import { connection, QUEUE_NAME, queue } from './queue.js'; import { connection, QUEUE_NAME, queue } from './queue.js';
import { MetadataRefreshJob, AudioAnalysisJob, LibraryScanJob, ArtistSimilarityJob, ArtistImageJob, AlbumCoverJob, ReprocessArtistsJob } from './types.js'; import { MetadataRefreshJob, AudioAnalysisJob, AudioAnalysisSweepJob, LibraryScanJob, ArtistSimilarityJob, ArtistImageJob, AlbumCoverJob, ReprocessArtistsJob, AcquisitionJob } from './types.js';
import { Pool } from 'pg'; import { Pool } from 'pg';
import { ScannerService } from './scanner.service.js'; import { ScannerService } from './scanner.service.js';
import { IntegrityService } from './integrity.service.js'; import { IntegrityService } from './integrity.service.js';
@@ -8,6 +8,8 @@ import { EnrichmentService } from './enrichment.service.js';
import { AudioFeaturesService } from './audio-features.service.js'; import { AudioFeaturesService } from './audio-features.service.js';
import { CleanupSweepService } from './cleanup.service.js'; import { CleanupSweepService } from './cleanup.service.js';
import { reprocessArtists } from './reprocess-artists.service.js'; import { reprocessArtists } from './reprocess-artists.service.js';
import { AcquisitionService } from './acquisition.service.js';
import { AUDIO_ANALYSIS_JOB_OPTIONS, AUDIO_ANALYSIS_VERSION, audioAnalysisJobId } from './audio-analysis.js';
// Cron for the periodic integrity sweep (default: daily at 03:00). Configurable // Cron for the periodic integrity sweep (default: daily at 03:00). Configurable
// via INTEGRITY_SWEEP_CRON. MUSIC_DIR (consumed by IntegrityService) controls // via INTEGRITY_SWEEP_CRON. MUSIC_DIR (consumed by IntegrityService) controls
@@ -19,8 +21,31 @@ const CLEANUP_SWEEP_CRON = process.env.CLEANUP_SWEEP_CRON || '0 */6 * * *';
// VIBE_REAP_CRON. Spec §4 / Invariant B: ACTIVE batches with no interaction for // VIBE_REAP_CRON. Spec §4 / Invariant B: ACTIVE batches with no interaction for
// 24h must transition to RESOLVED so returning users start fresh sessions. // 24h must transition to RESOLVED so returning users start fresh sessions.
const VIBE_REAP_CRON = process.env.VIBE_REAP_CRON || '0 * * * *'; const VIBE_REAP_CRON = process.env.VIBE_REAP_CRON || '0 * * * *';
const PROBATION_SWEEP_CRON = process.env.PROBATION_SWEEP_CRON || '15 * * * *';
// A small daily backfill is intentionally bounded. It refreshes stale v1
// measurements over time without turning a worker restart into a library-wide
// ffmpeg/Essentia batch.
const AUDIO_ANALYSIS_SWEEP_CRON = process.env.AUDIO_ANALYSIS_SWEEP_CRON || '20 4 * * *';
const configuredAudioBatchSize = Number.parseInt(process.env.AUDIO_ANALYSIS_BATCH_SIZE || '25', 10);
const AUDIO_ANALYSIS_BATCH_SIZE = Number.isFinite(configuredAudioBatchSize)
? Math.min(100, Math.max(1, configuredAudioBatchSize))
: 25;
// Worker concurrency - how many jobs to process in parallel // Worker concurrency - how many jobs to process in parallel
const WORKER_CONCURRENCY = parseInt(process.env.WORKER_CONCURRENCY || '10', 10); const WORKER_CONCURRENCY = parseInt(process.env.WORKER_CONCURRENCY || '10', 10);
const AUDIO_ANALYSIS_CONCURRENCY = Math.max(1, Math.min(2, parseInt(process.env.AUDIO_ANALYSIS_CONCURRENCY || '1', 10) || 1));
let activeAudioAnalyses = 0;
const audioWaiters: Array<() => void> = [];
async function withAudioSlot<T>(fn: () => Promise<T>): Promise<T> {
if (activeAudioAnalyses >= AUDIO_ANALYSIS_CONCURRENCY) {
await new Promise<void>((resolve) => audioWaiters.push(resolve));
}
activeAudioAnalyses++;
try { return await fn(); }
finally {
activeAudioAnalyses--;
audioWaiters.shift()?.();
}
}
// A Pool, not a single Client. The worker processes jobs with // A Pool, not a single Client. The worker processes jobs with
// `concurrency: 10` on one event loop, so a shared Client would multiplex every // `concurrency: 10` on one event loop, so a shared Client would multiplex every
@@ -48,6 +73,7 @@ async function initWorker() {
console.log('Worker connected to PostgreSQL'); console.log('Worker connected to PostgreSQL');
const scannerService = new ScannerService(pgPool, queue); const scannerService = new ScannerService(pgPool, queue);
const acquisitionService = new AcquisitionService(pgPool, scannerService);
const enrichmentService = new EnrichmentService(pgPool); const enrichmentService = new EnrichmentService(pgPool);
const audioFeaturesService = new AudioFeaturesService(pgPool); const audioFeaturesService = new AudioFeaturesService(pgPool);
await audioFeaturesService.ensureSchema(); await audioFeaturesService.ensureSchema();
@@ -78,9 +104,9 @@ async function initWorker() {
// Real best-effort enrichment via the external-integration clients. // Real best-effort enrichment via the external-integration clients.
// Each provider is isolated inside the service so one failing source // Each provider is isolated inside the service so one failing source
// never aborts the others or fails the job. // never aborts the others or fails the job.
await enrichmentService.enrichTrack(payload.trackId); const outcome = await enrichmentService.enrichTrack(payload.trackId);
console.log(`[Metadata] Successfully refreshed track: ${payload.trackId}`); console.log(`[Metadata] ${payload.trackId}: ${outcome.outcome}`);
break; return outcome;
} }
case 'artist_similarity': { case 'artist_similarity': {
const payload = job.data as ArtistSimilarityJob; const payload = job.data as ArtistSimilarityJob;
@@ -91,22 +117,55 @@ async function initWorker() {
case 'artist_image': { case 'artist_image': {
const payload = job.data as ArtistImageJob; const payload = job.data as ArtistImageJob;
console.log(`[ArtistImage] Refreshing image for artist: ${payload.artistId}`); console.log(`[ArtistImage] Refreshing image for artist: ${payload.artistId}`);
await enrichmentService.refreshArtistImage(payload.artistId); return await enrichmentService.refreshArtistImage(payload.artistId);
break;
} }
case 'album_cover': { case 'album_cover': {
const payload = job.data as AlbumCoverJob; const payload = job.data as AlbumCoverJob;
console.log(`[AlbumCover] Refreshing cover for album: ${payload.albumId}`); console.log(`[AlbumCover] Refreshing cover for album: ${payload.albumId}`);
await enrichmentService.refreshAlbumCover(payload.albumId); return await enrichmentService.refreshAlbumCover(payload.albumId);
break;
} }
case 'audio_analysis': { case 'audio_analysis': {
const payload = job.data as AudioAnalysisJob; const payload = job.data as AudioAnalysisJob;
console.log(`[Audio] Analyzing track: ${payload.trackId}`); console.log(`[Audio] Analyzing track: ${payload.trackId}`);
await audioFeaturesService.extractAndPersist(payload.trackId); await withAudioSlot(() => audioFeaturesService.extractAndPersist(payload.trackId));
console.log(`[Audio] Successfully analyzed track: ${payload.trackId}`); console.log(`[Audio] Successfully analyzed track: ${payload.trackId}`);
break; break;
} }
case 'audio_analysis_sweep': {
const payload = job.data as AudioAnalysisSweepJob;
const enabledResult = await pgPool.query<{ value: string }>(
"SELECT value FROM settings WHERE key = 'enrich_audio_analysis'"
);
if (enabledResult.rows[0]?.value !== 'true') {
console.log('[Audio] Sweep skipped: enrich_audio_analysis is disabled');
break;
}
const requestedLimit = payload.limit ?? AUDIO_ANALYSIS_BATCH_SIZE;
const limit = Math.min(100, Math.max(1, Number.isFinite(requestedLimit) ? requestedLimit : AUDIO_ANALYSIS_BATCH_SIZE));
const stale = await pgPool.query<{ id: string }>(
`SELECT t.id
FROM tracks t
LEFT JOIN track_audio_features af ON af.track_id = t.id
WHERE t.state IN ('LIBRARY', 'RECOMMENDED')
AND (
af.track_id IS NULL
OR af.analysis_version < $1
OR af.source_hash IS DISTINCT FROM t.hash
)
ORDER BY COALESCE(af.analyzed_at, to_timestamp(0)), t.id
LIMIT $2`,
[AUDIO_ANALYSIS_VERSION, limit]
);
for (const track of stale.rows) {
await queue.add('audio_analysis', { trackId: track.id } satisfies AudioAnalysisJob, {
jobId: audioAnalysisJobId(track.id),
...AUDIO_ANALYSIS_JOB_OPTIONS,
});
}
console.log(`[Audio] Sweep enqueued ${stale.rows.length} track(s)`);
break;
}
case 'integrity_sweep': { case 'integrity_sweep': {
// Periodic self-healing pass: detect corrupt/missing track metadata, // Periodic self-healing pass: detect corrupt/missing track metadata,
// auto-fix via rescan + SQL strip, flag the rest for manual review. // auto-fix via rescan + SQL strip, flag the rest for manual review.
@@ -182,7 +241,7 @@ async function initWorker() {
`SELECT t.id, t.title, t.artist, al.title AS album, t.duration, t.play_count, t.source_type `SELECT t.id, t.title, t.artist, al.title AS album, t.duration, t.play_count, t.source_type
FROM tracks t FROM tracks t
LEFT JOIN albums al ON al.id = t.album_id LEFT JOIN albums al ON al.id = t.album_id
WHERE t.state = 'LIBRARY'` WHERE t.state IN ('LIBRARY', 'RECOMMENDED')`
); );
const tracks = tracksRes.rows; const tracks = tracksRes.rows;
@@ -227,6 +286,35 @@ async function initWorker() {
}); });
break; break;
} }
case 'acquire_discovery_candidate': {
const payload = job.data as AcquisitionJob;
const result = await acquisitionService.acquire(payload.candidateId);
console.log(`[Acquisition] Candidate ${payload.candidateId}: ${result.status}${'reason' in result ? ` (${result.reason})` : ''}`);
if (result.status === 'failed') throw new Error(result.reason);
return result;
}
case 'probation_sweep': {
// Keep probation moving without exposing an operator-only HTTP endpoint
// as the sole lifecycle driver. These conditions mirror DiscoveryService.
const retained = await pgPool.query(
`UPDATE tracks t SET probation_status = 'retained', state = 'LIBRARY'
WHERE t.probation_status = 'probation'
AND (SELECT COUNT(*) FROM evidence e WHERE e.entity_type = 'track'
AND e.entity_id = t.id AND e.signal = 'playback_completed') >= 3`
);
const retired = await pgPool.query(
`UPDATE tracks t SET probation_status = 'retired', state = 'HIDDEN'
WHERE t.probation_status = 'probation'
AND t.probation_entered_at < NOW() - INTERVAL '7 days'
AND (SELECT COUNT(*) FROM evidence e WHERE e.entity_type = 'track'
AND e.entity_id = t.id AND e.signal = 'playback_completed') = 0
AND (SELECT COUNT(*) FROM evidence e WHERE e.entity_type = 'track'
AND e.entity_id = t.id AND e.signal = 'skip_quick') >= 3`
);
const result = { retained: retained.rowCount ?? 0, retired: retired.rowCount ?? 0 };
console.log(`[Probation] Sweep retained=${result.retained} retired=${result.retired}`);
return result;
}
default: default:
console.log(`Received job of type: ${job.name} with data:`, job.data); console.log(`Received job of type: ${job.name} with data:`, job.data);
break; break;
@@ -268,6 +356,20 @@ async function initWorker() {
); );
console.log(`[VibeReap] Reaper scheduled with cron: ${VIBE_REAP_CRON}`); console.log(`[VibeReap] Reaper scheduled with cron: ${VIBE_REAP_CRON}`);
await queue.upsertJobScheduler(
'probation-sweep',
{ pattern: PROBATION_SWEEP_CRON },
{ name: 'probation_sweep', data: { reason: 'scheduled' } }
);
console.log(`[Probation] Sweep scheduled with cron: ${PROBATION_SWEEP_CRON}`);
await queue.upsertJobScheduler(
'audio-analysis-sweep',
{ pattern: AUDIO_ANALYSIS_SWEEP_CRON },
{ name: 'audio_analysis_sweep', data: { reason: 'scheduled', limit: AUDIO_ANALYSIS_BATCH_SIZE } satisfies AudioAnalysisSweepJob }
);
console.log(`[Audio] Bounded analysis sweep scheduled with cron: ${AUDIO_ANALYSIS_SWEEP_CRON}, batch: ${AUDIO_ANALYSIS_BATCH_SIZE}`);
// Enqueue a startup scan only when the database is empty (fresh volume after // Enqueue a startup scan only when the database is empty (fresh volume after
// deleting data/postgres, or first deploy). On subsequent restarts the library // deleting data/postgres, or first deploy). On subsequent restarts the library
// is already populated, so an unconditional scan would be wasteful. // is already populated, so an unconditional scan would be wasteful.
+102 -11
View File
@@ -5,9 +5,25 @@ import path from 'path';
import mm from 'music-metadata'; import mm from 'music-metadata';
import type { Queryable } from './db.js'; import type { Queryable } from './db.js';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { MetadataRefreshJob, ArtistSimilarityJob, ArtistImageJob, AlbumCoverJob } from './types.js'; import { MetadataRefreshJob, ArtistSimilarityJob, ArtistImageJob, AlbumCoverJob, AudioAnalysisJob } from './types.js';
import { AUDIO_ANALYSIS_JOB_OPTIONS, audioAnalysisJobId, AUDIO_ANALYSIS_VERSION } from './audio-analysis.js';
import { splitArtistNames, parseArtists } from './utils/artist-names.js'; import { splitArtistNames, parseArtists } from './utils/artist-names.js';
/**
* Scanner provenance is supplied by the acquisition worker, not inferred from
* tags. The optional candidate id lets the acquisition service make an exact
* candidate -> scanned-track association after a successful scan.
*/
export interface ScanContext {
sourceType?: 'MANUAL' | 'RECOMMENDATION';
probationStatus?: 'probation' | 'retained' | 'retired';
candidateId?: string;
}
export interface ScanResult {
trackIds: string[];
}
/** /**
* Parse main + featured artists from music-metadata. Prefers the structured * Parse main + featured artists from music-metadata. Prefers the structured
* `artists[]` array when the tag provides it (each entry already one artist), * `artists[]` array when the tag provides it (each entry already one artist),
@@ -79,26 +95,31 @@ export class ScannerService {
// pending jobs; these avoid even issuing the redundant add() within one scan). // pending jobs; these avoid even issuing the redundant add() within one scan).
private enqueuedArtists = new Set<string>(); private enqueuedArtists = new Set<string>();
private enqueuedAlbums = new Set<string>(); private enqueuedAlbums = new Set<string>();
private audioAnalysisEnabled = false;
constructor(private pgClient: Queryable, private queue: Queue) {} constructor(private pgClient: Queryable, private queue: Queue) {}
async scanDirectory(directory: string) { async scanDirectory(directory: string, context: ScanContext = {}): Promise<ScanResult> {
console.log(`[Scanner] Starting scan in: ${directory}`); console.log(`[Scanner] Starting scan in: ${directory}`);
this.enqueuedArtists.clear(); this.enqueuedArtists.clear();
this.enqueuedAlbums.clear(); this.enqueuedAlbums.clear();
await this.walk(directory); this.audioAnalysisEnabled = await this.loadAudioAnalysisSetting();
const trackIds: string[] = [];
await this.walk(directory, context, trackIds);
console.log(`[Scanner] Scan completed.`); console.log(`[Scanner] Scan completed.`);
return { trackIds };
} }
private async walk(dir: string) { private async walk(dir: string, context: ScanContext, trackIds: string[]) {
const entries = await fs.readdir(dir, { withFileTypes: true }); const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
const fullPath = path.join(dir, entry.name); const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
await this.walk(fullPath); await this.walk(fullPath, context, trackIds);
} else if (this.isMusicFile(entry.name)) { } else if (this.isMusicFile(entry.name)) {
await this.processFile(fullPath); const trackId = await this.processFile(fullPath, context);
if (trackId) trackIds.push(trackId);
} }
} }
} }
@@ -108,7 +129,7 @@ export class ScannerService {
return extensions.includes(path.extname(fileName).toLowerCase()); return extensions.includes(path.extname(fileName).toLowerCase());
} }
private async processFile(filePath: string) { private async processFile(filePath: string, context: ScanContext): Promise<string | null> {
try { try {
console.log(`[Scanner] Processing: ${filePath}`); console.log(`[Scanner] Processing: ${filePath}`);
const metadata = await mm.parseFile(filePath); const metadata = await mm.parseFile(filePath);
@@ -159,19 +180,44 @@ export class ScannerService {
const duration = format.duration || 0; const duration = format.duration || 0;
const fileHash = await hashFile(filePath); const fileHash = await hashFile(filePath);
// A recommendation scan sets provenance at creation time. A routine
// library rescan must never erase that provenance or reset probation.
const sourceType = context.sourceType ?? 'MANUAL';
const state = sourceType === 'RECOMMENDATION' ? 'RECOMMENDED' : 'LIBRARY';
const probationStatus = sourceType === 'RECOMMENDATION'
? (context.probationStatus ?? 'probation')
: 'retained';
const trackRes = await this.pgClient.query( const trackRes = await this.pgClient.query(
`INSERT INTO tracks (path, hash, title, artist, album_id, duration, state) `INSERT INTO tracks (
VALUES ($1, $2, $3, $4, $5, $6, 'LIBRARY') path, hash, title, artist, album_id, duration, state, source_type,
probation_status, probation_entered_at
)
VALUES (
$1, $2, $3, $4, $5, $6, $7::track_state, $8::track_source_type,
$9, CASE WHEN $8::track_source_type = 'RECOMMENDATION' THEN NOW() ELSE NULL END
)
ON CONFLICT (path) DO UPDATE SET ON CONFLICT (path) DO UPDATE SET
hash = EXCLUDED.hash, hash = EXCLUDED.hash,
title = EXCLUDED.title, title = EXCLUDED.title,
artist = EXCLUDED.artist, artist = EXCLUDED.artist,
album_id = EXCLUDED.album_id, album_id = EXCLUDED.album_id,
duration = EXCLUDED.duration, duration = EXCLUDED.duration,
mtime = EXTRACT(EPOCH FROM NOW()) mtime = EXTRACT(EPOCH FROM NOW()),
-- Existing recommendation rows stay recommendations during every
-- ordinary scan. This is load-bearing for probation and Vibe.
state = CASE WHEN tracks.source_type = 'RECOMMENDATION'
THEN tracks.state ELSE EXCLUDED.state END,
source_type = CASE WHEN tracks.source_type = 'RECOMMENDATION'
THEN tracks.source_type ELSE $8::track_source_type END,
probation_status = CASE WHEN tracks.source_type = 'RECOMMENDATION'
THEN tracks.probation_status ELSE $9 END,
probation_entered_at = CASE WHEN tracks.source_type = 'RECOMMENDATION'
THEN tracks.probation_entered_at
WHEN $8::track_source_type = 'RECOMMENDATION' THEN NOW()
ELSE tracks.probation_entered_at END
RETURNING id RETURNING id
`, `,
[filePath, fileHash, trackTitle, resolvedArtist, albumId, duration] [filePath, fileHash, trackTitle, resolvedArtist, albumId, duration, state, sourceType, probationStatus]
); );
const trackId = String(trackRes.rows[0].id); const trackId = String(trackRes.rows[0].id);
@@ -196,8 +242,10 @@ export class ScannerService {
// Trigger external-API enrichment for this track + artist + album. // Trigger external-API enrichment for this track + artist + album.
// Best-effort: an enqueue failure must never abort the scan of remaining files. // Best-effort: an enqueue failure must never abort the scan of remaining files.
await this.enqueueEnrichment(trackId, String(artistId), String(albumId)); await this.enqueueEnrichment(trackId, String(artistId), String(albumId));
return trackId;
} catch (err) { } catch (err) {
console.error(`[Scanner] Error processing ${filePath}:`, err); console.error(`[Scanner] Error processing ${filePath}:`, err);
return null;
} }
} }
@@ -258,6 +306,35 @@ export class ScannerService {
removeOnFail: { age: 86400, count: 5000 }, removeOnFail: { age: 86400, count: 5000 },
} as const; } as const;
// Audio decoding is CPU/memory intensive. It must never be performed inline
// with metadata refreshes: enqueue one deduplicated, retryable job only when
// the user has enabled it and the file has changed or lacks the current
// analysis version.
if (this.audioAnalysisEnabled) {
try {
const current = await this.pgClient.query<{ current: boolean }>(
`SELECT EXISTS (
SELECT 1
FROM tracks t
JOIN track_audio_features af ON af.track_id = t.id
WHERE t.id = $1
AND af.analysis_version >= $2
AND af.source_hash = t.hash
) AS current`,
[trackId, AUDIO_ANALYSIS_VERSION]
);
if (!current.rows[0]?.current) {
const payload: AudioAnalysisJob = { trackId };
await this.queue.add('audio_analysis', payload, {
jobId: audioAnalysisJobId(trackId),
...AUDIO_ANALYSIS_JOB_OPTIONS,
});
}
} catch (err) {
console.error(`[Scanner] Failed to enqueue audio_analysis for track ${trackId}:`, err);
}
}
// metadata_refresh per track. jobId `meta-<trackId>` collapses duplicate // metadata_refresh per track. jobId `meta-<trackId>` collapses duplicate
// pending jobs across re-scans; the handler (enrichTrack) is idempotent so // pending jobs across re-scans; the handler (enrichTrack) is idempotent so
// re-enqueues are always safe. BullMQ 5.x rejects colons in custom ids. // re-enqueues are always safe. BullMQ 5.x rejects colons in custom ids.
@@ -298,4 +375,18 @@ export class ScannerService {
console.error(`[Scanner] Failed to enqueue artist_image for artist ${artistId}:`, err); console.error(`[Scanner] Failed to enqueue artist_image for artist ${artistId}:`, err);
} }
} }
private async loadAudioAnalysisSetting(): Promise<boolean> {
try {
const result = await this.pgClient.query<{ value: string }>(
"SELECT value FROM settings WHERE key = 'enrich_audio_analysis'"
);
return result.rows[0]?.value === 'true';
} catch (err) {
// Safe default: a schema/startup problem must not fan out expensive DSP
// work across a scan.
console.warn('[Scanner] Audio analysis disabled: unable to read setting:', (err as Error).message);
return false;
}
}
} }
+14 -2
View File
@@ -5,7 +5,12 @@ export interface MetadataRefreshJob {
export interface AudioAnalysisJob { export interface AudioAnalysisJob {
trackId: string; trackId: string;
features: string[]; }
/** A bounded sweep is used for existing tracks, never an unbounded startup job. */
export interface AudioAnalysisSweepJob {
reason?: 'scheduled' | 'manual';
limit?: number;
} }
export interface CleanupJob { export interface CleanupJob {
@@ -48,13 +53,20 @@ export interface ReprocessArtistsJob {
offset?: number; offset?: number;
} }
/** System E acquisition job. Candidate details stay in Postgres, not Redis. */
export interface AcquisitionJob {
candidateId: string;
}
export type JobPayload = export type JobPayload =
| MetadataRefreshJob | MetadataRefreshJob
| ArtistSimilarityJob | ArtistSimilarityJob
| ArtistImageJob | ArtistImageJob
| AlbumCoverJob | AlbumCoverJob
| AudioAnalysisJob | AudioAnalysisJob
| AudioAnalysisSweepJob
| CleanupJob | CleanupJob
| LibraryScanJob | LibraryScanJob
| IntegritySweepJob | IntegritySweepJob
| ReprocessArtistsJob; | ReprocessArtistsJob
| AcquisitionJob;