feat: enhance discovery, vibe sessions, and library enrichment
This commit is contained in:
+1
-1
@@ -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.
|
||||||
|
|||||||
@@ -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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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'");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Generated
+1269
-1
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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'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
@@ -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'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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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: () => {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
import { afterEach } from 'vitest';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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[] = [];
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Vendored
-1
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user