initial state: muzick music player + recommendation engine

This commit is contained in:
kami
2026-07-14 01:35:52 +04:00
commit 737bf19fd1
196 changed files with 32431 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.git
.env
+8
View File
@@ -0,0 +1,8 @@
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm install --legacy-peer-deps
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "run", "start"]
+3206
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "muzick-backend",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "node dist/server.js",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"prebuild": "tsc --noEmit",
"build": "tsc",
"setup-db": "./scripts/setup-db.sh"
},
"dependencies": {
"bullmq": "^5.1.0",
"fastify": "^4.24.3",
"pg": "^8.11.3",
"redis": "^5.0.0",
"typesense": "^3.0.6"
},
"devDependencies": {
"@types/node": "^20.10.0",
"@types/pg": "^8.20.0",
"tsx": "^4.6.2",
"typescript": "^5.3.3",
"vitest": "^4.1.10"
}
}
+52
View File
@@ -0,0 +1,52 @@
import { Client as PgClient } from 'pg';
async function seed() {
const pgClient = new PgClient({
connectionString: process.env.DATABASE_URL,
});
await pgClient.connect();
console.log('Seeding database...');
try {
// Clear existing data
await pgClient.query('TRUNCATE artists, albums, tracks, genre, track_genre, dislikes, recommendation_batch, recommendation_batch_track, track_audio_features, track_lyrics CASCADE');
// Insert an artist
const artistRes = await pgClient.query(
'INSERT INTO artists (name, mbid) VALUES ($1, $2) RETURNING id',
['Daft Punk', '5742e173-e031-4848-90a4-977799791608']
);
const artistId = artistRes.rows[0].id;
// Insert an album
const albumRes = await pgClient.query(
'INSERT INTO albums (artist_id, title, year) VALUES ($1, $2, $3) RETURNING id',
[artistId, 'Discovery', 2001]
);
const albumId = albumRes.rows[0].id;
// Insert tracks
await pgClient.query(
`INSERT INTO tracks (path, hash, title, artist, album_id, duration, state, source_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
['/music/daft_punk/discovery/one_more_time.mp3', 'hash1', 'One More Time', 'Daft Punk', albumId, 320, 'LIBRARY', 'MANUAL']
);
await pgClient.query(
`INSERT INTO tracks (path, hash, title, artist, album_id, duration, state, source_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
['/music/daft_punk/discovery/harder_better_faster_stronger.mp3', 'hash2', 'Harder, Better, Faster, Stronger', 'Daft Punk', albumId, 224, 'LIBRARY', 'MANUAL']
);
console.log('Seeding successful!');
} catch (err) {
console.error('Seeding failed:', err);
process.exit(1);
} finally {
await pgClient.end();
}
}
seed();
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# Initialize the database schema
psql "$DATABASE_URL" -f src/db/schema.sql
echo "Database initialized successfully."
+185
View File
@@ -0,0 +1,185 @@
import Fastify from 'fastify';
import { Client as PgClient } from 'pg';
import { createClient as createRedisClient } from 'redis';
import { DbService } from './services/db.service.js';
import { JobService } from './services/job.service.js';
import { SearchService } from './services/search.service.js';
import libraryRoutes from './routes/library.routes.js';
import searchRoutes from './routes/search.routes.js';
import adminRoutes from './routes/admin.routes.js';
import vibeRoutes from './routes/vibe.routes.js';
import historyRoutes from './routes/history.routes.js';
import streamRoutes from './routes/stream.routes.js';
import quarantineRoutes from './routes/quarantine.routes.js';
import settingsRoutes from './routes/settings.routes.js';
import graphRoutes from './routes/graph.routes.js';
import { SessionDirector } from './services/session-director.service.js';
import v2Routes from './routes/v2.routes.js';
import discoveryRoutes from './routes/discovery.routes.js';
import imagesRoutes from './routes/images.routes.js';
export interface AppConfig {
port: number;
searchHost: string;
searchPort: number;
searchApiKey: string;
}
export async function buildApp(config: AppConfig) {
const fastify = Fastify({ logger: true });
const pgClient = new PgClient({
connectionString: process.env.DATABASE_URL,
});
await pgClient.connect();
const redisClient = createRedisClient({
url: process.env.REDIS_URL,
});
await redisClient.connect();
const jobService = new JobService({ redisUrl: process.env.REDIS_URL! });
const searchService = new SearchService({
host: config.searchHost,
port: config.searchPort,
protocol: 'http',
apiKey: config.searchApiKey,
});
const dbService = new DbService(pgClient, searchService);
// Apply the idempotent schema on boot so tables added after the initial DB
// volume was created (e.g. play_history, feedback) exist. The init-time
// docker-entrypoint mount only runs on first init, so older volumes miss them.
await dbService.ensureSchema();
await dbService.runMigrations();
// Keep the claim_fusion materialised view fresh. The trigger on
// `claims` fires NOTIFY on every change; rather than maintain a
// LISTEN consumer (separate long-lived connection), we refresh on a
// short interval. 10s staleness is well below any user-facing
// latency for a homelab music player.
const FUSION_REFRESH_MS = 10_000;
const fusionTimer = setInterval(() => {
dbService.refreshClaimFusion().catch(() => {});
}, FUSION_REFRESH_MS);
// Daily belief decay (spec §B.4). Runs hourly; the SQL only touches
// beliefs whose last_decayed_at is >1h old, so frequent runs are safe.
const DECAY_INTERVAL_MS = 60 * 60 * 1000;
const decayTimer = setInterval(() => {
dbService.decayBeliefs().catch((e) => console.error('[DB] belief decay failed:', e));
}, DECAY_INTERVAL_MS);
// Nightly 'forgotten' profile derivation (spec §B.2).
const FORGOTTEN_INTERVAL_MS = 24 * 60 * 60 * 1000;
const forgottenTimer = setInterval(() => {
dbService.deriveForgottenProfile().catch((e) =>
console.error('[DB] forgotten derivation failed:', e)
);
}, FORGOTTEN_INTERVAL_MS);
// Run both once at boot so the first session benefits.
dbService.decayBeliefs().catch(() => {});
dbService.deriveForgottenProfile().catch(() => {});
// Ensure the Typesense 'tracks' collection schema exists on boot so that
// the first search request doesn't hit a 404.
await searchService.ensureCollection();
// Health check route
fastify.get('/api/health', async (request, reply) => {
const status = {
postgres: 'unknown',
redis: 'unknown',
};
try {
await pgClient.query('SELECT 1');
status.postgres = 'ok';
} catch (err) {
status.postgres = 'error';
fastify.log.error(err);
}
try {
const redisRes = await redisClient.ping();
if (redisRes === 'PONG') {
status.redis = 'ok';
}
} catch (err) {
status.redis = 'error';
fastify.log.error(err);
}
const isHealthy = status.postgres === 'ok' && status.redis === 'ok';
if (isHealthy) {
return reply.code(200).send(status);
} else {
return reply.code(503).send(status);
}
});
fastify.register(imagesRoutes, { prefix: '/api' });
fastify.register(libraryRoutes, { prefix: '/api', dbService });
fastify.register(searchRoutes, { prefix: '/api', dbService });
fastify.register(adminRoutes, { prefix: '/api/admin', jobService, dbService });
fastify.register(vibeRoutes, { prefix: '/api/vibe', dbService });
fastify.register(historyRoutes, { prefix: '/api', dbService });
fastify.register(streamRoutes, { prefix: '/api', dbService });
fastify.register(quarantineRoutes, { prefix: '/api', dbService });
fastify.register(settingsRoutes, { prefix: '/api', dbService });
fastify.register(graphRoutes, { prefix: '/api', dbService });
const sessionDirector = new SessionDirector(dbService);
fastify.register(v2Routes, { prefix: '/api', dbService, sessionDirector });
fastify.register(discoveryRoutes, { prefix: '/api', dbService });
fastify.post('/api/test/enqueue-job', async (request, reply) => {
const { jobType, trackId, payload } = request.body as any;
try {
if (jobType === 'metadataRefresh') {
await jobService.enqueueMetadataRefresh(trackId, payload.type);
} else if (jobType === 'audioAnalysis') {
await jobService.enqueueAudioAnalysis(trackId, payload.features);
} else if (jobType === 'cleanup') {
await jobService.enqueueCleanup(payload.reason, payload.targetFiles);
} else {
return reply.code(400).send({ error: 'Unknown job type' });
}
await reply.send({ message: 'Job enqueued' });
} catch (error) {
request.log.error(error);
await reply.status(500).send({ error: 'Internal server error' });
}
});
// Register hooks to close connections on shutdown
fastify.addHook('onClose', async () => {
try {
clearInterval(fusionTimer);
clearInterval(decayTimer);
clearInterval(forgottenTimer);
} catch (err) {
fastify.log.error(err);
}
try {
await pgClient.end();
} catch (err) {
fastify.log.error(err);
}
try {
await redisClient.quit();
} catch (err) {
fastify.log.error(err);
}
try {
await jobService.close();
} catch (err) {
fastify.log.error(err);
}
});
return { fastify, pgClient, redisClient };
}
+490
View File
@@ -0,0 +1,490 @@
-- Enums
-- Guarded so this file is idempotent and can be re-applied on every backend boot
-- (CREATE TYPE has no IF NOT EXISTS; swallow the duplicate_object error instead).
DO $$ BEGIN
CREATE TYPE track_state AS ENUM ('LIBRARY', 'RECOMMENDED', 'HIDDEN', 'MISSING', 'DELETED');
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
CREATE TYPE track_source_type AS ENUM ('MANUAL', 'RECOMMENDATION');
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
CREATE TYPE recommendation_status AS ENUM ('ACTIVE', 'RESOLVED', 'FAILED');
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
CREATE TYPE dislike_state AS ENUM ('HIDDEN', 'WARNED', 'DELETED');
EXCEPTION WHEN duplicate_object THEN null; END $$;
-- Reduce an artist string to its PRIMARY (first-billed) artist, so that every
-- form of a collaboration maps to the same canonical identity:
-- "Artist feat. Guest", "Artist ft. Guest", "Artist x Guest",
-- "Artist & Guest", "Artist; Guest", "Artist, Guest", "Artist / Guest"
-- all map to "Artist". This is the identity used by the normalized_name /
-- normalized_artist generated columns, dedup, and the Vibe engine. The full
-- list of co-billed artists is preserved separately in the track_artists table
-- (populated by the scanner and the split-collab-artists migration); this
-- function intentionally only yields the main artist.
-- Also handles parenthesized feature forms like "(feat. X)".
CREATE OR REPLACE FUNCTION normalize_artist(artist TEXT) RETURNS TEXT AS $$
DECLARE
result TEXT;
BEGIN
-- 1. Strip feat/ft/vs/x feature suffixes (+ everything after them), incl.
-- parenthesized forms like "(feat. X)" / "(Feat. X)".
result := REGEXP_REPLACE(
artist,
'\s*\(?\s*([fF]eat(uring)?\.?|[fF]t\.?|[vV]s\.?|[xX])\s+.*$',
''
);
-- 2. Cut at the first collaboration separator ( ; & / , ) and keep the part
-- before it: "$bunny, Metox" -> "$bunny", "Booker & ЗАМАЙ" -> "Booker".
result := REGEXP_REPLACE(result, '\s*[;&/,].*$', '');
result := BTRIM(result);
RETURN result;
END;
$$ LANGUAGE plpgsql IMMUTABLE STRICT;
-- Tables
CREATE TABLE IF NOT EXISTS artists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Canonical display name (e.g., "P!nk" not "Pink")
canonical_name TEXT NOT NULL,
-- Sort name for alphabetical ordering (e.g., "Pink, P!" or "Beatles, The")
sort_name TEXT,
-- MusicBrainz ID: the canonical identity. NULL for artists not in MB.
-- Unique when present so we never create duplicate MB artists.
mbid UUID UNIQUE,
-- Fallback: legacy name used before MBID resolution.
-- Not unique; multiple rows can have same name before dedup.
name TEXT NOT NULL,
normalized_name TEXT
GENERATED ALWAYS AS (normalize_artist(name)) STORED,
discogs_id TEXT,
image_path TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_artists_mbid ON artists(mbid) WHERE mbid IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_artists_normalized_name ON artists(normalized_name);
-- Artist aliases: alternative names for the same artist.
-- Enables matching "Pink", "P!nk", "PINK" to the same artist_id.
CREATE TABLE IF NOT EXISTS artist_aliases (
artist_id UUID NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
alias TEXT NOT NULL,
alias_normalized TEXT GENERATED ALWAYS AS (normalize_artist(alias)) STORED,
PRIMARY KEY (artist_id, alias)
);
CREATE INDEX IF NOT EXISTS idx_artist_aliases_normalized ON artist_aliases(alias_normalized);
-- Artist lookup cache: avoids repeated MusicBrainz queries.
-- Keyed by normalized artist name.
CREATE TABLE IF NOT EXISTS artist_lookup_cache (
normalized_name TEXT PRIMARY KEY,
mbid UUID,
canonical_name TEXT,
sort_name TEXT,
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Track if we looked up and found nothing (negative cache)
not_found BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS albums (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
artist_id UUID REFERENCES artists(id) ON DELETE CASCADE,
title TEXT NOT NULL,
year INTEGER,
-- Full release date from MusicBrainz first-release-date (YYYY-MM-DD).
-- More precise than `year` (which can also come from Discogs). Used as a
-- deterministic tiebreaker in album dedup (earlier release = keeper).
release_date DATE,
artwork_id TEXT,
mbid UUID UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(artist_id, title)
);
CREATE INDEX IF NOT EXISTS idx_albums_mbid ON albums(mbid) WHERE mbid IS NOT NULL;
CREATE TABLE IF NOT EXISTS tracks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
path TEXT UNIQUE NOT NULL,
hash TEXT NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
album_id UUID REFERENCES albums(id) ON DELETE CASCADE,
duration REAL NOT NULL,
state track_state DEFAULT 'LIBRARY',
play_count INTEGER DEFAULT 0,
skip_count INTEGER DEFAULT 0,
dislike_count INTEGER DEFAULT 0,
last_played_at TIMESTAMP,
mtime REAL,
source_type track_source_type DEFAULT 'MANUAL',
quarantined_at TIMESTAMP,
deleted_at TIMESTAMP,
release_date DATE
);
-- 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).
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'tracks' AND column_name = 'release_date'
) THEN
ALTER TABLE tracks ADD COLUMN release_date DATE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_tracks_release_date ON tracks (release_date) WHERE release_date IS NOT NULL;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'tracks' AND column_name = 'normalized_artist'
) THEN
ALTER TABLE tracks ADD COLUMN normalized_artist TEXT
GENERATED ALWAYS AS (normalize_artist(artist)) STORED;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'artists' AND column_name = 'normalized_name'
) THEN
ALTER TABLE artists ADD COLUMN normalized_name TEXT
GENERATED ALWAYS AS (normalize_artist(name)) STORED;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_tracks_hash ON tracks(hash);
CREATE INDEX IF NOT EXISTS idx_tracks_normalized_artist ON tracks(normalized_artist);
CREATE INDEX IF NOT EXISTS idx_artists_normalized_name ON artists(normalized_name);
-- Tracks integrity issues found by the periodic integrity-sweep worker.
-- Created with IF NOT EXISTS so the worker can self-provision this table at
-- runtime on databases that predate this schema change (see IntegrityService.ensureSchema).
CREATE TABLE IF NOT EXISTS track_integrity_issues (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
issue_type TEXT NOT NULL, -- 'CORRUPT_METADATA' | 'MISSING_FILE'
status TEXT NOT NULL DEFAULT 'OPEN', -- 'OPEN' | 'FIXED' | 'NEEDS_REVIEW'
details TEXT, -- human-readable: e.g. the corrupted value
detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
resolved_at TIMESTAMP,
UNIQUE(track_id, issue_type)
);
-- Last.fm artist similarity, feeds Vibe discovery. Populated by the worker's
-- `artist_similarity` job (see EnrichmentService.refreshArtistSimilarity).
-- Created with IF NOT EXISTS so the worker can self-provision this table at
-- runtime on databases that predate this schema change.
--
-- mbid storage decision: artists.mbid is typed UUID and MusicBrainz MBIDs are
-- themselves UUID-format strings, so the worker stores the artist MBID directly
-- in the existing artists.mbid column (guarded with a UUID-shape check before
-- the write). No mbid_text column was needed.
CREATE TABLE IF NOT EXISTS artist_similar (
artist_id UUID REFERENCES artists(id) ON DELETE CASCADE,
similar_name TEXT NOT NULL,
match REAL NOT NULL DEFAULT 0,
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (artist_id, similar_name)
);
CREATE TABLE IF NOT EXISTS genre (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
parent_id UUID REFERENCES genre(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS track_genre (
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
genre_id UUID REFERENCES genre(id) ON DELETE CASCADE,
weight DECIMAL NOT NULL DEFAULT 1.0,
PRIMARY KEY (track_id, genre_id)
);
CREATE TABLE IF NOT EXISTS dislikes (
track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
disliked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
warned_at TIMESTAMP,
deleted_at TIMESTAMP,
grace_hours INTEGER DEFAULT 48,
state dislike_state DEFAULT 'HIDDEN'
);
CREATE TABLE IF NOT EXISTS favorites (
user_id UUID NOT NULL,
track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS recommendation_batch (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
status recommendation_status DEFAULT 'ACTIVE',
last_interaction_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
seed_track_id UUID REFERENCES tracks(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS recommendation_batch_track (
batch_id UUID REFERENCES recommendation_batch(id) ON DELETE CASCADE,
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
PRIMARY KEY (batch_id, track_id)
);
-- Play history: one row per playback event. Feeds the Vibe engine's
-- "Success-Driven Center" rule (a completed play moves the active batch's center)
-- and the feedback learning loop. Created with IF NOT EXISTS so it can be
-- self-provisioned on databases that predate this schema change.
CREATE TABLE IF NOT EXISTS play_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
batch_id UUID REFERENCES recommendation_batch(id) ON DELETE SET NULL,
completed BOOLEAN NOT NULL DEFAULT false,
played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_play_history_user_played_at ON play_history(user_id, played_at DESC);
-- Feedback: explicit user signals consumed by the Vibe scorer's feedback
-- learning loop. action is one of 'promoted' | 'disliked' | 'skipped' | 'deleted_permanent'.
CREATE TABLE IF NOT EXISTS feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
action TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_feedback_user_action ON feedback(user_id, action);
CREATE TABLE IF NOT EXISTS track_audio_features (
track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
bpm REAL,
key TEXT,
energy REAL,
danceability REAL,
valence REAL,
acousticness REAL,
instrumentalness REAL,
liveness REAL,
valence_score REAL,
tempo REAL
);
CREATE TABLE IF NOT EXISTS track_lyrics (
track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
lyrics_text TEXT,
provider TEXT,
language VARCHAR(10),
synced_lyrics JSONB
);
-- Enrichment settings: toggles that control which external-enrichment steps the
-- worker runs. Default all to true (best-effort, credentials-permitting).
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO settings (key, value) VALUES ('enrich_metadata', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('enrich_cover_art', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('enrich_genres', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('enrich_lyrics', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('enrich_artist_similarity', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('enrich_audio_analysis', 'false') ON CONFLICT (key) DO NOTHING;
-- ==========================================================================
-- v2 Recommendation Engine — System A: Knowledge Graph (probabilistic fusion)
-- ==========================================================================
-- Source trust weights. One row per source of claims. Tunable.
CREATE TABLE IF NOT EXISTS source_trust (
key TEXT PRIMARY KEY,
trust REAL NOT NULL CHECK (trust >= 0 AND trust <= 1.0),
description TEXT NOT NULL
);
INSERT INTO source_trust (key, trust, description) VALUES
('curated', 1.00, 'Manual / human-curated claim. Never decayed.'),
('mb', 0.90, 'MusicBrainz structural spine. High-trust seed; not infallible.'),
('cover_art_archive', 0.85, 'Cover Art Archive, MB-backed.'),
('discogs', 0.75, 'Discogs release/artist credits.'),
('lastfm', 0.50, 'Last.fm tags + similar. Noisy; used as weak signal.'),
('listener_behavior', 0.40, 'Derived from observed play patterns. User-keyed.'),
('tag', 0.30, 'File-tag-derived via scanner heuristic. Lowest trust.')
ON CONFLICT (key) DO NOTHING;
-- Claims: the spine of the graph. One row per (subject, predicate, object, source).
-- user_id is NULL for objective claims (MB, Discogs, tags), non-NULL for
-- listener-behavior-derived claims.
CREATE TABLE IF NOT EXISTS claims (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
subject_type TEXT NOT NULL,
subject_id UUID NOT NULL,
predicate TEXT NOT NULL,
object_type TEXT NOT NULL,
object_id UUID NOT NULL,
source TEXT NOT NULL REFERENCES source_trust(key),
confidence REAL NOT NULL DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1.0),
evidence_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_reinforced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
raw JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
);
CREATE INDEX IF NOT EXISTS idx_claims_subject ON claims (subject_type, subject_id, predicate);
CREATE INDEX IF NOT EXISTS idx_claims_object ON claims (object_type, object_id, predicate);
CREATE INDEX IF NOT EXISTS idx_claims_user ON claims (user_id) WHERE user_id IS NOT NULL;
-- recording_mbid on tracks — the structural spine anchor for the graph
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'tracks' AND column_name = 'recording_mbid'
) THEN
ALTER TABLE tracks ADD COLUMN recording_mbid UUID;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_tracks_recording_mbid ON tracks (recording_mbid) WHERE recording_mbid IS NOT NULL;
-- ==========================================================================
-- System B: Listener Model
-- ==========================================================================
-- Evidence: every observed interaction that should influence a belief.
-- Append-only. Never edited or deleted (purge policy separate).
CREATE TABLE IF NOT EXISTS evidence (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
entity_type TEXT NOT NULL,
entity_id UUID NOT NULL,
signal TEXT NOT NULL,
profile TEXT NOT NULL,
weight REAL NOT NULL,
context JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_evidence_user_entity ON evidence (user_id, entity_type, entity_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_evidence_user_profile ON evidence (user_id, profile, created_at DESC);
-- Listener beliefs: the derived state. Continuously decayed; reinforced by evidence.
CREATE TABLE IF NOT EXISTS listener_beliefs (
user_id UUID NOT NULL,
profile TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id UUID NOT NULL,
dimension TEXT NOT NULL,
value REAL NOT NULL CHECK (value >= -1.0 AND value <= 1.0),
confidence REAL NOT NULL CHECK (confidence >= 0 AND confidence <= 1.0),
evidence_count INTEGER NOT NULL DEFAULT 0,
last_reinforced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_decayed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, profile, entity_type, entity_id, dimension)
);
CREATE INDEX IF NOT EXISTS idx_listener_beliefs_user_profile ON listener_beliefs (user_id, profile, entity_type, entity_id);
-- ==========================================================================
-- System D: Session Director
-- ==========================================================================
-- Per-session state; persisted across heartbeats so resumes stay coherent.
CREATE TABLE IF NOT EXISTS session_state (
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_interaction TIMESTAMPTZ NOT NULL DEFAULT NOW(),
context TEXT,
state_vector JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_session_state_user ON session_state (user_id, last_interaction DESC);
-- Diversity budgets for the session director's planner.
CREATE TABLE IF NOT EXISTS diversity_budgets (
user_id UUID NOT NULL,
dimension TEXT NOT NULL,
budget_share REAL NOT NULL,
horizon_min INTEGER NOT NULL,
PRIMARY KEY (user_id, dimension, horizon_min)
);
-- Adaptive minimum-distance repetition rules.
CREATE TABLE IF NOT EXISTS repetition_rules (
user_id UUID NOT NULL,
dimension TEXT NOT NULL,
min_distance INTEGER NOT NULL,
PRIMARY KEY (user_id, dimension)
);
-- ==========================================================================
-- System E: Acquisition Pipeline
-- ==========================================================================
-- Discovery candidates: tracks not yet in the library, identified by E.
CREATE TABLE IF NOT EXISTS discovery_candidates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source TEXT NOT NULL,
external_id TEXT NOT NULL,
title TEXT,
artist_credit JSONB,
notes JSONB,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_eval_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'candidate',
UNIQUE (source, external_id)
);
-- Probation status for acquired tracks.
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'tracks' AND column_name = 'probation_status'
) THEN
ALTER TABLE tracks ADD COLUMN probation_status TEXT
DEFAULT 'retained'
CHECK (probation_status IN ('probation', 'retained', 'retired'));
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'tracks' AND column_name = 'probation_entered_at'
) THEN
ALTER TABLE tracks ADD COLUMN probation_entered_at TIMESTAMPTZ;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_tracks_probation ON tracks (probation_status) WHERE probation_status = 'probation';
-- ==========================================================================
-- Phase 4 (preserved): Image candidates
-- ==========================================================================
CREATE TABLE IF NOT EXISTS image_candidates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type TEXT NOT NULL CHECK (entity_type IN ('artist', 'album')),
entity_id UUID NOT NULL,
source TEXT NOT NULL,
url TEXT,
width INTEGER,
verified BOOLEAN DEFAULT FALSE,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (entity_type, entity_id, source)
);
CREATE INDEX IF NOT EXISTS idx_image_candidates_entity ON image_candidates (entity_type, entity_id);
+1
View File
@@ -0,0 +1 @@
console.log('Backend starting...');
+194
View File
@@ -0,0 +1,194 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { JobService } from '../services/job.service.js';
import { DbService } from '../services/db.service.js';
export default async function adminRoutes(fastify: FastifyInstance, options: { jobService: JobService; dbService: DbService }) {
const { jobService, dbService } = options;
fastify.post('/scan', async (request: FastifyRequest, reply: FastifyReply) => {
const { directory } = request.body as { directory: string };
if (!directory) {
return reply.code(400).send({ error: 'Directory is required' });
}
await jobService.enqueueLibraryScan(directory);
return { status: 'Scan job enqueued', directory };
});
fastify.post('/reindex-tracks', async (_request: FastifyRequest, reply: FastifyReply) => {
await jobService.enqueueReindexTracks();
return { status: 'Reindex job enqueued' };
});
fastify.post('/reprocess-artists', async (_request: FastifyRequest, reply: FastifyReply) => {
await jobService.enqueueReprocessArtists();
return { status: 'Artist reprocessing job enqueued' };
});
fastify.post('/dedup-albums', async (_request: FastifyRequest, reply: FastifyReply) => {
// Merge duplicate album rows directly (synchronous — it's just SQL, no
// external API calls). Returns the number of albums merged away.
// Tiebreaker for keeper selection: MBID > artwork > earliest release_date
// > most tracks > oldest created_at.
//
// Note: the two duplicate-detection passes (by title and by MBID) may find
// overlapping pairs; the UNION ALL in `pairs` can produce duplicates, but
// the DELETE at the end is idempotent (a loser deleted in one pair won't
// exist for the next). The folded/moved CTEs also tolerate this because
// COALESCE is idempotent and the loser row simply won't be found again.
const res = await dbService.pgClient.query<{ count: number }>(`
WITH duplicates AS (
SELECT lower(title) AS lt, array_agg(id ORDER BY
CASE WHEN mbid IS NOT NULL THEN 0 ELSE 1 END,
CASE WHEN artwork_id IS NOT NULL AND artwork_id <> '' THEN 0 ELSE 1 END,
release_date NULLS LAST,
(SELECT COUNT(*) FROM tracks t WHERE t.album_id = albums.id) DESC,
created_at
) AS ids
FROM albums GROUP BY lower(title) HAVING COUNT(*) > 1
),
mbid_dupes AS (
SELECT mbid, array_agg(id ORDER BY
CASE WHEN artwork_id IS NOT NULL AND artwork_id <> '' THEN 0 ELSE 1 END,
release_date NULLS LAST,
created_at
) AS ids
FROM albums WHERE mbid IS NOT NULL
GROUP BY mbid HAVING COUNT(*) > 1
),
pairs AS (
SELECT ids[1] AS keep_id, unnest(ids[2:]) AS loser_id FROM duplicates
UNION
SELECT ids[1] AS keep_id, unnest(ids[2:]) AS loser_id FROM mbid_dupes
),
-- Fold metadata from losers onto keepers (idempotent via COALESCE).
folded AS (
UPDATE albums a SET
artwork_id = COALESCE(a.artwork_id, src.artwork_id),
year = COALESCE(a.year, src.year),
mbid = COALESCE(a.mbid, src.mbid),
release_date = COALESCE(a.release_date, src.release_date)
FROM (
SELECT DISTINCT ON (p.loser_id) p.keep_id, lo.artwork_id, lo.year, lo.mbid, lo.release_date, p.loser_id
FROM pairs p
JOIN albums lo ON lo.id = p.loser_id
ORDER BY p.loser_id
) AS src
WHERE a.id = src.keep_id
),
-- Move tracks from losers to keepers.
moved AS (
UPDATE tracks SET album_id = src.keep_id
FROM (SELECT DISTINCT keep_id, loser_id FROM pairs) AS src
WHERE tracks.album_id = src.loser_id
),
-- Delete losers.
deleted AS (
DELETE FROM albums
WHERE id IN (SELECT DISTINCT loser_id FROM pairs)
RETURNING 1
)
SELECT COUNT(*)::int AS count FROM deleted
`);
return { status: 'Albums deduplicated', merged: res.rows[0]?.count ?? 0 };
});
fastify.post('/reenrich-tracks', async (_request: FastifyRequest, reply: FastifyReply) => {
// Re-enqueue metadata_refresh for every LIBRARY track without re-reading
// files from disk. This re-runs the MusicBrainz canonicalisation (artist
// names, album titles, MBIDs) and re-triggers album_cover jobs — much
// faster than a full scan when only metadata needs refreshing.
const res = await dbService.pgClient.query<{ id: string }>(
`SELECT id FROM tracks WHERE state = 'LIBRARY' ORDER BY id`
);
const trackIds = res.rows.map((r) => r.id);
const enqueued = await jobService.enqueueMetadataRefreshBatch(trackIds);
return { status: 'Re-enrich enqueued', trackCount: enqueued };
});
fastify.get('/queue-stats', async () => {
return await jobService.getQueueStats();
});
fastify.get('/job-history', async (request: FastifyRequest) => {
const { limit } = request.query as { limit?: string };
return await jobService.getJobHistory(parseInt(limit || '100', 10));
});
fastify.get('/duplicates', async (request) => {
const { mode } = request.query as { mode?: string };
return await dbService.getDuplicateGroups(mode === 'title-artist' ? 'title-artist' : 'hash');
});
fastify.post('/duplicates/merge', async (request: FastifyRequest, reply: FastifyReply) => {
const { keepId, deleteIds } = request.body as { keepId: string; deleteIds: string[] };
if (!keepId || !Array.isArray(deleteIds) || deleteIds.length === 0) {
return reply.code(400).send({ error: 'keepId and deleteIds[] are required' });
}
await dbService.mergeDuplicates(keepId, deleteIds);
return { status: 'merged', kept: keepId, deleted: deleteIds.length };
});
fastify.get('/artist-stats', async (request: FastifyRequest, reply: FastifyReply) => {
const db = dbService.pgClient;
const total = await db.query('SELECT COUNT(*)::int AS n FROM artists');
const withMbid = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE mbid IS NOT NULL');
const withCanonical = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE canonical_name IS NOT NULL');
const withSort = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE sort_name IS NOT NULL');
const withImage = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE image_path IS NOT NULL AND image_path != \'\'');
const aliases = await db.query('SELECT COUNT(*)::int AS n FROM artist_aliases');
const cache = await db.query('SELECT COUNT(*)::int AS n FROM artist_lookup_cache');
const noImage = await db.query(`
SELECT name, canonical_name, mbid, sort_name
FROM artists
WHERE image_path IS NULL OR image_path = ''
ORDER BY name
LIMIT 50
`);
return {
total: total.rows[0].n,
withMbid: withMbid.rows[0].n,
withCanonicalName: withCanonical.rows[0].n,
withSortName: withSort.rows[0].n,
withImage: withImage.rows[0].n,
withoutImage: total.rows[0].n - withImage.rows[0].n,
aliases: aliases.rows[0].n,
cacheSize: cache.rows[0].n,
imageCoverage: `${((withImage.rows[0].n / total.rows[0].n) * 100).toFixed(1)}%`,
artistsWithoutImage: noImage.rows,
};
});
fastify.get('/artist-verify/:name', async (request: FastifyRequest, reply: FastifyReply) => {
const { name } = request.params as { name: string };
const db = dbService.pgClient;
const exact = await db.query(
`SELECT id, name, canonical_name, sort_name, mbid, image_path
FROM artists WHERE name = $1`,
[name]
);
const normalized = await db.query(
`SELECT id, name, canonical_name, sort_name, mbid, image_path
FROM artists WHERE normalize_artist(name) = normalize_artist($1)`,
[name]
);
const aliases = await db.query(
`SELECT a.*, ar.canonical_name as artist_canonical, ar.mbid as artist_mbid
FROM artist_aliases a
JOIN artists ar ON ar.id = a.artist_id
WHERE a.alias_normalized = normalize_artist($1)`,
[name]
);
return {
exactMatch: exact.rows,
normalizedMatches: normalized.rows,
aliases: aliases.rows,
};
});
}
+94
View File
@@ -0,0 +1,94 @@
import { FastifyInstance } from 'fastify';
import { DbService } from '../services/db.service.js';
import { DiscoveryService } from '../services/discovery.service.js';
import { ImageEnrichmentService } from '../services/image-enrichment.service.js';
export default async function discoveryRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
const { dbService } = options;
const discovery = new DiscoveryService(dbService);
const images = new ImageEnrichmentService(dbService);
/**
* POST /api/discovery/walk — trigger graph walk for discovery candidates
*/
fastify.post('/discovery/walk', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const count = await discovery.walkGraphForDiscovery(userId);
return reply.send({ newCandidates: count });
});
/**
* GET /api/discovery/candidates — list discovery candidates
* Query: ?status=candidate&limit=50
*/
fastify.get('/discovery/candidates', async (request, reply) => {
const query = request.query as { status?: string; limit?: string };
const status = query.status || 'candidate';
const limit = parseInt(query.limit || '50', 10);
const res = await dbService.pgClient.query(
`SELECT * FROM discovery_candidates WHERE status = $1 ORDER BY first_seen_at DESC LIMIT $2`,
[status, limit]
);
return reply.send({ candidates: res.rows });
});
/**
* POST /api/discovery/eval — evaluate pending candidates for acquisition
*/
fastify.post('/discovery/eval', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const results = await discovery.evalCandidates(userId);
return reply.send({ evaluated: results.length, results });
});
/**
* POST /api/discovery/sweep-probation — evaluate probation tracks
*/
fastify.post('/discovery/sweep-probation', async (_request, reply) => {
const result = await discovery.sweepProbation();
return reply.send(result);
});
/**
* POST /api/discovery/meta-learn — run meta-learning
*/
fastify.post('/discovery/meta-learn', async (_request, reply) => {
await discovery.runMetaLearning();
return reply.send({ status: 'ok' });
});
/**
* POST /api/images/fetch — mark image candidates for an entity
* Body: { entity_type, entity_id }
*/
fastify.post('/images/fetch', async (request, reply) => {
const body = request.body as { entity_type: string; entity_id: string };
if (!body.entity_type || !body.entity_id) {
return reply.code(400).send({ error: 'entity_type and entity_id required' });
}
let count = 0;
if (body.entity_type === 'artist') {
count = await images.fetchImagesForArtist(body.entity_id);
} else if (body.entity_type === 'album') {
count = await images.fetchImagesForAlbum(body.entity_id);
} else {
return reply.code(400).send({ error: 'entity_type must be "artist" or "album"' });
}
return reply.send({ candidateRows: count });
});
/**
* POST /api/images/select — select best image for an entity
* Body: { entity_type, entity_id }
*/
fastify.post('/images/select', async (request, reply) => {
const body = request.body as { entity_type: string; entity_id: string };
if (!body.entity_type || !body.entity_id) {
return reply.code(400).send({ error: 'entity_type and entity_id required' });
}
const url = await images.selectBestImage(body.entity_type, body.entity_id);
return reply.send({ url });
});
}
+152
View File
@@ -0,0 +1,152 @@
import { FastifyInstance } from 'fastify';
import { DbService } from '../services/db.service.js';
export default async function graphRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
const { dbService } = options;
/**
* GET /api/graph/artists/:id/fusion — fused artist credits for a track or album
* Query: ?entity_type=track&entity_id=<uuid>
* Returns the fused view of who is credited as main/featured on this entity.
*/
fastify.get('/graph/artists/:id/fusion', async (request, reply) => {
const { id } = request.params as { id: string };
const query = request.query as { entity_type?: string; entity_id?: string };
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
if (query.entity_type === 'track' && query.entity_id) {
const artists = await dbService.getFusedTrackArtists(query.entity_id, userId);
return reply.send({ entity_type: 'track', entity_id: query.entity_id, artists });
}
return reply.code(400).send({ error: 'Provide ?entity_type=track&entity_id=<uuid>' });
});
/**
* GET /api/graph/tracks/:id/claims — all claims for a track
*/
fastify.get('/graph/tracks/:id/claims', async (request, reply) => {
const { id } = request.params as { id: string };
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const claims = await dbService.getClaimsBySubject('track', id, undefined, userId);
return reply.send({ track_id: id, claims });
});
/**
* GET /api/graph/artists/:id/claims — all claims for an artist
*/
fastify.get('/graph/artists/:id/claims', async (request, reply) => {
const { id } = request.params as { id: string };
const predicate = (request.query as { predicate?: string }).predicate;
const claims = await dbService.getClaimsBySubject('artist', id, predicate);
return reply.send({ artist_id: id, claims });
});
/**
* GET /api/graph/artists/:id/beliefs — listener beliefs for an artist
*/
fastify.get('/graph/artists/:id/beliefs', async (request, reply) => {
const { id } = request.params as { id: string };
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const beliefs = await dbService.getListenerBeliefs({
userId,
entityType: 'artist',
entityId: id,
});
return reply.send({ artist_id: id, beliefs });
});
/**
* POST /api/graph/claim — upsert a claim into the graph
* Body: { subject_type, subject_id, predicate, object_type, object_id, source, confidence?, raw? }
*/
fastify.post('/graph/claim', async (request, reply) => {
const body = request.body as {
subject_type: string;
subject_id: string;
predicate: string;
object_type: string;
object_id: string;
source: string;
confidence?: number;
raw?: unknown;
};
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
if (!body.subject_type || !body.subject_id || !body.predicate || !body.object_type || !body.object_id || !body.source) {
return reply.code(400).send({ error: 'Missing required fields: subject_type, subject_id, predicate, object_type, object_id, source' });
}
const id = await dbService.upsertClaim({
user_id: userId === '00000000-0000-0000-0000-000000000000' ? null : userId,
subject_type: body.subject_type,
subject_id: body.subject_id,
predicate: body.predicate,
object_type: body.object_type,
object_id: body.object_id,
source: body.source,
confidence: body.confidence,
raw: body.raw,
});
return reply.code(201).send({ id });
});
/**
* POST /api/graph/evidence — record an evidence signal
* Body: { entity_type, entity_id, signal, profile, weight, context? }
*/
fastify.post('/graph/evidence', async (request, reply) => {
const body = request.body as {
entity_type: string;
entity_id: string;
signal: string;
profile: string;
weight: number;
context?: unknown;
};
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
if (!body.entity_type || !body.entity_id || !body.signal || body.weight === undefined) {
return reply.code(400).send({ error: 'Missing required fields: entity_type, entity_id, signal, weight' });
}
const id = await dbService.recordEvidence({
user_id: userId,
entity_type: body.entity_type,
entity_id: body.entity_id,
signal: body.signal,
profile: body.profile || 'longterm',
weight: body.weight,
context: body.context,
});
return reply.code(201).send({ id });
});
/**
* GET /api/graph/sources — list all claim sources and their trust weights
*/
fastify.get('/graph/sources', async (_request, reply) => {
const res = await (dbService as any).pgClient.query(
'SELECT * FROM source_trust ORDER BY trust DESC'
);
return reply.send({ sources: res.rows });
});
/**
* GET /api/graph/summary — aggregate graph stats (claim counts per source)
*/
fastify.get('/graph/summary', async (_request, reply) => {
const counts = await (dbService as any).pgClient.query(
`SELECT c.source, st.trust, COUNT(*)::int AS claim_count
FROM claims c
JOIN source_trust st ON st.key = c.source
GROUP BY c.source, st.trust
ORDER BY claim_count DESC`
);
const total = counts.rows.reduce((sum: number, r: any) => sum + r.claim_count, 0);
return reply.send({ total_claims: total, by_source: counts.rows });
});
}
+52
View File
@@ -0,0 +1,52 @@
import { FastifyInstance } from 'fastify';
import { DbService, FEEDBACK_ACTIONS, FeedbackAction } from '../services/db.service.js';
export default async function historyRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
const { dbService } = options;
// Record a playback event. completed defaults to false.
fastify.post('/history', async (request, reply) => {
const { trackId, completed, batchId } = request.body as {
trackId: string;
completed?: boolean;
batchId?: string;
};
if (!trackId) {
return reply.code(400).send({ error: 'trackId is required' });
}
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const historyId = await dbService.recordPlay(userId, trackId, completed === true, batchId);
return reply.send({ historyId });
});
// Record a skip (transient negative signal).
fastify.post('/history/skip', async (request, reply) => {
const { trackId } = request.body as { trackId: string };
if (!trackId) {
return reply.code(400).send({ error: 'trackId is required' });
}
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
await dbService.recordSkip(userId, trackId);
return reply.send({ status: 'ok' });
});
// Recent play history for the user.
fastify.get('/history', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
return await dbService.getHistory(userId);
});
// Explicit feedback.
fastify.post('/feedback', async (request, reply) => {
const { trackId, action } = request.body as { trackId: string; action: string };
if (!trackId) {
return reply.code(400).send({ error: 'trackId is required' });
}
if (!FEEDBACK_ACTIONS.includes(action as FeedbackAction)) {
return reply.code(400).send({ error: `Invalid action. Allowed: ${FEEDBACK_ACTIONS.join(', ')}` });
}
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
await dbService.recordFeedback(userId, trackId, action as FeedbackAction);
return reply.send({ status: 'ok' });
});
}
+49
View File
@@ -0,0 +1,49 @@
import { FastifyInstance } from 'fastify';
/**
* Image proxy — fetches external artwork URLs server-side and returns them
* with aggressive caching headers so the browser never re-fetches from
* Discogs / Cover Art Archive on repeat page loads.
*/
export default async function imagesRoutes(fastify: FastifyInstance) {
fastify.get('/images/proxy', async (request, reply) => {
const { url } = request.query as { url?: string };
if (!url) {
return reply.code(400).send({ error: 'url query parameter is required' });
}
// Only proxy http(s) URLs — don't be an open proxy for file:// etc.
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return reply.code(400).send({ error: 'Only http/https URLs are supported' });
}
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
return reply.code(response.status).send({ error: `Upstream returned ${response.status}` });
}
const buffer = await response.arrayBuffer();
const contentType = response.headers.get('content-type') || 'image/jpeg';
// Cache aggressively — artwork URLs are immutable (Discogs, Cover Art
// Archive etc. use content-addressed paths). 1 year.
return reply
.headers({
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
'Content-Length': buffer.byteLength,
})
.send(Buffer.from(buffer));
} catch (err: any) {
if (err?.name === 'TimeoutError' || err?.code === 'UND_ERR_CONNECT_TIMEOUT') {
return reply.code(504).send({ error: 'Upstream timed out' });
}
request.log.error({ err, url }, 'Image proxy failed');
return reply.code(502).send({ error: 'Failed to fetch image' });
}
});
}
+185
View File
@@ -0,0 +1,185 @@
import { FastifyInstance } from 'fastify';
import { DbService } from '../services/db.service.js';
export default async function libraryRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
const { dbService } = options;
fastify.get('/tracks', async (request, reply) => {
const query = request.query as any;
const tracks = await dbService.getTracks({
limit: query.limit ? parseInt(query.limit) : undefined,
offset: query.offset ? parseInt(query.offset) : undefined,
sort_by: query.sort_by,
order: query.order,
search: query.search,
});
return tracks;
});
fastify.get('/artists', async (request) => {
const query = request.query as any;
return await dbService.getArtists({
limit: query.limit ? parseInt(query.limit) : undefined,
offset: query.offset ? parseInt(query.offset) : undefined,
});
});
fastify.get('/artists/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const artist = await dbService.getArtistsById(id);
if (!artist) {
return reply.code(404).send({ error: 'Artist not found' });
}
return artist;
});
fastify.get('/artists/:id/similar', async (request, reply) => {
const { id } = request.params as { id: string };
const artist = await dbService.getArtistsById(id);
if (!artist) {
return reply.code(404).send({ error: 'Artist not found' });
}
return await dbService.getSimilarArtists(id);
});
fastify.get('/albums', async (request) => {
const query = request.query as any;
return await dbService.getAlbums({
limit: query.limit ? parseInt(query.limit) : undefined,
offset: query.offset ? parseInt(query.offset) : undefined,
});
});
fastify.get('/albums/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const album = await dbService.getAlbumById(id);
if (!album) {
return reply.code(404).send({ error: 'Album not found' });
}
return album;
});
// Genres
fastify.get('/genres', async () => {
return await dbService.getGenres();
});
fastify.get('/genres/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const genre = await dbService.getGenreById(id);
if (!genre) {
return reply.code(404).send({ error: 'Genre not found' });
}
return genre;
});
fastify.get('/genres/:id/tracks', async (request, reply) => {
const { id } = request.params as { id: string };
const query = request.query as any;
return await dbService.getTracksByGenre(
id,
query.limit ? parseInt(query.limit) : undefined,
query.offset ? parseInt(query.offset) : undefined
);
});
// Favorites
fastify.get('/favorites', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
return await dbService.getFavorites(userId);
});
fastify.post('/favorites/:trackId', async (request, reply) => {
const { trackId } = request.params as { trackId: string };
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
await dbService.addFavorite(userId, trackId);
return reply.send({ status: 'added' });
});
fastify.delete('/favorites/:trackId', async (request, reply) => {
const { trackId } = request.params as { trackId: string };
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
await dbService.removeFavorite(userId, trackId);
return reply.send({ status: 'removed' });
});
// Dislikes
fastify.post('/tracks/:trackId/dislike', async (request, reply) => {
const { trackId } = request.params as { trackId: string };
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
await dbService.dislikeTrack(userId, trackId);
return reply.send({ status: 'disliked' });
});
// Artists CRUD
fastify.post('/artists', async (request, reply) => {
const artist = await dbService.createArtist(request.body as any);
return reply.code(201).send(artist);
});
fastify.put('/artists/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const artist = await dbService.updateArtist(id, request.body as any);
return artist;
});
fastify.delete('/artists/:id', async (request, reply) => {
const { id } = request.params as { id: string };
await dbService.deleteArtist(id);
return reply.send({ status: 'deleted' });
});
// Albums CRUD
fastify.post('/albums', async (request, reply) => {
const album = await dbService.createAlbum(request.body as any);
return reply.code(201).send(album);
});
fastify.put('/albums/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const album = await dbService.updateAlbum(id, request.body as any);
return album;
});
fastify.delete('/albums/:id', async (request, reply) => {
const { id } = request.params as { id: string };
await dbService.deleteAlbum(id);
return reply.send({ status: 'deleted' });
});
// Tracks CRUD
fastify.get('/tracks/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const track = await dbService.getTrackById(id);
if (!track) {
return reply.code(404).send({ error: 'Track not found' });
}
return track;
});
fastify.get('/tracks/:id/lyrics', async (request, reply) => {
const { id } = request.params as { id: string };
const lyrics = await dbService.getTrackLyrics(id);
if (!lyrics) {
return reply.code(404).send({ error: 'No lyrics found' });
}
return lyrics;
});
fastify.post('/tracks', async (request, reply) => {
const track = await dbService.createTrack(request.body as any);
return reply.code(201).send(track);
});
fastify.put('/tracks/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const track = await dbService.updateTrack(id, request.body as any);
return track;
});
fastify.delete('/tracks/:id', async (request, reply) => {
const { id } = request.params as { id: string };
await dbService.deleteTrack(id);
return reply.send({ status: 'deleted' });
});
}
+34
View File
@@ -0,0 +1,34 @@
import { FastifyInstance } from 'fastify';
import { DbService } from '../services/db.service.js';
export default async function quarantineRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
const { dbService } = options;
// List all disliked tracks (HIDDEN + WARNED states)
fastify.get('/dislikes', async () => {
return await dbService.getDislikedTracks();
});
// Restore a disliked track back to LIBRARY
fastify.post('/dislikes/:trackId/restore', async (request, reply) => {
const { trackId } = request.params as { trackId: string };
const entry = await dbService.getDislikeByTrackId(trackId);
if (!entry) {
return reply.code(404).send({ error: 'Dislike record not found' });
}
await dbService.restoreDislike(trackId);
return reply.send({ status: 'restored' });
});
// Hard-delete a disliked track immediately (skips grace period)
fastify.delete('/dislikes/:trackId', async (request, reply) => {
const { trackId } = request.params as { trackId: string };
const entry = await dbService.getDislikeByTrackId(trackId);
if (!entry) {
return reply.code(404).send({ error: 'Dislike record not found' });
}
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
await dbService.permanentlyDeleteTrack(userId, trackId, entry.track_path);
return reply.send({ status: 'deleted' });
});
}
+21
View File
@@ -0,0 +1,21 @@
import { FastifyInstance } from 'fastify';
import { DbService } from '../services/db.service.js';
export default async function searchRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
const { dbService } = options;
fastify.get('/search', async (request, reply) => {
const query = (request.query as any).q;
if (!query) {
return reply.code(400).send({ error: 'Query parameter "q" is required' });
}
try {
// Typesense-first with a Postgres ILIKE fallback (Typesense isn't indexed yet).
return await dbService.searchTracks(String(query));
} catch (error) {
request.log.error(error);
return reply.code(500).send({ error: 'Search failed' });
}
});
}
+53
View File
@@ -0,0 +1,53 @@
import { FastifyInstance } from 'fastify';
import { DbService } from '../services/db.service.js';
const SETTING_KEYS = [
'enrich_metadata',
'enrich_cover_art',
'enrich_genres',
'enrich_lyrics',
'enrich_artist_similarity',
'enrich_audio_analysis',
] as const;
type SettingKey = typeof SETTING_KEYS[number];
export default async function settingsRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
const { dbService } = options;
// GET /api/settings — return all settings as { key: value } map.
fastify.get('/settings', async () => {
const rows = await dbService.pgClient.query('SELECT key, value FROM settings');
const map: Record<string, string> = {};
for (const row of rows.rows) {
map[row.key] = row.value;
}
return map;
});
// PUT /api/settings/:key — update one setting.
// Validates the key against known keys and the value as 'true'/'false'.
fastify.put<{ Params: { key: string }; Body: { value: string } }>(
'/settings/:key',
async (request, reply) => {
const { key } = request.params;
const { value } = request.body;
if (!SETTING_KEYS.includes(key as SettingKey)) {
return reply.code(400).send({ error: `Unknown setting: ${key}` });
}
if (value !== 'true' && value !== 'false') {
return reply.code(400).send({ error: 'Value must be "true" or "false"' });
}
await dbService.pgClient.query(
`INSERT INTO settings (key, value, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
[key, value]
);
return { status: 'ok', key, value };
}
);
}
+140
View File
@@ -0,0 +1,140 @@
import { FastifyInstance } from 'fastify';
import { createReadStream } from 'fs';
import { stat } from 'fs/promises';
import path from 'path';
import { DbService } from '../services/db.service.js';
// Music library root on disk. The worker scanner writes absolute file paths into
// tracks.path rooted here; the backend container must mount the same path so they
// resolve. Path-traversal guard below verifies the resolved file stays inside.
const MUSIC_DIR = path.resolve(process.env.MUSIC_DIR || '/mnt/hdd1/media/Music');
const CONTENT_TYPES: Record<string, string> = {
'.mp3': 'audio/mpeg',
'.flac': 'audio/flac',
'.m4a': 'audio/mp4',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
};
function contentTypeFor(filePath: string): string {
return CONTENT_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
}
// True when resolvedPath is the music root itself or a descendant of it.
function isInsideRoot(resolvedPath: string, root: string): boolean {
return resolvedPath === root || resolvedPath.startsWith(root + path.sep);
}
export default async function streamRoutes(
fastify: FastifyInstance,
options: { dbService: DbService }
) {
const { dbService } = options;
fastify.get('/tracks/:id/stream', async (request, reply) => {
const { id } = request.params as { id: string };
const track = await dbService.getTrackById(id);
if (!track) {
return reply.code(404).send({ error: 'Track not found' });
}
// SECURITY: resolve the path and confirm it stays within MUSIC_DIR. This
// rejects relative paths, symlink-style escapes and any path outside root.
const resolvedPath = path.resolve(track.path);
if (!isInsideRoot(resolvedPath, MUSIC_DIR)) {
return reply.code(403).send({ error: 'Forbidden' });
}
let fileSize: number;
try {
const stats = await stat(resolvedPath);
if (!stats.isFile()) {
return reply.code(404).send({ error: 'File not found' });
}
fileSize = stats.size;
} catch (err: any) {
if (err && err.code === 'ENOENT') {
// File missing on disk; the integrity worker would flag this track MISSING.
return reply.code(404).send({ error: 'File not found on disk' });
}
throw err;
}
const contentType = contentTypeFor(resolvedPath);
const rangeHeader = request.headers.range;
// No Range header: stream the whole file with a 200.
if (!rangeHeader) {
reply
.code(200)
.header('Content-Type', contentType)
.header('Content-Length', fileSize)
.header('Accept-Ranges', 'bytes');
const stream = createReadStream(resolvedPath);
stream.on('error', (err) => {
request.log.error(err);
reply.raw.destroy(err);
});
return reply.send(stream);
}
// Parse "bytes=start-end". Either bound may be omitted.
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
if (!match || (match[1] === '' && match[2] === '')) {
return reply
.code(416)
.header('Content-Range', `bytes */${fileSize}`)
.send({ error: 'Invalid range' });
}
let start: number;
let end: number;
if (match[1] === '') {
// suffix range: last N bytes
const suffixLength = parseInt(match[2], 10);
if (suffixLength <= 0) {
return reply
.code(416)
.header('Content-Range', `bytes */${fileSize}`)
.send({ error: 'Unsatisfiable range' });
}
start = Math.max(fileSize - suffixLength, 0);
end = fileSize - 1;
} else {
start = parseInt(match[1], 10);
end = match[2] === '' ? fileSize - 1 : parseInt(match[2], 10);
}
if (end > fileSize - 1) end = fileSize - 1;
if (
Number.isNaN(start) ||
Number.isNaN(end) ||
start > end ||
start < 0 ||
start >= fileSize
) {
return reply
.code(416)
.header('Content-Range', `bytes */${fileSize}`)
.send({ error: 'Unsatisfiable range' });
}
const chunkSize = end - start + 1;
reply
.code(206)
.header('Content-Type', contentType)
.header('Content-Range', `bytes ${start}-${end}/${fileSize}`)
.header('Accept-Ranges', 'bytes')
.header('Content-Length', chunkSize);
const stream = createReadStream(resolvedPath, { start, end });
stream.on('error', (err) => {
request.log.error(err);
reply.raw.destroy(err);
});
return reply.send(stream);
});
}
+143
View File
@@ -0,0 +1,143 @@
import { FastifyInstance } from 'fastify';
import { createClient, RedisClientType } from 'redis';
import { DbService } from '../services/db.service.js';
import { SessionDirector } from '../services/session-director.service.js';
import { Candidate } from '../services/generators.service.js';
interface ActivePlan {
sessionId: string;
plan: Candidate[];
seedTrackId: string | null;
}
const PLAN_TTL_SEC = 2 * 3600;
function planKey(userId: string): string {
return `v2:plan:${userId}`;
}
export default async function v2Routes(fastify: FastifyInstance, options: { dbService: DbService; sessionDirector: SessionDirector }) {
const { dbService, sessionDirector: director } = options;
const redisClient: RedisClientType = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
});
await redisClient.connect();
fastify.addHook('onClose', async () => { await redisClient.quit(); });
async function getActivePlan(userId: string): Promise<ActivePlan | null> {
const raw = await redisClient.get(planKey(userId));
if (!raw) return null;
return JSON.parse(raw) as ActivePlan;
}
async function setActivePlan(userId: string, plan: ActivePlan): Promise<void> {
await redisClient.setEx(planKey(userId), PLAN_TTL_SEC, JSON.stringify(plan));
}
async function delActivePlan(userId: string): Promise<void> {
await redisClient.del(planKey(userId));
}
/**
* POST /api/v2/vibe/start — start a v2 session
* Body: { seedTrackId? }
* Returns: { sessionId, plan: Candidate[] }
*/
fastify.post('/v2/vibe/start', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const { seedTrackId } = request.body as { seedTrackId?: string };
const sessionId = await dbService.createSessionState(userId, undefined, { energy: 0.5, novelty_hunger: 0.3 });
const plan = await director.buildPlan(userId, sessionId, seedTrackId);
await setActivePlan(userId, { sessionId, plan, seedTrackId: seedTrackId ?? null });
return reply.send({ sessionId, plan: plan.slice(0, 10) });
});
/**
* GET /api/v2/vibe/next — get next track from the plan
* Returns: { track, planRemaining }
*/
fastify.get('/v2/vibe/next', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const active = await getActivePlan(userId);
if (!active || active.plan.length === 0) {
return reply.code(404).send({ error: 'No active plan. POST /api/v2/vibe/start first.' });
}
const next = active.plan.shift()!;
// Enrich with track details
const track = await dbService.getTrackById(next.trackId);
// Replan if running low
if (active.plan.length < 5) {
const refill = await director.replan(userId, active.sessionId, active.plan, [next.trackId], active.seedTrackId ?? undefined);
active.plan.push(...refill);
}
await setActivePlan(userId, active);
return reply.send({ track, explanation: next.explanation, planRemaining: active.plan.length });
});
/**
* POST /api/v2/vibe/feedback — feedback that triggers replan
* Body: { trackId, action: 'completed' | 'skipped' | 'promoted' | 'disliked' }
*/
fastify.post('/v2/vibe/feedback', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const { trackId, action } = request.body as { trackId: string; action: string };
if (!trackId || !action) {
return reply.code(400).send({ error: 'trackId and action are required' });
}
// Route to existing handlers for evidence wiring
if (action === 'completed') {
await dbService.recordPlay(userId, trackId, true);
} else if (action === 'skipped') {
await dbService.recordSkip(userId, trackId);
} else if (action === 'promoted') {
await dbService.addFavorite(userId, trackId);
await dbService.recordFeedback(userId, trackId, 'promoted');
} else if (action === 'disliked') {
await dbService.dislikeTrack(userId, trackId);
}
// Replan the session
const active = await getActivePlan(userId);
if (active) {
const playedTrackIds = [trackId];
const refill = await director.replan(userId, active.sessionId, active.plan, playedTrackIds, active.seedTrackId ?? undefined);
active.plan.push(...refill);
await setActivePlan(userId, active);
}
return reply.send({ status: 'ok', planRemaining: active?.plan.length ?? 0 });
});
/**
* GET /api/v2/vibe/plan — current plan for debugging
*/
fastify.get('/v2/vibe/plan', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const active = await getActivePlan(userId);
if (!active) return reply.send({ plan: [] });
return reply.send({ sessionId: active.sessionId, planRemaining: active.plan.length, plan: active.plan });
});
/**
* GET /api/v2/state — current listener state (debugging)
*/
fastify.get('/v2/state', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const state = await director.buildState(userId);
const fatigue = await director.computeFatigue(userId);
const budgets = await director.getBudgets(userId);
return reply.send({ state, fatigue: Object.fromEntries(
Object.entries(fatigue).map(([k, v]) => [k, v instanceof Map ? Object.fromEntries(v) : v])
), budgets });
});
}
+76
View File
@@ -0,0 +1,76 @@
import { FastifyInstance } from 'fastify';
import { DbService } from '../services/db.service.js';
export default async function vibeRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
const { dbService } = options;
// Start a new vibe session
fastify.post('/start', async (request, reply) => {
const { seedTrackId } = request.body as { seedTrackId: string };
if (!seedTrackId) {
return reply.code(400).send({ error: 'seedTrackId is required' });
}
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const batchId = await dbService.createVibeSession(userId, seedTrackId);
return reply.send({ batchId });
});
// Get the next chunk of tracks for the active session
fastify.get('/next', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const activeSession = await dbService.getActiveVibeSession(userId);
if (!activeSession) {
return reply.code(404).send({ error: 'No active vibe session found' });
}
const tracks = await dbService.getNextVibeChunk(activeSession.batchId);
// Update the session timestamp to keep it alive
await dbService.updateVibeSession(activeSession.batchId);
return tracks;
});
// Start/return a chunk seeded by a genre (id or name) — no active session required.
fastify.get('/from-genre', async (request, reply) => {
const { genre } = request.query as { genre?: string };
if (!genre) {
return reply.code(400).send({ error: 'genre query param is required' });
}
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const tracks = await dbService.getVibeChunkFromGenre(genre, userId);
return tracks;
});
// Current ACTIVE batch metadata for the user, or 404.
fastify.get('/current', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const session = await dbService.getCurrentVibeSession(userId);
if (!session) {
return reply.code(404).send({ error: 'No active vibe session found' });
}
return reply.send(session);
});
// Heartbeat: keep the active batch alive by bumping last_interaction_at.
fastify.post('/heartbeat', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const updated = await dbService.heartbeatVibeSession(userId);
if (!updated) {
return reply.code(404).send({ error: 'No active vibe session found' });
}
return reply.send({ status: 'ok' });
});
// End the vibe session
fastify.post('/end', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const activeSession = await dbService.getActiveVibeSession(userId);
if (activeSession) {
await dbService.endVibeSession(activeSession.batchId);
}
return reply.send({ status: 'session_ended' });
});
}
+21
View File
@@ -0,0 +1,21 @@
import { buildApp } from './app.js';
const port = parseInt(process.env.PORT || '3000', 10);
async function start() {
try {
const { fastify } = await buildApp({
port,
searchHost: process.env.TYPESENSE_HOST || 'search',
searchPort: parseInt(process.env.TYPESENSE_PORT || '8108', 10),
searchApiKey: process.env.TYPESENSE_API_KEY || 'muzick-key'
});
await fastify.listen({ port, host: '0.0.0.0' });
console.log(`Server listening at http://localhost:${port}`);
} catch (err) {
console.error(err);
process.exit(1);
}
}
start();
+182
View File
@@ -0,0 +1,182 @@
import { describe, it, expect, vi } from 'vitest';
import { DbService } from './db.service.js';
function makeService(): { service: DbService; mockQuery: ReturnType<typeof vi.fn> } {
const mockQuery = vi.fn();
const service = new DbService({ query: mockQuery } as any);
return { service, mockQuery };
}
describe('DbService v2 methods', () => {
describe('upsertClaim', () => {
it('calls INSERT ... ON CONFLICT with correct parameters', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [{ id: 'claim-1' }] });
const id = await service.upsertClaim({
subject_type: 'track',
subject_id: 'track-1',
predicate: 'credited_main_on',
object_type: 'artist',
object_id: 'artist-1',
source: 'mb',
confidence: 1.0,
});
expect(id).toBe('claim-1');
expect(mockQuery).toHaveBeenCalledTimes(1);
const [sql, params] = mockQuery.mock.calls[0];
expect(sql).toContain('INSERT INTO claims');
expect(sql).toContain('ON CONFLICT');
expect(params).toContain('track');
expect(params).toContain('track-1');
expect(params).toContain('credited_main_on');
});
it('handles user_id null for objective claims', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [{ id: 'c1' }] });
await service.upsertClaim({
subject_type: 'artist', subject_id: 'a1', predicate: 'alias_of',
object_type: 'artist', object_id: 'a2', source: 'listener_behavior',
user_id: 'user-1',
});
const params = mockQuery.mock.calls[0][1];
expect(params[0]).toBe('user-1');
});
});
describe('getClaimsBySubject', () => {
it('filters by subject type and id', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [] });
await service.getClaimsBySubject('track', 'track-1');
const [sql, params] = mockQuery.mock.calls[0];
expect(sql).toContain('subject_type = $1');
expect(sql).toContain('subject_id = $2');
expect(params).toEqual(['track', 'track-1']);
});
it('optionally filters by predicate and user_id', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [] });
await service.getClaimsBySubject('artist', 'a1', 'alias_of', 'user-1');
const [sql] = mockQuery.mock.calls[0];
expect(sql).toContain('predicate');
expect(sql).toContain('user_id IS NULL');
});
});
describe('recordEvidence', () => {
it('appends evidence row', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [{ id: 'ev-1' }] });
const id = await service.recordEvidence({
user_id: 'user-1', entity_type: 'track', entity_id: 'track-1',
signal: 'playback_completed', profile: 'longterm', weight: 0.10,
});
expect(id).toBe('ev-1');
const [sql] = mockQuery.mock.calls[0];
expect(sql).toContain('INSERT INTO evidence');
});
});
describe('updateListenerBelief', () => {
it('UPSERTs with delta formula', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rowCount: 1 });
await service.updateListenerBelief({
user_id: 'user-1', profile: 'longterm',
entity_type: 'track', entity_id: 'track-1',
dimension: 'affinity', value_delta: 0.10,
});
const [sql] = mockQuery.mock.calls[0];
expect(sql).toContain('INSERT INTO listener_beliefs');
expect(sql).toContain('ON CONFLICT');
expect(sql).toContain('GREATEST(-1.0, LEAST(1.0');
});
});
describe('recordEvidence → belief derivation wiring', () => {
it('derives a fresh longterm affinity belief after playback_completed', async () => {
const { service, mockQuery } = makeService();
// first call: INSERT evidence → returns id; second call: UPSERT belief → rowCount 1
mockQuery
.mockResolvedValueOnce({ rows: [{ id: 'ev-1' }] })
.mockResolvedValueOnce({ rowCount: 1 });
const id = await service.recordEvidence({
user_id: 'user-1', entity_type: 'track', entity_id: 'track-1',
signal: 'playback_completed', profile: 'longterm', weight: 0.10,
});
expect(id).toBe('ev-1');
expect(mockQuery).toHaveBeenCalledTimes(2);
// First call INSERTs the evidence row.
const [evidSql, evidParams] = mockQuery.mock.calls[0];
expect(evidSql).toContain('INSERT INTO evidence');
expect(evidParams[4]).toBe('longterm'); // profile
expect(evidParams[5]).toBe(0.10); // weight
// Second call UPSERTs the matching listener_belief. For a fresh
// belief the INSERT path sets value = weight directly (per spec §B.4
// INSERT branch), so the resulting row has value=0.10, confidence=0.05.
const [beliefSql, beliefParams] = mockQuery.mock.calls[1];
expect(beliefSql).toContain('INSERT INTO listener_beliefs');
expect(beliefSql).toContain('ON CONFLICT');
// [user_id, profile, entity_type, entity_id, dimension, value_delta, confidence_delta]
expect(beliefParams[0]).toBe('user-1');
expect(beliefParams[1]).toBe('longterm');
expect(beliefParams[2]).toBe('track');
expect(beliefParams[3]).toBe('track-1');
expect(beliefParams[4]).toBe('affinity');
expect(beliefParams[5]).toBe(0.10); // value_delta = weight
expect(beliefParams[6]).toBe(0.05); // confidence_delta default
});
it('maps play_of_never_seen to the novelty_tolerance dimension', async () => {
const { service, mockQuery } = makeService();
mockQuery
.mockResolvedValueOnce({ rows: [{ id: 'ev-2' }] })
.mockResolvedValueOnce({ rowCount: 1 });
await service.recordEvidence({
user_id: 'user-1', entity_type: 'track', entity_id: 'track-2',
signal: 'play_of_never_seen', profile: 'discovery', weight: 0.05,
});
const beliefParams = mockQuery.mock.calls[1][1] as unknown[];
expect(beliefParams[4]).toBe('novelty_tolerance');
expect(beliefParams[1]).toBe('discovery');
expect(beliefParams[5]).toBe(0.05);
});
it('still appends an evidence row before deriving the belief', async () => {
const { service, mockQuery } = makeService();
mockQuery
.mockResolvedValueOnce({ rows: [{ id: 'ev-3' }] })
.mockResolvedValueOnce({ rowCount: 1 });
const id = await service.recordEvidence({
user_id: 'user-1', entity_type: 'track', entity_id: 'track-3',
signal: 'skip_quick', profile: 'negative', weight: -0.20,
});
expect(id).toBe('ev-3');
expect(mockQuery.mock.calls[0][0]).toContain('INSERT INTO evidence');
expect(mockQuery.mock.calls[1][0]).toContain('listener_beliefs');
expect(mockQuery.mock.calls[1][1][4]).toBe('affinity');
});
});
describe('getFusedTrackArtists', () => {
it('reads from claim_fusion view', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [{ id: 'a1', name: 'Artist 1', role: 'main', confidence: 0.9 }] });
const result = await service.getFusedTrackArtists('track-1');
const [sql] = mockQuery.mock.calls[0];
expect(sql).toContain('claim_fusion');
expect(result).toHaveLength(1);
expect(result[0].role).toBe('main');
});
});
});
File diff suppressed because it is too large Load Diff
+297
View File
@@ -0,0 +1,297 @@
import { DbService } from './db.service.js';
export interface DiscoveryCandidate {
id: string;
source: string;
externalId: string;
title: string | null;
artistCredit: unknown;
notes: unknown;
status: string;
relevance: number;
explanation: string;
}
export class DiscoveryService {
constructor(private db: DbService) {}
// ---------------------------------------------------------------
// E.1 — Graph exploration: walk the graph beyond the library
// ---------------------------------------------------------------
async walkGraphForDiscovery(userId: string): Promise<number> {
const beliefs = await this.db.getListenerBeliefs({
userId,
profile: 'longterm',
entityType: 'artist',
dimension: 'affinity',
limit: 100,
orderBy: 'value',
order: 'DESC',
});
const highAffinity = beliefs.filter((b) => b.value > 0.3);
let newCount = 0;
for (const belief of highAffinity) {
const candidates = await this.db.pgClient.query<{ candidate_artist_id: string }>(
`SELECT cf.object_id AS candidate_artist_id
FROM claim_fusion cf
WHERE cf.subject_id = $1::uuid
AND cf.predicate IN ('same_scene_as', 'featured_on')
AND cf.object_type = 'artist'
AND NOT EXISTS (
SELECT 1 FROM tracks t
JOIN claim_fusion cf2 ON cf2.subject_id = t.id
WHERE cf2.object_id = cf.object_id
AND cf2.predicate = 'credited_main_on'
)
LIMIT 20`,
[belief.entity_id]
);
for (const row of candidates.rows) {
const dcRes = await this.db.pgClient.query<{ id: string }>(
`INSERT INTO discovery_candidates (source, external_id, artist_credit, notes)
VALUES ($1, $2, $3, $4)
ON CONFLICT (source, external_id) DO NOTHING
RETURNING id`,
[
'graph_exploration',
row.candidate_artist_id,
JSON.stringify([{ artist_id: row.candidate_artist_id }]),
JSON.stringify({
discovery_source: 'graph_exploration',
path: [
{
entity_id: belief.entity_id,
predicate: 'affinity_source',
profile: 'longterm',
affinity: belief.value,
},
{
entity_id: row.candidate_artist_id,
predicate: 'same_scene_as',
},
],
source_artist_belief_id: belief.entity_id,
}),
]
);
if (dcRes.rows.length === 0) continue;
const dcId = dcRes.rows[0].id;
const relevance = Math.min(belief.value, 0.8);
await this.db.upsertClaim({
subject_type: 'track',
subject_id: dcId,
predicate: 'discovery_candidate',
object_type: 'artist',
object_id: row.candidate_artist_id,
source: 'graph_exploration',
confidence: relevance,
raw: {
discovery_source: 'graph_exploration',
path: [
{ entity_id: belief.entity_id, relationship: 'affinity_source', belief_value: belief.value },
{ entity_id: row.candidate_artist_id, relationship: 'same_scene_as' },
],
},
});
newCount++;
}
}
return newCount;
}
// ---------------------------------------------------------------
// E.3 — Evaluate discovery candidates for acquisition
// ---------------------------------------------------------------
async evalCandidates(
userId: string,
limit?: number
): Promise<{ candidateId: string; shouldAcquire: boolean; reason: string }[]> {
const cap = limit ?? 20;
const results: { candidateId: string; shouldAcquire: boolean; reason: string }[] = [];
const candidates = await this.db.pgClient.query(
`SELECT * FROM discovery_candidates
WHERE status = 'candidate'
ORDER BY first_seen_at ASC
LIMIT $1`,
[cap]
);
const backlogRes = await this.db.pgClient.query(
`SELECT COUNT(*)::int AS cnt FROM discovery_candidates WHERE status = 'acquiring'`
);
let backlog = backlogRes.rows[0]?.cnt as number ?? 0;
for (const row of candidates.rows) {
const claimRes = await this.db.pgClient.query<{ object_id: string; fused_value: number }>(
`SELECT object_id, fused_value
FROM claim_fusion
WHERE subject_type = 'track' AND subject_id = $1::uuid
AND predicate = 'discovery_candidate'
LIMIT 1`,
[row.id]
);
const relevance = claimRes.rows[0]?.fused_value ?? 0;
const candidateArtistId = claimRes.rows[0]?.object_id;
const noveltyBeliefs = await this.db.getListenerBeliefs({
userId,
profile: 'discovery',
entityType: 'artist',
entityId: candidateArtistId,
dimension: 'tolerance',
limit: 1,
});
const tolerance = noveltyBeliefs.length > 0 ? noveltyBeliefs[0].value : 0.5;
let artistCount = 0;
if (candidateArtistId) {
const acRes = await this.db.pgClient.query(
`SELECT COUNT(*)::int AS cnt
FROM discovery_candidates dc
JOIN claims c ON c.subject_id = dc.id
WHERE dc.status = 'acquiring'
AND c.predicate = 'discovery_candidate'
AND c.object_id = $1::uuid`,
[candidateArtistId]
);
artistCount = acRes.rows[0]?.cnt as number ?? 0;
}
const shouldAcquire = relevance > 0.3 && tolerance > 0.2 && backlog < 20 && artistCount < 3;
let reason: string;
if (shouldAcquire) {
await this.db.pgClient.query(
`UPDATE discovery_candidates SET status = 'acquiring', last_eval_at = NOW() WHERE id = $1`,
[row.id]
);
backlog++;
reason = 'meets criteria';
} else {
if (relevance <= 0.3) reason = 'relevance too low';
else if (tolerance <= 0.2) reason = 'novelty tolerance exceeded';
else if (backlog >= 20) reason = 'backlog full';
else if (artistCount >= 3) reason = 'artist diversity limit';
else reason = 'unknown';
await this.db.pgClient.query(
`UPDATE discovery_candidates SET status = 'retired', last_eval_at = NOW() WHERE id = $1`,
[row.id]
);
}
results.push({
candidateId: row.id,
shouldAcquire,
reason,
});
}
return results;
}
// ---------------------------------------------------------------
// E.4 — Probation lifecycle
// ---------------------------------------------------------------
async evalProbation(trackId: string): Promise<'retained' | 'retired' | 'probation'> {
const completedRes = await this.db.pgClient.query(
`SELECT COUNT(*)::int AS cnt FROM evidence
WHERE entity_type = 'track' AND entity_id = $1 AND signal = 'playback_completed'`,
[trackId]
);
const completedPlays = completedRes.rows[0]?.cnt as number ?? 0;
const skipRes = await this.db.pgClient.query(
`SELECT COUNT(*)::int AS cnt FROM evidence
WHERE entity_type = 'track' AND entity_id = $1 AND signal = 'skip_quick'`,
[trackId]
);
const skips = skipRes.rows[0]?.cnt as number ?? 0;
const trackRes = await this.db.pgClient.query<{ probation_entered_at: Date | null }>(
`SELECT probation_entered_at FROM tracks WHERE id = $1`,
[trackId]
);
const probTrack = trackRes.rows[0];
if (completedPlays >= 3) {
await this.db.pgClient.query(
`UPDATE tracks SET probation_status = 'retained' WHERE id = $1`,
[trackId]
);
const claimRes = await this.db.pgClient.query<{ source: string }>(
`SELECT source FROM claims
WHERE subject_type = 'track' AND subject_id = $1 AND predicate = 'discovery_candidate'
LIMIT 1`,
[trackId]
);
if (claimRes.rows[0]) {
await this.db.pgClient.query(
`UPDATE source_trust SET trust = LEAST(1.0, trust + 0.05) WHERE key = $1`,
[claimRes.rows[0].source]
);
}
return 'retained';
}
const daysSinceProbation = probTrack?.probation_entered_at
? (Date.now() - new Date(probTrack.probation_entered_at).getTime()) / (1000 * 86400)
: 0;
if (completedPlays === 0 && skips >= 3 && daysSinceProbation > 7) {
await this.db.pgClient.query(
`UPDATE tracks SET probation_status = 'retired' WHERE id = $1`,
[trackId]
);
return 'retired';
}
return 'probation';
}
async sweepProbation(): Promise<{ retained: number; retired: number }> {
const res = await this.db.pgClient.query(
`SELECT id FROM tracks WHERE probation_status = 'probation'`
);
let retained = 0;
let retired = 0;
for (const row of res.rows) {
const result = await this.evalProbation(row.id as string);
if (result === 'retained') retained++;
else if (result === 'retired') retired++;
}
return { retained, retired };
}
// ---------------------------------------------------------------
// E.5 — Meta-learning stub
// ---------------------------------------------------------------
async runMetaLearning(): Promise<void> {
const res = await this.db.pgClient.query(
`SELECT c.source, COUNT(*)::int AS cnt
FROM claims c
JOIN tracks t ON t.id = c.subject_id
WHERE c.predicate = 'discovery_candidate'
AND t.probation_status = 'retained'
GROUP BY c.source
ORDER BY cnt DESC`
);
console.log('[MetaLearning] Discovery source retention counts:', JSON.stringify(res.rows));
}
}
+500
View File
@@ -0,0 +1,500 @@
import { DbService, ListenerBelief } from './db.service.js';
// ---------------------------------------------------------------------------
// System C — Candidate Generators
// Each generator returns candidates with graph-path explanations.
// No scoring — the session director handles ranking.
// ---------------------------------------------------------------------------
export interface ClaimEdge {
subjectType: string;
subjectId: string;
predicate: string;
objectType: string;
objectId: string;
fusedValue: number;
}
export interface Candidate {
trackId: string;
generatorId: string;
explanation: ClaimEdge[];
relevance: number;
}
export interface GeneratorContext {
userId: string;
seedTrackId: string | null;
seedArtistId: string | null;
beliefs: ListenerBelief[];
recentExclusions: string[];
toleranceMap: Record<string, number>;
state: {
energy: number;
lastArtistIds: string[];
lastGenreIds: string[];
context: string | null;
noveltyHunger: number;
sessionAgeMin: number;
};
}
export type Generator = (db: DbService, ctx: GeneratorContext) => Promise<Candidate[]>;
const OBJECTIVE_USER = '00000000-0000-0000-0000-000000000000';
// ---------------------------------------------------------------------------
// 1. COMFORT — Top artists by longterm affinity > 0.5
// ---------------------------------------------------------------------------
async function comfortGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
const topArtists = ctx.beliefs
.filter(b => b.profile === 'longterm' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.5)
.sort((a, b) => b.value - a.value)
.slice(0, 20);
const candidates: Candidate[] = [];
for (const belief of topArtists) {
const res = await db.pgClient.query(
`SELECT t.id
FROM tracks t
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
AND cf.predicate IN ('credited_main_on', 'featured_on')
AND cf.object_type = 'artist' AND cf.object_id = $1
AND (cf.user_id = $2 OR cf.user_id = $3)
WHERE t.state = 'LIBRARY'
AND NOT (t.id = ANY($4::uuid[]))
ORDER BY cf.fused_value DESC
LIMIT 2`,
[belief.entity_id, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions]
);
for (const row of res.rows as { id: string }[]) {
candidates.push({
trackId: row.id,
generatorId: 'comfort',
explanation: [{
subjectType: 'artist',
subjectId: belief.entity_id,
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: belief.value,
}],
relevance: belief.value,
});
}
}
return candidates;
}
// ---------------------------------------------------------------------------
// 2. ADJACENT — Walk graph from seed artist, exclude comfort pool
// ---------------------------------------------------------------------------
async function adjacentGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
if (!ctx.seedArtistId) return [];
const comfortArtistIds = new Set(
ctx.beliefs
.filter(b => b.profile === 'longterm' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.5)
.map(b => b.entity_id)
);
// Walk: seedArtist -> (credited_main_on|featured_on) -> track -> (credited_main_on|featured_on) -> reachedArtist
// cf1 finds tracks where seed artist appears; cf2 finds OTHER artists on those same tracks
const reachedRes = await db.pgClient.query(
`SELECT DISTINCT cf2.object_id AS artist_id
FROM claim_fusion cf1
JOIN claim_fusion cf2 ON cf2.subject_type = 'track'
AND cf2.subject_id = cf1.subject_id
AND cf2.predicate IN ('credited_main_on', 'featured_on')
AND cf2.object_type = 'artist'
AND cf2.object_id != $1
AND (cf2.user_id = $2 OR cf2.user_id = $3)
WHERE cf1.subject_type = 'track'
AND cf1.predicate IN ('credited_main_on', 'featured_on')
AND cf1.object_type = 'artist'
AND cf1.object_id = $1
AND (cf1.user_id = $2 OR cf1.user_id = $3)
LIMIT 30`,
[ctx.seedArtistId, OBJECTIVE_USER, ctx.userId]
);
const reachedArtistIds = (reachedRes.rows as { artist_id: string }[])
.map(r => r.artist_id)
.filter(id => !comfortArtistIds.has(id));
if (reachedArtistIds.length === 0) return [];
const trackRes = await db.pgClient.query(
`SELECT id FROM (
SELECT DISTINCT t.id
FROM tracks t
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
AND cf.predicate IN ('credited_main_on', 'featured_on')
AND cf.object_type = 'artist'
AND cf.object_id = ANY($1::uuid[])
AND (cf.user_id = $2 OR cf.user_id = $3)
WHERE t.state = 'LIBRARY'
AND NOT (t.id = ANY($4::uuid[]))
) sub
ORDER BY RANDOM()
LIMIT 20`,
[reachedArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions]
);
return (trackRes.rows as { id: string }[]).map(row => ({
trackId: row.id,
generatorId: 'adjacent',
explanation: [{
subjectType: 'artist',
subjectId: ctx.seedArtistId!,
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: 0.6,
}],
relevance: 0.6,
}));
}
// ---------------------------------------------------------------------------
// 3. DISCOVERY — Unfamiliar artists via graph edges from trusted artists
// ---------------------------------------------------------------------------
async function discoveryGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
const trustedIds = ctx.beliefs
.filter(b => b.profile === 'longterm' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.3)
.map(b => b.entity_id);
if (trustedIds.length === 0) return [];
const noveltyTolerance = ctx.toleranceMap.novelty_tolerance ?? 0.3;
const maxCandidates = Math.max(1, Math.floor(10 * noveltyTolerance));
const unfamiliarRes = await db.pgClient.query(
`SELECT DISTINCT cf.object_id AS artist_id
FROM claim_fusion cf
WHERE cf.subject_type = 'artist'
AND cf.subject_id = ANY($1::uuid[])
AND cf.predicate IN ('same_scene_as', 'same_label_as', 'produced')
AND cf.object_type = 'artist'
AND NOT EXISTS (
SELECT 1 FROM listener_beliefs lb
WHERE lb.user_id = $2
AND lb.entity_type = 'artist'
AND lb.entity_id = cf.object_id
AND lb.profile IN ('longterm', 'obsession')
)
LIMIT 30`,
[trustedIds, ctx.userId]
);
const unfamiliarArtistIds = (unfamiliarRes.rows as { artist_id: string }[]).map(r => r.artist_id);
if (unfamiliarArtistIds.length === 0) return [];
const trackRes = await db.pgClient.query(
`SELECT id FROM (
SELECT DISTINCT t.id
FROM tracks t
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
AND cf.predicate IN ('credited_main_on', 'featured_on')
AND cf.object_type = 'artist'
AND cf.object_id = ANY($1::uuid[])
WHERE t.state = 'LIBRARY'
AND NOT (t.id = ANY($2::uuid[]))
) sub
ORDER BY RANDOM()
LIMIT $3`,
[unfamiliarArtistIds, ctx.recentExclusions, maxCandidates]
);
return (trackRes.rows as { id: string }[]).map(row => ({
trackId: row.id,
generatorId: 'discovery',
explanation: [{
subjectType: 'artist',
subjectId: unfamiliarArtistIds[0],
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: 0.4,
}],
relevance: 0.4,
}));
}
// ---------------------------------------------------------------------------
// 4. DEEP-DIVE — Albums from obsession artists, unplayed tracks first
// ---------------------------------------------------------------------------
async function deepDiveGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
const obsessedIds = ctx.beliefs
.filter(b => b.profile === 'obsession' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.3)
.map(b => b.entity_id);
if (obsessedIds.length === 0) return [];
const albumRes = await db.pgClient.query(
`SELECT al.id AS album_id, al.artist_id
FROM albums al
WHERE al.artist_id = ANY($1::uuid[])
ORDER BY al.year ASC NULLS LAST, al.title ASC
LIMIT 20`,
[obsessedIds]
);
const candidates: Candidate[] = [];
for (const album of albumRes.rows as { album_id: string; artist_id: string }[]) {
const trackRes = await db.pgClient.query(
`SELECT t.id
FROM tracks t
WHERE t.album_id = $1 AND t.state = 'LIBRARY'
AND NOT (t.id = ANY($2::uuid[]))
ORDER BY t.title ASC
LIMIT 5`,
[album.album_id, ctx.recentExclusions]
);
for (const row of trackRes.rows as { id: string }[]) {
candidates.push({
trackId: row.id,
generatorId: 'deep-dive',
explanation: [{
subjectType: 'artist',
subjectId: album.artist_id,
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: 0.7,
}],
relevance: 0.7,
});
}
}
return candidates;
}
// ---------------------------------------------------------------------------
// 5. REVIVAL — Stale longterm affinity (last_reinforced > 90 days ago)
// ---------------------------------------------------------------------------
async function revivalGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
const staleRes = await db.pgClient.query(
`SELECT lb.entity_id AS artist_id, lb.value AS affinity
FROM listener_beliefs lb
WHERE lb.user_id = $1
AND lb.profile = 'longterm'
AND lb.entity_type = 'artist'
AND lb.dimension = 'affinity'
AND lb.value > 0.3
AND lb.last_reinforced_at < NOW() - INTERVAL '90 days'
ORDER BY lb.value DESC
LIMIT 20`,
[ctx.userId]
);
const staleArtists = staleRes.rows as { artist_id: string; affinity: number }[];
if (staleArtists.length === 0) return [];
const staleArtistIds = staleArtists.map(a => a.artist_id);
const trackRes = await db.pgClient.query(
`SELECT id FROM (
SELECT DISTINCT t.id
FROM tracks t
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
AND cf.predicate IN ('credited_main_on', 'featured_on')
AND cf.object_type = 'artist'
AND cf.object_id = ANY($1::uuid[])
AND (cf.user_id = $2 OR cf.user_id = $3)
WHERE t.state = 'LIBRARY'
AND NOT (t.id = ANY($4::uuid[]))
) sub
ORDER BY RANDOM()
LIMIT 20`,
[staleArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions]
);
return (trackRes.rows as { id: string }[]).map(row => ({
trackId: row.id,
generatorId: 'revival',
explanation: [{
subjectType: 'artist',
subjectId: staleArtistIds[0],
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: 0.6,
}],
relevance: 0.6,
}));
}
// ---------------------------------------------------------------------------
// 6. NOVELTY — Recently released tracks by graph-adjacent artists
// ---------------------------------------------------------------------------
async function noveltyGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
const trustedIds = ctx.beliefs
.filter(b => b.entity_type === 'artist' && b.value > 0.3)
.map(b => b.entity_id);
if (trustedIds.length === 0) return [];
const res = await db.pgClient.query(
`SELECT id FROM (
SELECT DISTINCT t.id, t.release_date
FROM tracks t
JOIN claim_fusion cf_edge ON cf_edge.subject_type = 'artist'
AND cf_edge.subject_id = ANY($2::uuid[])
AND cf_edge.predicate IN ('same_scene_as', 'same_label_as', 'produced')
AND cf_edge.object_type = 'artist'
JOIN claim_fusion cf_track ON cf_track.subject_type = 'track'
AND cf_track.subject_id = t.id
AND cf_track.predicate IN ('credited_main_on', 'featured_on')
AND cf_track.object_type = 'artist'
AND cf_track.object_id = cf_edge.object_id
WHERE t.release_date IS NOT NULL
AND t.release_date >= NOW() - INTERVAL '60 days'
AND t.state = 'LIBRARY'
AND NOT (t.id = ANY($1::uuid[]))
) sub
ORDER BY release_date DESC
LIMIT 20`,
[ctx.recentExclusions.length > 0 ? ctx.recentExclusions : ['00000000-0000-0000-0000-000000000000'], trustedIds]
);
return res.rows.map((row: { id: string }) => ({
trackId: row.id,
generatorId: 'novelty',
relevance: 0.5,
explanation: [{
subjectType: 'track', subjectId: row.id,
predicate: 'release_date',
objectType: 'date', objectId: 'recent',
fusedValue: 0.5,
}],
}));
}
// ---------------------------------------------------------------------------
// 7. EXPERIMENTAL — Genres with high network distance from favourites
// ---------------------------------------------------------------------------
async function experimentalGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
const favArtistIds = ctx.beliefs
.filter(b => b.entity_type === 'artist' && b.value > 0.4)
.map(b => b.entity_id);
if (favArtistIds.length === 0) return [];
const favGenreIds = ctx.beliefs
.filter(b => b.entity_type === 'genre' && b.value > 0.2)
.map(b => b.entity_id);
const result = await db.pgClient.query(
`WITH unfamiliar_genres AS (
SELECT g.id, g.name,
(SELECT COUNT(*) FROM track_genre tg2 WHERE tg2.genre_id = g.id) AS track_count
FROM genre g
WHERE NOT (g.id = ANY($1::uuid[]))
AND EXISTS (SELECT 1 FROM track_genre tg WHERE tg.genre_id = g.id)
ORDER BY RANDOM()
LIMIT 3
),
candidate_tracks AS (
SELECT DISTINCT t.id, tg.genre_id
FROM tracks t
JOIN track_genre tg ON tg.track_id = t.id
JOIN unfamiliar_genres ug ON ug.id = tg.genre_id
WHERE t.state = 'LIBRARY'
AND NOT (t.id = ANY($2::uuid[]))
LIMIT 30
)
SELECT ct.id, ct.genre_id
FROM candidate_tracks ct
ORDER BY RANDOM()
LIMIT 6`,
[
favGenreIds.length > 0 ? favGenreIds : ['00000000-0000-0000-0000-000000000000'],
ctx.recentExclusions.length > 0 ? ctx.recentExclusions : ['00000000-0000-0000-0000-000000000000'],
]
);
return result.rows.map((row: { id: string; genre_id: string }) => ({
trackId: row.id,
generatorId: 'experimental',
relevance: 0.2,
explanation: [{
subjectType: 'genre', subjectId: row.genre_id,
predicate: 'belongs_to_genre',
objectType: 'track', objectId: row.id,
fusedValue: 0.2,
}],
}));
}
// ---------------------------------------------------------------------------
// 8. CONTEXTUAL — Contextual profile beliefs
// ---------------------------------------------------------------------------
async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
if (!ctx.state.context) return [];
const contextualBeliefs = await db.getListenerBeliefs({
userId: ctx.userId,
profile: 'contextual',
limit: 30,
orderBy: 'value',
order: 'DESC',
});
const targetArtistIds = contextualBeliefs
.filter(b => b.entity_type === 'artist' && b.value > 0.2)
.map(b => b.entity_id);
if (targetArtistIds.length === 0) return [];
const trackRes = await db.pgClient.query(
`SELECT id FROM (
SELECT DISTINCT t.id
FROM tracks t
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
AND cf.predicate IN ('credited_main_on', 'featured_on')
AND cf.object_type = 'artist'
AND cf.object_id = ANY($1::uuid[])
AND (cf.user_id = $2 OR cf.user_id = $3)
WHERE t.state = 'LIBRARY'
AND NOT (t.id = ANY($4::uuid[]))
) sub
ORDER BY RANDOM()
LIMIT 15`,
[targetArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions]
);
return (trackRes.rows as { id: string }[]).map(row => ({
trackId: row.id,
generatorId: 'contextual',
explanation: [{
subjectType: 'artist',
subjectId: targetArtistIds[0],
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: 0.5,
}],
relevance: 0.5,
}));
}
// ---------------------------------------------------------------------------
// All generators, ordered by priority (comfort first, experimental last)
// ---------------------------------------------------------------------------
export const ALL_GENERATORS: Generator[] = [
comfortGenerator,
adjacentGenerator,
deepDiveGenerator,
revivalGenerator,
discoveryGenerator,
noveltyGenerator,
contextualGenerator,
experimentalGenerator,
];
+179
View File
@@ -0,0 +1,179 @@
import { describe, it, expect, vi } from 'vitest';
import { ALL_GENERATORS, type GeneratorContext, type Candidate } from './generators.service.js';
import { DbService } from './db.service.js';
function makeMockDb(overrides: Record<string, any> = {}): DbService {
const mockQuery = vi.fn();
return {
pgClient: { query: mockQuery },
getListenerBeliefs: vi.fn().mockResolvedValue([]),
...overrides,
} as unknown as DbService;
}
function makeCtx(overrides: Partial<GeneratorContext> = {}): GeneratorContext {
return {
userId: '00000000-0000-0000-0000-000000000000',
seedTrackId: null,
seedArtistId: null,
beliefs: [],
recentExclusions: [],
toleranceMap: {},
state: { energy: 0.5, lastArtistIds: [], lastGenreIds: [], context: null, noveltyHunger: 0.3, sessionAgeMin: 10 },
...overrides,
};
}
// Index generators by name for easy test access
const generatorByName: Record<string, (typeof ALL_GENERATORS)[0]> = {
comfort: ALL_GENERATORS[0],
adjacent: ALL_GENERATORS[1],
deepDive: ALL_GENERATORS[2],
revival: ALL_GENERATORS[3],
discovery: ALL_GENERATORS[4],
novelty: ALL_GENERATORS[5],
contextual: ALL_GENERATORS[6],
experimental: ALL_GENERATORS[7],
};
describe('generators', () => {
describe('comfort', () => {
it('returns tracks for artists with affinity > 0.5', async () => {
const db = makeMockDb();
(db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 'track-1' }, { id: 'track-2' }] });
const ctx = makeCtx({
beliefs: [
{ entity_type: 'artist', entity_id: 'artist-1', value: 0.8, confidence: 0.9, profile: 'longterm', dimension: 'affinity' } as any,
],
});
const results = await generatorByName.comfort(db, ctx);
expect(results.length).toBeGreaterThanOrEqual(1);
expect(results[0]).toHaveProperty('trackId');
expect(results[0]).toHaveProperty('generatorId', 'comfort');
expect(results[0].explanation.length).toBeGreaterThanOrEqual(1);
});
it('returns empty when no high-affinity artists', async () => {
const db = makeMockDb();
const ctx = makeCtx({ beliefs: [ { entity_type: 'artist', entity_id: 'a1', value: 0.3, profile: 'longterm', dimension: 'affinity' } as any ] });
const results = await generatorByName.comfort(db, ctx);
expect(results).toHaveLength(0);
});
});
describe('adjacent', () => {
it('returns tracks from graph walks', async () => {
const db = makeMockDb();
(db.pgClient.query as any).mockResolvedValueOnce({ rows: [{ reached_artist_id: 'artist-2' }] });
(db.pgClient.query as any).mockResolvedValueOnce({ rows: [{ id: 'track-3' }] });
const ctx = makeCtx({ seedTrackId: 'track-1', seedArtistId: 'artist-1' });
const results = await generatorByName.adjacent(db, ctx);
if (results.length > 0) {
expect(results[0].explanation.length).toBeGreaterThanOrEqual(1);
}
});
it('returns empty when no seed artist', async () => {
const results = await generatorByName.adjacent(makeMockDb(), makeCtx());
expect(results).toHaveLength(0);
});
});
describe('discovery', () => {
it('respects novelty_tolerance cap', async () => {
const db = makeMockDb();
(db.pgClient.query as any).mockResolvedValue({ rows: [{ artist_id: 'a1' }, { artist_id: 'a2' }] });
(db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1' }] });
const ctx = makeCtx({
beliefs: [ { entity_type: 'artist', entity_id: 'trusted-1', value: 0.5, confidence: 0.8, profile: 'longterm', dimension: 'affinity' } as any ],
toleranceMap: { novelty_tolerance: 0.1 },
});
const results = await generatorByName.discovery(db, ctx);
const maxCandidates = Math.max(2, Math.floor(20 * 0.1));
expect(results.length).toBeLessThanOrEqual(maxCandidates);
});
});
describe('deepDive', () => {
it('returns album-ordered tracks for obsession artists', async () => {
const db = makeMockDb();
(db.pgClient.query as any).mockResolvedValueOnce({ rows: [{ album_id: 'alb-1', artist_id: 'a1', title: 'Album 1' }] });
(db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1' }, { id: 't2' }] });
const ctx = makeCtx({
beliefs: [ { entity_type: 'artist', entity_id: 'a1', dimension: 'affinity', value: 0.6, profile: 'obsession' } as any ],
});
const results = await generatorByName.deepDive(db, ctx);
expect(results.length).toBeGreaterThanOrEqual(1);
expect(results[0].generatorId).toBe('deep-dive');
});
});
describe('revival', () => {
it('returns tracks for stale high-affinity artists', async () => {
const db = makeMockDb();
// First query: stale listener_beliefs
(db.pgClient.query as any).mockResolvedValueOnce({
rows: [{ artist_id: 'a1', affinity: 0.7 }]
});
// Second query: tracks by those artists
(db.pgClient.query as any).mockResolvedValue({
rows: [{ id: 't1' }]
});
const ctx = makeCtx({
beliefs: [ { entity_type: 'artist', entity_id: 'a1', dimension: 'affinity', value: 0.5, profile: 'forgotten' } as any ],
});
const results = await generatorByName.revival(db, ctx);
expect(results.length).toBeGreaterThanOrEqual(1);
expect(results[0].generatorId).toBe('revival');
});
});
describe('novelty', () => {
it('returns recent tracks', async () => {
const db = makeMockDb();
(db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1' }] });
const ctx = makeCtx({
beliefs: [ { entity_type: 'artist', entity_id: 'trusted-1', value: 0.5, profile: 'longterm', dimension: 'affinity' } as any ],
});
const results = await generatorByName.novelty(db, ctx);
if (results.length > 0) {
expect(results[0].generatorId).toBe('novelty');
}
});
});
describe('experimental', () => {
it('returns tracks from unfamiliar genres', async () => {
const db = makeMockDb();
(db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1', genre_id: 'g1' }] });
const ctx = makeCtx({
beliefs: [ { entity_type: 'artist', entity_id: 'a1', value: 0.6, profile: 'longterm', dimension: 'affinity' } as any ],
});
const results = await generatorByName.experimental(db, ctx);
expect(results).toBeDefined();
});
});
describe('contextual', () => {
it('returns tracks matching context when set', async () => {
const db = makeMockDb();
(db.pgClient.query as any).mockResolvedValueOnce({ rows: [{ id: 't1' }] });
const ctx = makeCtx({
state: { energy: 0.5, lastArtistIds: [], lastGenreIds: [], context: 'coding', noveltyHunger: 0.3, sessionAgeMin: 10 },
});
const results = await generatorByName.contextual(db, ctx);
expect(results).toBeDefined();
});
it('returns empty when no context set', async () => {
const results = await generatorByName.contextual(makeMockDb(), makeCtx());
expect(results).toHaveLength(0);
});
});
});
describe('ALL_GENERATORS', () => {
it('contains 8 generators', () => {
expect(ALL_GENERATORS).toHaveLength(8);
ALL_GENERATORS.forEach(g => expect(typeof g).toBe('function'));
});
});
@@ -0,0 +1,105 @@
import { DbService } from './db.service.js';
export class ImageEnrichmentService {
private sourcePriority: Record<string, number> = {
cover_art_archive: 1,
theaudiodb: 2,
fanart: 3,
deezer: 4,
discogs: 5,
lastfm: 6,
};
constructor(private db: DbService) {}
// ---------------------------------------------------------------
// Phase 4 — Fetch image candidates from all sources
// ---------------------------------------------------------------
async fetchImagesForArtist(artistId: string): Promise<number> {
const artistRes = await this.db.pgClient.query<{ id: string; name: string; mbid: string | null; discogs_id: string | null }>(
`SELECT id, name, mbid, discogs_id FROM artists WHERE id = $1`,
[artistId]
);
const artist = artistRes.rows[0];
if (!artist) return 0;
const sources: { key: string; condition: string }[] = [
{ key: 'cover_art_archive', condition: artist.mbid ? 'mbid present' : 'no mbid' },
{ key: 'deezer', condition: 'always' },
{ key: 'discogs', condition: artist.discogs_id ? 'discogs_id present' : 'no discogs_id' },
{ key: 'lastfm', condition: 'always' },
];
let count = 0;
for (const src of sources) {
const res = await this.db.pgClient.query(
`INSERT INTO image_candidates (entity_type, entity_id, source, url, width)
VALUES ('artist', $1, $2, NULL, NULL)
ON CONFLICT (entity_type, entity_id, source) DO NOTHING
RETURNING 1 AS ins`,
[artistId, src.key]
);
if (res.rows.length > 0) count++;
}
return count;
}
async fetchImagesForAlbum(albumId: string): Promise<number> {
const sources = ['cover_art_archive', 'itunes', 'deezer', 'discogs'];
let count = 0;
for (const source of sources) {
const res = await this.db.pgClient.query(
`INSERT INTO image_candidates (entity_type, entity_id, source, url, width)
VALUES ('album', $1, $2, NULL, NULL)
ON CONFLICT (entity_type, entity_id, source) DO NOTHING
RETURNING 1 AS ins`,
[albumId, source]
);
if (res.rows.length > 0) count++;
}
return count;
}
async selectBestImage(entityType: string, entityId: string): Promise<string | null> {
const candidates = await this.db.pgClient.query<{ url: string; source: string; verified: boolean }>(
`SELECT url, source, verified
FROM image_candidates
WHERE entity_type = $1 AND entity_id = $2 AND url IS NOT NULL
ORDER BY
CASE source
WHEN 'cover_art_archive' THEN 1
WHEN 'theaudiodb' THEN 2
WHEN 'fanart' THEN 3
WHEN 'deezer' THEN 4
WHEN 'discogs' THEN 5
WHEN 'lastfm' THEN 6
ELSE 99
END,
verified DESC,
width DESC NULLS LAST
LIMIT 1`,
[entityType, entityId]
);
const best = candidates.rows[0];
if (!best) return null;
if (entityType === 'artist') {
await this.db.pgClient.query(
`UPDATE artists SET image_path = $1 WHERE id = $2`,
[best.url, entityId]
);
} else if (entityType === 'album') {
await this.db.pgClient.query(
`UPDATE albums SET artwork_id = $1 WHERE id = $2`,
[best.url, entityId]
);
}
return best.url;
}
}
+133
View File
@@ -0,0 +1,133 @@
import { Queue, Job } from 'bullmq';
import { MetadataRefreshJob, AudioAnalysisJob, CleanupJob, LibraryScanJob, ReindexTracksJob, ReprocessArtistsJob } from '../types/job.types.js';
export interface JobServiceConfig {
redisUrl: string;
}
export const QUEUE_NAME = 'muzick-queue';
export interface QueueStats {
waiting: number;
active: number;
completed: number;
failed: number;
delayed: number;
paused: number;
}
export interface JobHistoryEntry {
id: string;
name: string;
data: Record<string, unknown>;
timestamp: number;
finishedOn?: number;
failedReason?: string;
returnvalue?: unknown;
}
export class JobService {
private queue: Queue;
constructor(config: JobServiceConfig) {
this.queue = new Queue(QUEUE_NAME, {
connection: {
url: config.redisUrl,
},
});
}
async enqueueMetadataRefresh(trackId: string, type: 'full' | 'partial') {
const payload: MetadataRefreshJob = { trackId, refreshType: type };
await this.queue.add('metadata_refresh', payload);
}
async enqueueAudioAnalysis(trackId: string, features: string[]) {
const payload: AudioAnalysisJob = { trackId, features };
await this.queue.add('audio_analysis', payload);
}
async enqueueCleanup(reason: 'expired' | 'manual', targetFiles: string[]) {
const payload: CleanupJob = { reason, targetFiles };
await this.queue.add('cleanup', payload);
}
async enqueueLibraryScan(directory: string) {
const payload: LibraryScanJob = { directory };
await this.queue.add('scan_library', payload);
}
async enqueueReindexTracks() {
const payload: ReindexTracksJob = {};
await this.queue.add('reindex_tracks', payload);
}
async enqueueReprocessArtists() {
const payload: ReprocessArtistsJob = { batchSize: 100, offset: 0 };
await this.queue.add('reprocess_artists', payload);
}
/**
* Enqueue metadata_refresh jobs for a batch of track IDs. Used by the
* /admin/reenrich-tracks endpoint to re-canonicalize metadata (artist names,
* album titles, MBIDs, cover art) without re-scanning files from disk.
*
* Each job is deduped by `jobId: meta-<trackId>` so re-running the endpoint
* doesn't stack duplicate jobs. Old completed/failed jobs with the same ID
* are removed first so re-enrichment actually works (BullMQ otherwise treats
* existing jobIds as duplicates and silently skips them).
*/
async enqueueMetadataRefreshBatch(trackIds: string[]): Promise<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;
}
async getQueueStats(): Promise<QueueStats> {
const [waiting, active, completed, failed, delayed] = await Promise.all([
this.queue.getWaitingCount(),
this.queue.getActiveCount(),
this.queue.getCompletedCount(),
this.queue.getFailedCount(),
this.queue.getDelayedCount(),
]);
return { waiting, active, completed, failed, delayed, paused: 0 };
}
async getJobHistory(limit = 100): Promise<JobHistoryEntry[]> {
// Get jobs from completed and failed queues (most recent first)
const [completedJobs, failedJobs] = await Promise.all([
this.queue.getJobs(['completed'], 0, limit),
this.queue.getJobs(['failed'], 0, limit),
]);
const allJobs = [...completedJobs, ...failedJobs].map(job => ({
id: job.id as string,
name: job.name,
data: job.data as Record<string, unknown>,
timestamp: job.timestamp,
finishedOn: job.finishedOn,
failedReason: job.failedReason,
returnvalue: job.returnvalue,
}));
// Sort by timestamp descending (most recent first)
allJobs.sort((a, b) => b.timestamp - a.timestamp);
return allJobs.slice(0, limit);
}
async close() {
await this.queue.close();
}
}
+94
View File
@@ -0,0 +1,94 @@
import { Client } from 'typesense';
export interface SearchServiceConfig {
host: string;
port: number;
protocol: 'http' | 'https';
apiKey: string;
}
export class SearchService {
private client: Client;
private ready = false;
constructor(config: SearchServiceConfig) {
this.client = new Client({
nodes: [{
host: config.host,
port: config.port,
protocol: config.protocol,
}],
apiKey: config.apiKey,
});
}
get isReady(): boolean {
return this.ready;
}
async search(collection: string, query: string, options: any = {}) {
const searchParameters = {
'q': query,
'query_by': options.query_by || 'title,artist',
...options,
};
return await this.client.collections(collection).documents().search(searchParameters);
}
/**
* Create the 'tracks' collection schema if it does not already exist.
* Gracefully handles Typesense not being ready yet (503) — the collection
* can be created later by calling ensureCollection again or via the admin
* reindex endpoint. Search falls back to Postgres ILIKE when Typesense is
* unavailable, so a missing collection is never a hard failure.
*/
async ensureCollection(): Promise<void> {
const collectionSchema = {
name: 'tracks',
fields: [
{ name: 'id', type: 'string' as const },
{ name: 'title', type: 'string' as const },
{ name: 'artist', type: 'string' as const },
{ name: 'album', type: 'string' as const },
{ name: 'duration', type: 'int32' as const },
{ name: 'play_count', type: 'int32' as const },
{ name: 'genre', type: 'string[]' as const, facet: true },
{ name: 'source_type', type: 'string' as const },
],
};
// First check if the collection already exists (retrieve succeeds).
try {
await this.client.collections(collectionSchema.name).retrieve();
this.ready = true;
return;
} catch (checkErr: any) {
// 404 means collection doesn't exist — proceed to create.
// 503 means Typesense isn't ready yet — skip creation, search falls back.
if (checkErr?.httpStatus === 503) {
console.warn('[SearchService] Typesense not ready yet (503). Search will use Postgres ILIKE fallback.');
return;
}
}
// Collection doesn't exist — create it.
try {
await this.client.collections().create(collectionSchema);
this.ready = true;
} catch (createErr: any) {
// 409 / "already exists" — another process created it between our check and create.
if (createErr?.message?.includes('already exists')) {
this.ready = true;
return;
}
// 503 — Typesense not ready yet, not a hard failure.
if (createErr?.httpStatus === 503) {
console.warn('[SearchService] Typesense not ready yet (503). Search will use Postgres ILIKE fallback.');
return;
}
// Unexpected error — log and continue; search falls back to Postgres.
console.warn('[SearchService] Failed to create tracks collection:', createErr?.message ?? createErr);
}
}
}
@@ -0,0 +1,928 @@
import { DbService, ListenerBelief } from './db.service.js';
import { Candidate, GeneratorContext, Generator, ALL_GENERATORS } from './generators.service.js';
export interface FatigueState {
artist: Map<string, number>;
genre: Map<string, number>;
language: Map<string, number>;
track: Map<string, number>;
vocal: number;
}
export interface RecentPlay {
trackId: string;
artistId: string | null;
genreId: string | null;
bpm: number | null;
energy: number | null;
language: string | null;
vocal: boolean;
decade: number | null;
valence: number | null;
}
export interface DiversityBudget {
dimension: string;
budgetShare: number;
horizonMin: number;
spent: number;
}
const W_ENJOY = 1.0;
const W_FATIGUE = 0.4;
const W_DIVERSITY = 0.3;
const W_ENTROPY = 0.2;
const W_REPETITION = 0.5;
export class SessionDirector {
constructor(private db: DbService) {}
// ---------------------------------------------------------------
// D.1 — Build listener state vector
// ---------------------------------------------------------------
async buildState(userId: string, sessionId?: string): Promise<GeneratorContext['state']> {
let savedState: GeneratorContext['state'] | null = null;
if (sessionId) {
const res = await this.db.pgClient.query(
'SELECT * FROM session_state WHERE session_id = $1 AND user_id = $2',
[sessionId, userId]
);
if (res.rows[0]) {
const row = res.rows[0] as { state_vector: Record<string, unknown>; context: string | null; started_at: Date };
savedState = {
energy: (row.state_vector?.energy as number) ?? 0.5,
lastArtistIds: (row.state_vector?.lastArtistIds as string[]) ?? [],
lastGenreIds: (row.state_vector?.lastGenreIds as string[]) ?? [],
context: row.context,
noveltyHunger: (row.state_vector?.noveltyHunger as number) ?? 0.3,
sessionAgeMin: row.started_at
? (Date.now() - new Date(row.started_at).getTime()) / 60000
: 0,
};
}
}
if (!savedState) {
const latest = await this.db.getLatestSessionState(userId);
if (latest) {
savedState = {
energy: (latest.state_vector?.energy as number) ?? 0.5,
lastArtistIds: (latest.state_vector?.lastArtistIds as string[]) ?? [],
lastGenreIds: (latest.state_vector?.lastGenreIds as string[]) ?? [],
context: latest.context,
noveltyHunger: (latest.state_vector?.noveltyHunger as number) ?? 0.3,
sessionAgeMin: latest.started_at
? (Date.now() - new Date(latest.started_at).getTime()) / 60000
: 0,
};
}
}
// Compute fresh energy from last 5 completed plays
const energyRes = await this.db.pgClient.query(
`SELECT COALESCE(AVG(taf.energy), 0.5) AS energy
FROM (
SELECT ph.track_id
FROM play_history ph
WHERE ph.user_id = $1 AND ph.completed = true
ORDER BY ph.played_at DESC
LIMIT 5
) recent
JOIN track_audio_features taf ON taf.track_id = recent.track_id
WHERE taf.energy IS NOT NULL`,
[userId]
);
const energy = (energyRes.rows[0]?.energy as number) ?? 0.5;
// Read novelty hunger from discovery profile
const noveltyRes = await this.db.pgClient.query(
`SELECT value FROM listener_beliefs
WHERE user_id = $1 AND profile = 'discovery' AND dimension = 'novelty_tolerance'
LIMIT 1`,
[userId]
);
const noveltyHunger = (noveltyRes.rows[0]?.value as number) ?? 0.3;
// Last distinct artist IDs from recent completed plays.
// Use a subquery to order first, then DISTINCT — avoids PG's rule that
// DISTINCT + ORDER BY expressions must appear in the select list.
const lastArtistsRes = await this.db.pgClient.query(
`SELECT DISTINCT artist_id FROM (
SELECT ta.artist_id
FROM play_history ph
JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main'
WHERE ph.user_id = $1 AND ph.completed = true
ORDER BY ph.played_at DESC
LIMIT 40
) recent
LIMIT 10`,
[userId]
);
const lastArtistIds = lastArtistsRes.rows.map((r: { artist_id: string }) => r.artist_id);
// Last distinct genre IDs
const lastGenresRes = await this.db.pgClient.query(
`SELECT DISTINCT genre_id FROM (
SELECT tg.genre_id
FROM play_history ph
JOIN track_genre tg ON tg.track_id = ph.track_id
WHERE ph.user_id = $1 AND ph.completed = true
ORDER BY ph.played_at DESC
LIMIT 40
) recent
LIMIT 10`,
[userId]
);
const lastGenreIds = lastGenresRes.rows.map((r: { genre_id: string }) => r.genre_id);
const age = savedState?.sessionAgeMin ?? 0;
return {
energy,
lastArtistIds,
lastGenreIds,
context: savedState?.context ?? null,
noveltyHunger,
sessionAgeMin: age,
};
}
// ---------------------------------------------------------------
// D.2 — Fatigue model
// ---------------------------------------------------------------
async computeFatigue(userId: string): Promise<FatigueState> {
// Track fatigue: last 7 days, decay half-life 30d (2592000 seconds)
const TRACK_DECAY_SEC = 30 * 24 * 3600;
const trackRes = await this.db.pgClient.query(
`SELECT ph.track_id,
LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue
FROM play_history ph
WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '7 days' AND ph.completed = true
GROUP BY ph.track_id`,
[userId, TRACK_DECAY_SEC]
);
const track = new Map<string, number>();
for (const row of trackRes.rows as { track_id: string; fatigue: number }[]) {
track.set(row.track_id, row.fatigue);
}
// Artist fatigue: last 24h, decay half-life 8h (28800 seconds)
const ARTIST_DECAY_SEC = 8 * 3600;
const artistRes = await this.db.pgClient.query(
`SELECT ta.artist_id,
LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue
FROM play_history ph
JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main'
WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '24 hours' AND ph.completed = true
GROUP BY ta.artist_id`,
[userId, ARTIST_DECAY_SEC]
);
const artist = new Map<string, number>();
for (const row of artistRes.rows as { artist_id: string; fatigue: number }[]) {
artist.set(row.artist_id, row.fatigue);
}
// Genre fatigue: last 24h, decay half-life 8h
const genreRes = await this.db.pgClient.query(
`SELECT tg.genre_id,
LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue
FROM play_history ph
JOIN track_genre tg ON tg.track_id = ph.track_id
WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '24 hours' AND ph.completed = true
GROUP BY tg.genre_id`,
[userId, ARTIST_DECAY_SEC]
);
const genre = new Map<string, number>();
for (const row of genreRes.rows as { genre_id: string; fatigue: number }[]) {
genre.set(row.genre_id, row.fatigue);
}
// Language fatigue: last 2h, decay half-life 1h (3600 seconds)
const LANG_DECAY_SEC = 3600;
const langRes = await this.db.pgClient.query(
`SELECT tl.language,
LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue
FROM play_history ph
JOIN track_lyrics tl ON tl.track_id = ph.track_id
WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '2 hours' AND ph.completed = true
AND tl.language IS NOT NULL
GROUP BY tl.language`,
[userId, LANG_DECAY_SEC]
);
const language = new Map<string, number>();
for (const row of langRes.rows as { language: string; fatigue: number }[]) {
language.set(row.language, row.fatigue);
}
// Vocal fatigue: fraction of last 2h plays that are vocal (instrumentalness < 0.5)
const vocalRes = await this.db.pgClient.query(
`SELECT CASE WHEN COUNT(*) = 0 THEN 0.5
ELSE COUNT(*) FILTER (WHERE COALESCE(taf.instrumentalness, 0) < 0.5)::float8 / COUNT(*)::float8
END AS vocal_fatigue
FROM play_history ph
LEFT JOIN track_audio_features taf ON taf.track_id = ph.track_id
WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '2 hours' AND ph.completed = true`,
[userId]
);
const vocal = (vocalRes.rows[0]?.vocal_fatigue as number) ?? 0.5;
return { artist, genre, language, track, vocal };
}
// ---------------------------------------------------------------
// D.3 — Diversity budgets
// ---------------------------------------------------------------
async getBudgets(userId: string): Promise<DiversityBudget[]> {
const res = await this.db.pgClient.query(
'SELECT * FROM diversity_budgets WHERE user_id = $1 ORDER BY dimension',
[userId]
);
let rows: { dimension: string; budget_share: number; horizon_min: number }[];
if (res.rows.length === 0) {
await this.db.seedDefaultDiversityBudgets(userId);
const res2 = await this.db.pgClient.query(
'SELECT * FROM diversity_budgets WHERE user_id = $1 ORDER BY dimension',
[userId]
);
rows = res2.rows;
} else {
rows = res.rows;
}
const budgets: DiversityBudget[] = [];
for (const row of rows) {
const spent = await this.calcBudgetSpent(userId, row.dimension, row.horizon_min);
budgets.push({
dimension: row.dimension,
budgetShare: row.budget_share,
horizonMin: row.horizon_min,
spent,
});
}
return budgets;
}
private async calcBudgetSpent(userId: string, dimension: string, horizonMin: number): Promise<number> {
const interval = `${horizonMin} minutes`;
switch (dimension) {
case 'artist': {
const res = await this.db.pgClient.query(
`WITH sub AS (
SELECT COUNT(*) AS cnt
FROM play_history ph
JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main'
WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true
GROUP BY ta.artist_id
)
SELECT COALESCE(MAX(cnt)::float8 / NULLIF((SELECT SUM(cnt) FROM sub), 0), 0) AS spent
FROM sub`,
[userId, interval]
);
return (res.rows[0]?.spent as number) ?? 0;
}
case 'genre': {
const res = await this.db.pgClient.query(
`WITH sub AS (
SELECT COUNT(*) AS cnt
FROM play_history ph
JOIN track_genre tg ON tg.track_id = ph.track_id
WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true
GROUP BY tg.genre_id
)
SELECT COALESCE(MAX(cnt)::float8 / NULLIF((SELECT SUM(cnt) FROM sub), 0), 0) AS spent
FROM sub`,
[userId, interval]
);
return (res.rows[0]?.spent as number) ?? 0;
}
case 'language': {
const res = await this.db.pgClient.query(
`WITH sub AS (
SELECT tl.language, COUNT(*) AS cnt
FROM play_history ph
JOIN track_lyrics tl ON tl.track_id = ph.track_id
WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true
AND tl.language IS NOT NULL
GROUP BY tl.language
)
SELECT COALESCE(MAX(cnt)::float8 / NULLIF((SELECT SUM(cnt) FROM sub), 0), 0) AS spent
FROM sub`,
[userId, interval]
);
return (res.rows[0]?.spent as number) ?? 0;
}
case 'instrumental': {
const res = await this.db.pgClient.query(
`SELECT COALESCE(
COUNT(*) FILTER (WHERE COALESCE(taf.instrumentalness, 0) > 0.5)::float8 / NULLIF(COUNT(*), 0),
0) AS spent
FROM play_history ph
LEFT JOIN track_audio_features taf ON taf.track_id = ph.track_id
WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true`,
[userId, interval]
);
return (res.rows[0]?.spent as number) ?? 0;
}
case 'new_artist': {
const res = await this.db.pgClient.query(
`WITH recent_artists AS (
SELECT DISTINCT ta.artist_id
FROM play_history ph
JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main'
WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true
)
SELECT COALESCE(
SUM(CASE WHEN NOT EXISTS (
SELECT 1 FROM play_history ph3
JOIN track_artists_v2 ta3 ON ta3.track_id = ph3.track_id AND ta3.role = 'main'
WHERE ph3.user_id = $1 AND ph3.played_at <= NOW() - $2::interval
AND ta3.artist_id = ra.artist_id
) THEN 1 ELSE 0 END)::float8 / NULLIF(COUNT(*), 0),
0) AS spent
FROM recent_artists ra`,
[userId, interval]
);
return (res.rows[0]?.spent as number) ?? 0;
}
case 'favorite': {
const res = await this.db.pgClient.query(
`SELECT COALESCE(
COUNT(*) FILTER (WHERE f.track_id IS NOT NULL)::float8 / NULLIF(COUNT(*), 0),
0) AS spent
FROM play_history ph
LEFT JOIN favorites f ON f.track_id = ph.track_id AND f.user_id = $1
WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true`,
[userId, interval]
);
return (res.rows[0]?.spent as number) ?? 0;
}
default:
return 0;
}
}
// ---------------------------------------------------------------
// D.4 — Arc selection
// ---------------------------------------------------------------
pickArc(state: GeneratorContext['state']): string {
if (state.energy < 0.3) return 'late-night';
if (state.energy > 0.6 && state.noveltyHunger > 0.5) return 'discovery';
if (state.energy > 0.6) return 'energetic';
return 'comfort';
}
getArcSlots(arcType: string, count: number): { position: number; role: string }[] {
const pattern = this.getArcPattern(arcType);
const slots: { position: number; role: string }[] = [];
for (let i = 0; i < count; i++) {
slots.push({ position: i, role: pattern[i % pattern.length] });
}
return slots;
}
private getArcPattern(arcType: string): string[] {
switch (arcType) {
case 'comfort':
return ['known', 'known', 'known', 'known', 'adjacent', 'adjacent', 'adjacent', 'adjacent', 'favorite', 'favorite'];
case 'discovery':
return ['favorite', 'similar', 'new', 'favorite'];
case 'energetic':
return ['medium', 'medium', 'high', 'high', 'high', 'peak', 'cooldown', 'cooldown'];
case 'late-night':
return ['soft', 'soft', 'ambient', 'ambient', 'acoustic', 'slow'];
default:
return ['known', 'known', 'known', 'known', 'adjacent', 'adjacent', 'adjacent', 'adjacent', 'favorite', 'favorite'];
}
}
private roleToGeneratorIds(role: string): string[] {
switch (role) {
case 'known':
case 'medium':
case 'soft':
case 'acoustic':
case 'slow':
case 'cooldown':
return ['comfort'];
case 'adjacent':
case 'similar':
return ['adjacent'];
case 'favorite':
return ['deep-dive', 'comfort'];
case 'new':
case 'high':
return ['discovery'];
case 'peak':
return ['deep-dive', 'contextual'];
case 'ambient':
return ['contextual', 'comfort'];
default:
return ['comfort'];
}
}
// ---------------------------------------------------------------
// D.5 — Entropy, anti-loop
// ---------------------------------------------------------------
computeEntropy(candidates: Candidate[]): number {
if (candidates.length === 0) return 0;
const artistCounts = new Map<string, number>();
for (const c of candidates) {
const mainEdge = c.explanation.find(
e => e.subjectType === 'artist' || e.objectType === 'artist'
);
const key = mainEdge?.subjectId ?? mainEdge?.objectId ?? 'unknown';
artistCounts.set(key, (artistCounts.get(key) ?? 0) + 1);
}
const n = candidates.length;
let hhi = 0;
for (const count of artistCounts.values()) {
const share = count / n;
hhi += share * share;
}
return hhi;
}
async detectAntiLoop(
state: GeneratorContext['state'],
fatigue: FatigueState,
budgets: DiversityBudget[],
recentPlays: RecentPlay[]
): Promise<string | null> {
const n = recentPlays.length;
if (n < 3) return null;
// 1. ARTIST: single artist > 30% of recent plays
const artistCounts = new Map<string, number>();
for (const p of recentPlays) {
if (p.artistId) artistCounts.set(p.artistId, (artistCounts.get(p.artistId) ?? 0) + 1);
}
for (const count of artistCounts.values()) {
if (count / n > 0.3) return 'artist';
}
// 2. GENRE: single genre > 40% of recent plays
const genreCounts = new Map<string, number>();
for (const p of recentPlays) {
if (p.genreId) genreCounts.set(p.genreId, (genreCounts.get(p.genreId) ?? 0) + 1);
}
for (const count of genreCounts.values()) {
if (count / n > 0.4) return 'genre';
}
// 3. LANGUAGE: single language > 50% of recent plays
const langCounts = new Map<string, number>();
for (const p of recentPlays) {
if (p.language) langCounts.set(p.language, (langCounts.get(p.language) ?? 0) + 1);
}
for (const count of langCounts.values()) {
if (count / n > 0.5) return 'language';
}
// 4. ENERGY: >60% of plays in same energy quartile
const energies = recentPlays.filter(p => p.energy != null).map(p => p.energy!);
if (energies.length >= 3) {
const quartileCounts = [0, 0, 0, 0];
for (const e of energies) {
const q = Math.min(Math.floor(e / 0.25), 3);
quartileCounts[q]++;
}
if (Math.max(...quartileCounts) / energies.length > 0.6) return 'energy';
}
// 5. BPM: all plays within 20 BPM of each other
const bpms = recentPlays.filter(p => p.bpm != null).map(p => p.bpm!);
if (bpms.length >= 3) {
const bpmMin = Math.min(...bpms);
const bpmMax = Math.max(...bpms);
if (bpmMax - bpmMin <= 20) return 'bpm';
}
// 6. VOCAL: >80% all-vocal or all-instrumental
if (n >= 3) {
const vocalCount = recentPlays.filter(p => p.vocal).length;
const vocalRatio = vocalCount / n;
if (vocalRatio > 0.8 || vocalRatio < 0.2) return 'vocal';
}
// 7. DECADE: >50% from same decade
const decadeCounts = new Map<number, number>();
for (const p of recentPlays) {
if (p.decade != null) decadeCounts.set(p.decade, (decadeCounts.get(p.decade) ?? 0) + 1);
}
for (const count of decadeCounts.values()) {
if (count / n > 0.5) return 'decade';
}
// 8. PRODUCER: single producer > 3 tracks
const trackIds = recentPlays.map(p => p.trackId).filter(Boolean);
if (trackIds.length > 0) {
const prodRes = await this.db.pgClient.query(
`SELECT c.object_id
FROM claims c
WHERE c.predicate = 'produced'
AND c.subject_id = ANY($1::uuid[])
GROUP BY c.object_id
HAVING COUNT(DISTINCT c.subject_id) > 3`,
[trackIds]
);
if (prodRes.rows.length > 0) return 'producer';
}
// 9. LABEL: single label > 3 tracks
if (trackIds.length > 0) {
const labelRes = await this.db.pgClient.query(
`SELECT c.object_id
FROM claims c
WHERE c.predicate = 'same_label_as'
AND c.subject_id = ANY($1::uuid[])
GROUP BY c.object_id
HAVING COUNT(DISTINCT c.subject_id) > 3`,
[trackIds]
);
if (labelRes.rows.length > 0) return 'label';
}
// 10. MOOD: all plays same mood (valence > 0.5 = positive, <= 0.5 = negative)
const valences = recentPlays.filter(p => p.valence != null).map(p => p.valence!);
if (valences.length >= 3) {
const positiveCount = valences.filter(v => v > 0.5).length;
if (positiveCount === valences.length || positiveCount === 0) return 'mood';
}
return null;
}
// ---------------------------------------------------------------
// D.6 — Repetition rules
// ---------------------------------------------------------------
async checkRepetition(trackId: string, artistId: string, userId: string): Promise<boolean> {
const rulesRes = await this.db.pgClient.query(
'SELECT dimension, min_distance FROM repetition_rules WHERE user_id = $1',
[userId]
);
const ruleMap = new Map<string, number>();
for (const row of rulesRes.rows as { dimension: string; min_distance: number }[]) {
ruleMap.set(row.dimension, row.min_distance);
}
const trackMin = ruleMap.get('track') ?? 120;
if (trackMin > 0) {
const res = await this.db.pgClient.query(
`SELECT 1 FROM play_history
WHERE user_id = $1 AND track_id = $2 AND completed = true
AND played_at > NOW() - ($3 || ' minutes')::interval
LIMIT 1`,
[userId, trackId, String(trackMin)]
);
if (res.rows.length > 0) return true;
}
const artistMin = ruleMap.get('artist') ?? 20;
if (artistId && artistMin > 0) {
const res = await this.db.pgClient.query(
`SELECT 1 FROM play_history ph
JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.artist_id = $2 AND ta.role = 'main'
WHERE ph.user_id = $1 AND ph.completed = true
AND ph.played_at > NOW() - ($3 || ' minutes')::interval
LIMIT 1`,
[userId, artistId, String(artistMin)]
);
if (res.rows.length > 0) return true;
}
return false;
}
// ---------------------------------------------------------------
// D.8 — Multi-objective ranking
// ---------------------------------------------------------------
async rankCandidates(
candidates: Candidate[],
fatigue: FatigueState,
budgets: DiversityBudget[],
state: GeneratorContext['state'],
repetitionCheck: (trackId: string, artistId: string) => Promise<boolean>
): Promise<Candidate[]> {
if (candidates.length === 0) return [];
const trackIds = [...new Set(candidates.map(c => c.trackId))];
const artistMap = new Map<string, string>();
if (trackIds.length > 0) {
const artRes = await this.db.pgClient.query(
`SELECT DISTINCT ON (ta.track_id) ta.track_id, ta.artist_id
FROM track_artists_v2 ta
WHERE ta.track_id = ANY($1::uuid[]) AND ta.role = 'main'`,
[trackIds]
);
for (const row of artRes.rows as { track_id: string; artist_id: string }[]) {
artistMap.set(row.track_id, row.artist_id);
}
}
const genreMap = new Map<string, string>();
if (trackIds.length > 0) {
const genreRes = await this.db.pgClient.query(
`SELECT DISTINCT ON (tg.track_id) tg.track_id, tg.genre_id
FROM track_genre tg
WHERE tg.track_id = ANY($1::uuid[])
ORDER BY tg.track_id, tg.weight DESC`,
[trackIds]
);
for (const row of genreRes.rows as { track_id: string; genre_id: string }[]) {
genreMap.set(row.track_id, row.genre_id);
}
}
const artistBudget = budgets.find(b => b.dimension === 'artist');
const currentEntropy = this.computeEntropy(candidates);
const targetEntropy = 0.55;
const scored: { candidate: Candidate; score: number }[] = [];
for (const c of candidates) {
const artistId = artistMap.get(c.trackId) ?? '';
const genreId = genreMap.get(c.trackId) ?? '';
const trackFatigue = fatigue.track.get(c.trackId) ?? 0;
const artistFatigue = fatigue.artist.get(artistId) ?? 0;
const genreFatigue = fatigue.genre.get(genreId) ?? 0;
const avgFatigue = (trackFatigue + artistFatigue + genreFatigue) / 3;
const artistSpendRatio = artistBudget ? artistBudget.spent : 0;
const diversityBonus = 1 - artistSpendRatio;
const entropyBonus = 1 - Math.abs(currentEntropy - targetEntropy);
const wouldRepeat = await repetitionCheck(c.trackId, artistId);
let score = W_ENJOY * c.relevance
- W_FATIGUE * avgFatigue
+ W_DIVERSITY * diversityBonus
+ W_ENTROPY * entropyBonus;
if (wouldRepeat) {
score *= 0.1;
}
scored.push({ candidate: c, score });
}
const entropyDrift = Math.abs(currentEntropy - targetEntropy);
if (entropyDrift > 0.2) {
const genCounts = new Map<string, number>();
for (const s of scored) {
genCounts.set(s.candidate.generatorId, (genCounts.get(s.candidate.generatorId) ?? 0) + 1);
}
const maxCount = Math.max(...genCounts.values(), 1);
for (const s of scored) {
const genCount = genCounts.get(s.candidate.generatorId) ?? 0;
s.score += (1 - genCount / maxCount) * 0.15;
}
}
scored.sort((a, b) => b.score - a.score);
return scored.map(s => s.candidate);
}
// ---------------------------------------------------------------
// D.9 — Plan + replan loop
// ---------------------------------------------------------------
async buildPlan(userId: string, sessionId: string, seedTrackId?: string): Promise<Candidate[]> {
const allBeliefs = await this.db.getListenerBeliefs({ userId, limit: 200 });
// Fetch recent completed plays for anti-loop detection
const recentPlaysRes = await this.db.pgClient.query(
`SELECT t.id AS track_id, ta.artist_id, tg.genre_id,
af.bpm, af.energy, af.valence, af.instrumentalness,
tl.language,
t.release_date
FROM play_history ph
JOIN tracks t ON t.id = ph.track_id
LEFT JOIN track_audio_features af ON af.track_id = t.id
LEFT JOIN track_artists_v2 ta ON ta.track_id = t.id AND ta.role = 'main'
LEFT JOIN track_genre tg ON tg.track_id = t.id AND tg.weight = (
SELECT MAX(weight) FROM track_genre WHERE track_id = t.id
)
LEFT JOIN track_lyrics tl ON tl.track_id = t.id
WHERE ph.user_id = $1 AND ph.completed = true
ORDER BY ph.played_at DESC
LIMIT 20`,
[userId]
);
const recentPlays: RecentPlay[] = recentPlaysRes.rows.map((r: any) => ({
trackId: r.track_id,
artistId: r.artist_id ?? null,
genreId: r.genre_id ?? null,
bpm: r.bpm ?? null,
energy: r.energy ?? null,
language: r.language ?? null,
vocal: (r.instrumentalness == null) ? false : r.instrumentalness < 0.5,
decade: r.release_date ? Math.floor(new Date(r.release_date).getFullYear() / 10) * 10 : null,
valence: r.valence ?? null,
}));
const state = await this.buildState(userId, sessionId);
const fatigue = await this.computeFatigue(userId);
const budgets = await this.getBudgets(userId);
const arcType = this.pickArc(state);
const planSize = 20;
const slots = this.getArcSlots(arcType, planSize);
let seedArtistId: string | null = null;
if (seedTrackId) {
seedArtistId = await this.resolveSeedArtistId(seedTrackId) ?? null;
}
const recentExclusions: string[] = [];
const toleranceMap: Record<string, number> = {};
const discoveryBeliefs = allBeliefs.filter(b => b.profile === 'discovery');
for (const b of discoveryBeliefs) {
if (b.dimension) toleranceMap[b.dimension] = b.value;
}
const ctx: GeneratorContext = {
userId,
seedTrackId: seedTrackId ?? null,
seedArtistId,
beliefs: allBeliefs,
recentExclusions,
toleranceMap,
state,
};
const allCandidates: Candidate[] = [];
for (const gen of ALL_GENERATORS) {
const result = await gen(this.db, ctx);
allCandidates.push(...result);
}
if (allCandidates.length === 0) {
return [];
}
const repetitionCheckFn = (tid: string, aid: string) =>
this.checkRepetition(tid, aid, userId);
const ranked = await this.rankCandidates(
allCandidates, fatigue, budgets, state, repetitionCheckFn
);
const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays);
let forcedExperimental = false;
if (loopDim && ranked.length > 0) {
const expCtx: GeneratorContext = {
...ctx,
recentExclusions: ctx.recentExclusions.slice(0, Math.min(ctx.recentExclusions.length, 50)),
};
const extraCandidates: Candidate[] = [];
for (const gen of ALL_GENERATORS) {
const result = await gen(this.db, expCtx);
extraCandidates.push(...result);
}
const expRanked = await this.rankCandidates(
extraCandidates, fatigue, budgets, state, repetitionCheckFn
);
const injected = expRanked.filter(
c => c.generatorId === 'experimental' || c.generatorId === 'discovery'
);
ranked.unshift(...injected);
forcedExperimental = true;
}
const seen = new Set<string>();
const deduped: Candidate[] = [];
for (const c of ranked) {
if (!seen.has(c.trackId)) {
seen.add(c.trackId);
deduped.push(c);
}
}
const plan: Candidate[] = [];
const usedTrackIds = new Set<string>();
if (!forcedExperimental) {
const unused = [...deduped];
for (const slot of slots) {
const prefGenIds = this.roleToGeneratorIds(slot.role);
let idx = unused.findIndex(
c => prefGenIds.includes(c.generatorId) && !usedTrackIds.has(c.trackId)
);
if (idx === -1) {
idx = unused.findIndex(c => !usedTrackIds.has(c.trackId));
}
if (idx === -1) break;
const chosen = unused[idx];
usedTrackIds.add(chosen.trackId);
plan.push(chosen);
unused.splice(idx, 1);
}
if (plan.length < planSize) {
for (const c of deduped) {
if (plan.length >= planSize) break;
if (!usedTrackIds.has(c.trackId)) {
usedTrackIds.add(c.trackId);
plan.push(c);
}
}
}
} else {
for (const c of deduped) {
if (plan.length >= planSize) break;
plan.push(c);
}
}
return plan.slice(0, planSize);
}
async replan(
userId: string,
sessionId: string,
currentPlan: Candidate[],
playedTrackIds: string[],
seedTrackId?: string
): Promise<Candidate[]> {
const remainingSlots = currentPlan.filter(
c => !playedTrackIds.includes(c.trackId)
);
if (remainingSlots.length >= 10 && currentPlan.length > 0) {
const fatigue = await this.computeFatigue(userId);
const budgets = await this.getBudgets(userId);
const state = await this.buildState(userId, sessionId);
// Fetch recent plays for anti-loop
const recentPlaysRes = await this.db.pgClient.query(
`SELECT t.id AS track_id, ta.artist_id, tg.genre_id,
af.bpm, af.energy, af.valence, af.instrumentalness,
tl.language,
t.release_date
FROM play_history ph
JOIN tracks t ON t.id = ph.track_id
LEFT JOIN track_audio_features af ON af.track_id = t.id
LEFT JOIN track_artists_v2 ta ON ta.track_id = t.id AND ta.role = 'main'
LEFT JOIN track_genre tg ON tg.track_id = t.id AND tg.weight = (
SELECT MAX(weight) FROM track_genre WHERE track_id = t.id
)
LEFT JOIN track_lyrics tl ON tl.track_id = t.id
WHERE ph.user_id = $1 AND ph.completed = true
ORDER BY ph.played_at DESC
LIMIT 20`,
[userId]
);
const recentPlays: RecentPlay[] = recentPlaysRes.rows.map((r: any) => ({
trackId: r.track_id,
artistId: r.artist_id ?? null,
genreId: r.genre_id ?? null,
bpm: r.bpm ?? null,
energy: r.energy ?? null,
language: r.language ?? null,
vocal: (r.instrumentalness == null) ? false : r.instrumentalness < 0.5,
decade: r.release_date ? Math.floor(new Date(r.release_date).getFullYear() / 10) * 10 : null,
valence: r.valence ?? null,
}));
const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays);
if (loopDim) {
return this.buildPlan(userId, sessionId, seedTrackId);
}
const entropy = this.computeEntropy(currentPlan);
if (Math.abs(entropy - 0.55) > 0.2) {
return this.buildPlan(userId, sessionId, seedTrackId);
}
return remainingSlots;
}
return this.buildPlan(userId, sessionId, seedTrackId);
}
// ---------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------
private async resolveSeedArtistId(seedTrackId: string): Promise<string | undefined> {
const res = await this.db.pgClient.query(
`SELECT ta.artist_id
FROM track_artists_v2 ta
WHERE ta.track_id = $1 AND ta.role = 'main'
LIMIT 1`,
[seedTrackId]
);
if (res.rows[0]?.artist_id) return res.rows[0].artist_id as string;
const fallback = await this.db.pgClient.query(
`SELECT al.artist_id
FROM tracks t
JOIN albums al ON al.id = t.album_id
WHERE t.id = $1`,
[seedTrackId]
);
return fallback.rows[0]?.artist_id as string | undefined;
}
}
@@ -0,0 +1,117 @@
import { describe, it, expect, vi } from 'vitest';
import { SessionDirector } from './session-director.service.js';
import { DbService } from './db.service.js';
function makeMockDb(overrides: Record<string, any> = {}): DbService {
const mockQuery = vi.fn();
return {
pgClient: { query: mockQuery },
getListenerBeliefs: vi.fn().mockResolvedValue([]),
getLatestSessionState: vi.fn().mockResolvedValue(null),
seedDefaultDiversityBudgets: vi.fn().mockResolvedValue(undefined),
upsertDiversityBudget: vi.fn().mockResolvedValue(undefined),
...overrides,
} as unknown as DbService;
}
describe('SessionDirector', () => {
describe('pickArc', () => {
const director = new SessionDirector(makeMockDb());
it('returns late-night for low energy', () => {
const arc = director.pickArc({ energy: 0.2, noveltyHunger: 0.3, sessionAgeMin: 10 } as any);
expect(arc).toBe('late-night');
});
it('returns discovery for high energy + high novelty', () => {
const arc = director.pickArc({ energy: 0.7, noveltyHunger: 0.6, sessionAgeMin: 5 } as any);
expect(arc).toBe('discovery');
});
it('returns energetic for high energy + low novelty', () => {
const arc = director.pickArc({ energy: 0.7, noveltyHunger: 0.3, sessionAgeMin: 5 } as any);
expect(arc).toBe('energetic');
});
it('returns comfort for medium energy', () => {
const arc = director.pickArc({ energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10 } as any);
expect(arc).toBe('comfort');
});
});
describe('getArcSlots', () => {
const director = new SessionDirector(makeMockDb());
it('returns correct slot count', () => {
expect(director.getArcSlots('comfort', 20).length).toBe(20);
expect(director.getArcSlots('discovery', 20).length).toBe(20);
expect(director.getArcSlots('energetic', 10).length).toBe(10);
expect(director.getArcSlots('late-night', 8).length).toBe(8);
});
it('has valid role names', () => {
const slots = director.getArcSlots('comfort', 20);
const validRoles = ['known', 'adjacent', 'favorite', 'similar', 'new', 'medium', 'high', 'peak', 'cooldown', 'soft', 'ambient', 'acoustic', 'slow'];
slots.forEach(s => expect(validRoles).toContain(s.role));
});
});
describe('computeEntropy', () => {
const director = new SessionDirector(makeMockDb());
it('returns 0 for empty set', () => {
expect(director.computeEntropy([])).toBe(0);
});
it('returns 1 for all-same-artist', () => {
const candidates = [
{ trackId: 't1', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] },
{ trackId: 't2', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] },
] as any;
expect(director.computeEntropy(candidates)).toBe(1);
});
it('returns ~0.5 for two-artist split', () => {
const candidates = [
{ trackId: 't1', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] },
{ trackId: 't2', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a2', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] },
] as any;
const hhi = director.computeEntropy(candidates);
expect(hhi).toBeCloseTo(0.5);
});
});
describe('rankCandidates', () => {
it('sorts candidates by score descending', async () => {
const db = makeMockDb();
(db.pgClient.query as any).mockResolvedValue({ rows: [] });
const director = new SessionDirector(db);
const candidates = [
{ trackId: 't1', generatorId: 'a', relevance: 0.9, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] },
{ trackId: 't2', generatorId: 'b', relevance: 0.3, explanation: [{ subjectType: 'artist', subjectId: 'a2', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] },
];
const fatigue = { artist: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 };
const budgets = [{ dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 }];
const state = { energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10, lastArtistIds: [], lastGenreIds: [], context: null };
const ranked = await director.rankCandidates(candidates, fatigue, budgets, state, async () => false);
expect(ranked[0].relevance).toBeGreaterThanOrEqual(ranked[ranked.length - 1].relevance);
});
});
describe('buildState', () => {
it('returns state with default values when no prior session', async () => {
const db = makeMockDb();
(db.pgClient.query as any).mockResolvedValue({ rows: [] });
(db.getListenerBeliefs as any).mockResolvedValue([]);
(db.getLatestSessionState as any).mockResolvedValue(null);
const director = new SessionDirector(db);
const state = await director.buildState('user-1');
expect(state).toHaveProperty('energy');
expect(state).toHaveProperty('noveltyHunger');
expect(state).toHaveProperty('sessionAgeMin');
expect(typeof state.energy).toBe('number');
});
});
});
+27
View File
@@ -0,0 +1,27 @@
export interface MetadataRefreshJob {
trackId: string;
refreshType: 'full' | 'partial';
}
export interface AudioAnalysisJob {
trackId: string;
features: string[];
}
export interface CleanupJob {
reason: 'expired' | 'manual';
targetFiles: string[];
}
export interface LibraryScanJob {
directory: string;
}
export interface ReindexTracksJob {}
export interface ReprocessArtistsJob {
batchSize?: number;
offset?: number;
}
export type JobPayload = MetadataRefreshJob | AudioAnalysisJob | CleanupJob | LibraryScanJob | ReindexTracksJob | ReprocessArtistsJob;
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"*": ["node_modules/*"]
},
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
});