initial state: muzick music player + recommendation engine
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
@@ -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"]
|
||||
Generated
+3206
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Initialize the database schema
|
||||
psql "$DATABASE_URL" -f src/db/schema.sql
|
||||
|
||||
|
||||
echo "Database initialized successfully."
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
console.log('Backend starting...');
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user