initial state: muzick music player + recommendation engine

This commit is contained in:
kami
2026-07-14 01:35:52 +04:00
commit 737bf19fd1
196 changed files with 32431 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { JobService } from '../services/job.service.js';
import { DbService } from '../services/db.service.js';
export default async function adminRoutes(fastify: FastifyInstance, options: { jobService: JobService; dbService: DbService }) {
const { jobService, dbService } = options;
fastify.post('/scan', async (request: FastifyRequest, reply: FastifyReply) => {
const { directory } = request.body as { directory: string };
if (!directory) {
return reply.code(400).send({ error: 'Directory is required' });
}
await jobService.enqueueLibraryScan(directory);
return { status: 'Scan job enqueued', directory };
});
fastify.post('/reindex-tracks', async (_request: FastifyRequest, reply: FastifyReply) => {
await jobService.enqueueReindexTracks();
return { status: 'Reindex job enqueued' };
});
fastify.post('/reprocess-artists', async (_request: FastifyRequest, reply: FastifyReply) => {
await jobService.enqueueReprocessArtists();
return { status: 'Artist reprocessing job enqueued' };
});
fastify.post('/dedup-albums', async (_request: FastifyRequest, reply: FastifyReply) => {
// Merge duplicate album rows directly (synchronous — it's just SQL, no
// external API calls). Returns the number of albums merged away.
// Tiebreaker for keeper selection: MBID > artwork > earliest release_date
// > most tracks > oldest created_at.
//
// Note: the two duplicate-detection passes (by title and by MBID) may find
// overlapping pairs; the UNION ALL in `pairs` can produce duplicates, but
// the DELETE at the end is idempotent (a loser deleted in one pair won't
// exist for the next). The folded/moved CTEs also tolerate this because
// COALESCE is idempotent and the loser row simply won't be found again.
const res = await dbService.pgClient.query<{ count: number }>(`
WITH duplicates AS (
SELECT lower(title) AS lt, array_agg(id ORDER BY
CASE WHEN mbid IS NOT NULL THEN 0 ELSE 1 END,
CASE WHEN artwork_id IS NOT NULL AND artwork_id <> '' THEN 0 ELSE 1 END,
release_date NULLS LAST,
(SELECT COUNT(*) FROM tracks t WHERE t.album_id = albums.id) DESC,
created_at
) AS ids
FROM albums GROUP BY lower(title) HAVING COUNT(*) > 1
),
mbid_dupes AS (
SELECT mbid, array_agg(id ORDER BY
CASE WHEN artwork_id IS NOT NULL AND artwork_id <> '' THEN 0 ELSE 1 END,
release_date NULLS LAST,
created_at
) AS ids
FROM albums WHERE mbid IS NOT NULL
GROUP BY mbid HAVING COUNT(*) > 1
),
pairs AS (
SELECT ids[1] AS keep_id, unnest(ids[2:]) AS loser_id FROM duplicates
UNION
SELECT ids[1] AS keep_id, unnest(ids[2:]) AS loser_id FROM mbid_dupes
),
-- Fold metadata from losers onto keepers (idempotent via COALESCE).
folded AS (
UPDATE albums a SET
artwork_id = COALESCE(a.artwork_id, src.artwork_id),
year = COALESCE(a.year, src.year),
mbid = COALESCE(a.mbid, src.mbid),
release_date = COALESCE(a.release_date, src.release_date)
FROM (
SELECT DISTINCT ON (p.loser_id) p.keep_id, lo.artwork_id, lo.year, lo.mbid, lo.release_date, p.loser_id
FROM pairs p
JOIN albums lo ON lo.id = p.loser_id
ORDER BY p.loser_id
) AS src
WHERE a.id = src.keep_id
),
-- Move tracks from losers to keepers.
moved AS (
UPDATE tracks SET album_id = src.keep_id
FROM (SELECT DISTINCT keep_id, loser_id FROM pairs) AS src
WHERE tracks.album_id = src.loser_id
),
-- Delete losers.
deleted AS (
DELETE FROM albums
WHERE id IN (SELECT DISTINCT loser_id FROM pairs)
RETURNING 1
)
SELECT COUNT(*)::int AS count FROM deleted
`);
return { status: 'Albums deduplicated', merged: res.rows[0]?.count ?? 0 };
});
fastify.post('/reenrich-tracks', async (_request: FastifyRequest, reply: FastifyReply) => {
// Re-enqueue metadata_refresh for every LIBRARY track without re-reading
// files from disk. This re-runs the MusicBrainz canonicalisation (artist
// names, album titles, MBIDs) and re-triggers album_cover jobs — much
// faster than a full scan when only metadata needs refreshing.
const res = await dbService.pgClient.query<{ id: string }>(
`SELECT id FROM tracks WHERE state = 'LIBRARY' ORDER BY id`
);
const trackIds = res.rows.map((r) => r.id);
const enqueued = await jobService.enqueueMetadataRefreshBatch(trackIds);
return { status: 'Re-enrich enqueued', trackCount: enqueued };
});
fastify.get('/queue-stats', async () => {
return await jobService.getQueueStats();
});
fastify.get('/job-history', async (request: FastifyRequest) => {
const { limit } = request.query as { limit?: string };
return await jobService.getJobHistory(parseInt(limit || '100', 10));
});
fastify.get('/duplicates', async (request) => {
const { mode } = request.query as { mode?: string };
return await dbService.getDuplicateGroups(mode === 'title-artist' ? 'title-artist' : 'hash');
});
fastify.post('/duplicates/merge', async (request: FastifyRequest, reply: FastifyReply) => {
const { keepId, deleteIds } = request.body as { keepId: string; deleteIds: string[] };
if (!keepId || !Array.isArray(deleteIds) || deleteIds.length === 0) {
return reply.code(400).send({ error: 'keepId and deleteIds[] are required' });
}
await dbService.mergeDuplicates(keepId, deleteIds);
return { status: 'merged', kept: keepId, deleted: deleteIds.length };
});
fastify.get('/artist-stats', async (request: FastifyRequest, reply: FastifyReply) => {
const db = dbService.pgClient;
const total = await db.query('SELECT COUNT(*)::int AS n FROM artists');
const withMbid = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE mbid IS NOT NULL');
const withCanonical = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE canonical_name IS NOT NULL');
const withSort = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE sort_name IS NOT NULL');
const withImage = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE image_path IS NOT NULL AND image_path != \'\'');
const aliases = await db.query('SELECT COUNT(*)::int AS n FROM artist_aliases');
const cache = await db.query('SELECT COUNT(*)::int AS n FROM artist_lookup_cache');
const noImage = await db.query(`
SELECT name, canonical_name, mbid, sort_name
FROM artists
WHERE image_path IS NULL OR image_path = ''
ORDER BY name
LIMIT 50
`);
return {
total: total.rows[0].n,
withMbid: withMbid.rows[0].n,
withCanonicalName: withCanonical.rows[0].n,
withSortName: withSort.rows[0].n,
withImage: withImage.rows[0].n,
withoutImage: total.rows[0].n - withImage.rows[0].n,
aliases: aliases.rows[0].n,
cacheSize: cache.rows[0].n,
imageCoverage: `${((withImage.rows[0].n / total.rows[0].n) * 100).toFixed(1)}%`,
artistsWithoutImage: noImage.rows,
};
});
fastify.get('/artist-verify/:name', async (request: FastifyRequest, reply: FastifyReply) => {
const { name } = request.params as { name: string };
const db = dbService.pgClient;
const exact = await db.query(
`SELECT id, name, canonical_name, sort_name, mbid, image_path
FROM artists WHERE name = $1`,
[name]
);
const normalized = await db.query(
`SELECT id, name, canonical_name, sort_name, mbid, image_path
FROM artists WHERE normalize_artist(name) = normalize_artist($1)`,
[name]
);
const aliases = await db.query(
`SELECT a.*, ar.canonical_name as artist_canonical, ar.mbid as artist_mbid
FROM artist_aliases a
JOIN artists ar ON ar.id = a.artist_id
WHERE a.alias_normalized = normalize_artist($1)`,
[name]
);
return {
exactMatch: exact.rows,
normalizedMatches: normalized.rows,
aliases: aliases.rows,
};
});
}
+94
View File
@@ -0,0 +1,94 @@
import { FastifyInstance } from 'fastify';
import { DbService } from '../services/db.service.js';
import { DiscoveryService } from '../services/discovery.service.js';
import { ImageEnrichmentService } from '../services/image-enrichment.service.js';
export default async function discoveryRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
const { dbService } = options;
const discovery = new DiscoveryService(dbService);
const images = new ImageEnrichmentService(dbService);
/**
* POST /api/discovery/walk — trigger graph walk for discovery candidates
*/
fastify.post('/discovery/walk', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const count = await discovery.walkGraphForDiscovery(userId);
return reply.send({ newCandidates: count });
});
/**
* GET /api/discovery/candidates — list discovery candidates
* Query: ?status=candidate&limit=50
*/
fastify.get('/discovery/candidates', async (request, reply) => {
const query = request.query as { status?: string; limit?: string };
const status = query.status || 'candidate';
const limit = parseInt(query.limit || '50', 10);
const res = await dbService.pgClient.query(
`SELECT * FROM discovery_candidates WHERE status = $1 ORDER BY first_seen_at DESC LIMIT $2`,
[status, limit]
);
return reply.send({ candidates: res.rows });
});
/**
* POST /api/discovery/eval — evaluate pending candidates for acquisition
*/
fastify.post('/discovery/eval', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const results = await discovery.evalCandidates(userId);
return reply.send({ evaluated: results.length, results });
});
/**
* POST /api/discovery/sweep-probation — evaluate probation tracks
*/
fastify.post('/discovery/sweep-probation', async (_request, reply) => {
const result = await discovery.sweepProbation();
return reply.send(result);
});
/**
* POST /api/discovery/meta-learn — run meta-learning
*/
fastify.post('/discovery/meta-learn', async (_request, reply) => {
await discovery.runMetaLearning();
return reply.send({ status: 'ok' });
});
/**
* POST /api/images/fetch — mark image candidates for an entity
* Body: { entity_type, entity_id }
*/
fastify.post('/images/fetch', async (request, reply) => {
const body = request.body as { entity_type: string; entity_id: string };
if (!body.entity_type || !body.entity_id) {
return reply.code(400).send({ error: 'entity_type and entity_id required' });
}
let count = 0;
if (body.entity_type === 'artist') {
count = await images.fetchImagesForArtist(body.entity_id);
} else if (body.entity_type === 'album') {
count = await images.fetchImagesForAlbum(body.entity_id);
} else {
return reply.code(400).send({ error: 'entity_type must be "artist" or "album"' });
}
return reply.send({ candidateRows: count });
});
/**
* POST /api/images/select — select best image for an entity
* Body: { entity_type, entity_id }
*/
fastify.post('/images/select', async (request, reply) => {
const body = request.body as { entity_type: string; entity_id: string };
if (!body.entity_type || !body.entity_id) {
return reply.code(400).send({ error: 'entity_type and entity_id required' });
}
const url = await images.selectBestImage(body.entity_type, body.entity_id);
return reply.send({ url });
});
}
+152
View File
@@ -0,0 +1,152 @@
import { FastifyInstance } from 'fastify';
import { DbService } from '../services/db.service.js';
export default async function graphRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
const { dbService } = options;
/**
* GET /api/graph/artists/:id/fusion — fused artist credits for a track or album
* Query: ?entity_type=track&entity_id=<uuid>
* Returns the fused view of who is credited as main/featured on this entity.
*/
fastify.get('/graph/artists/:id/fusion', async (request, reply) => {
const { id } = request.params as { id: string };
const query = request.query as { entity_type?: string; entity_id?: string };
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
if (query.entity_type === 'track' && query.entity_id) {
const artists = await dbService.getFusedTrackArtists(query.entity_id, userId);
return reply.send({ entity_type: 'track', entity_id: query.entity_id, artists });
}
return reply.code(400).send({ error: 'Provide ?entity_type=track&entity_id=<uuid>' });
});
/**
* GET /api/graph/tracks/:id/claims — all claims for a track
*/
fastify.get('/graph/tracks/:id/claims', async (request, reply) => {
const { id } = request.params as { id: string };
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const claims = await dbService.getClaimsBySubject('track', id, undefined, userId);
return reply.send({ track_id: id, claims });
});
/**
* GET /api/graph/artists/:id/claims — all claims for an artist
*/
fastify.get('/graph/artists/:id/claims', async (request, reply) => {
const { id } = request.params as { id: string };
const predicate = (request.query as { predicate?: string }).predicate;
const claims = await dbService.getClaimsBySubject('artist', id, predicate);
return reply.send({ artist_id: id, claims });
});
/**
* GET /api/graph/artists/:id/beliefs — listener beliefs for an artist
*/
fastify.get('/graph/artists/:id/beliefs', async (request, reply) => {
const { id } = request.params as { id: string };
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const beliefs = await dbService.getListenerBeliefs({
userId,
entityType: 'artist',
entityId: id,
});
return reply.send({ artist_id: id, beliefs });
});
/**
* POST /api/graph/claim — upsert a claim into the graph
* Body: { subject_type, subject_id, predicate, object_type, object_id, source, confidence?, raw? }
*/
fastify.post('/graph/claim', async (request, reply) => {
const body = request.body as {
subject_type: string;
subject_id: string;
predicate: string;
object_type: string;
object_id: string;
source: string;
confidence?: number;
raw?: unknown;
};
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
if (!body.subject_type || !body.subject_id || !body.predicate || !body.object_type || !body.object_id || !body.source) {
return reply.code(400).send({ error: 'Missing required fields: subject_type, subject_id, predicate, object_type, object_id, source' });
}
const id = await dbService.upsertClaim({
user_id: userId === '00000000-0000-0000-0000-000000000000' ? null : userId,
subject_type: body.subject_type,
subject_id: body.subject_id,
predicate: body.predicate,
object_type: body.object_type,
object_id: body.object_id,
source: body.source,
confidence: body.confidence,
raw: body.raw,
});
return reply.code(201).send({ id });
});
/**
* POST /api/graph/evidence — record an evidence signal
* Body: { entity_type, entity_id, signal, profile, weight, context? }
*/
fastify.post('/graph/evidence', async (request, reply) => {
const body = request.body as {
entity_type: string;
entity_id: string;
signal: string;
profile: string;
weight: number;
context?: unknown;
};
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
if (!body.entity_type || !body.entity_id || !body.signal || body.weight === undefined) {
return reply.code(400).send({ error: 'Missing required fields: entity_type, entity_id, signal, weight' });
}
const id = await dbService.recordEvidence({
user_id: userId,
entity_type: body.entity_type,
entity_id: body.entity_id,
signal: body.signal,
profile: body.profile || 'longterm',
weight: body.weight,
context: body.context,
});
return reply.code(201).send({ id });
});
/**
* GET /api/graph/sources — list all claim sources and their trust weights
*/
fastify.get('/graph/sources', async (_request, reply) => {
const res = await (dbService as any).pgClient.query(
'SELECT * FROM source_trust ORDER BY trust DESC'
);
return reply.send({ sources: res.rows });
});
/**
* GET /api/graph/summary — aggregate graph stats (claim counts per source)
*/
fastify.get('/graph/summary', async (_request, reply) => {
const counts = await (dbService as any).pgClient.query(
`SELECT c.source, st.trust, COUNT(*)::int AS claim_count
FROM claims c
JOIN source_trust st ON st.key = c.source
GROUP BY c.source, st.trust
ORDER BY claim_count DESC`
);
const total = counts.rows.reduce((sum: number, r: any) => sum + r.claim_count, 0);
return reply.send({ total_claims: total, by_source: counts.rows });
});
}
+52
View File
@@ -0,0 +1,52 @@
import { FastifyInstance } from 'fastify';
import { DbService, FEEDBACK_ACTIONS, FeedbackAction } from '../services/db.service.js';
export default async function historyRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
const { dbService } = options;
// Record a playback event. completed defaults to false.
fastify.post('/history', async (request, reply) => {
const { trackId, completed, batchId } = request.body as {
trackId: string;
completed?: boolean;
batchId?: string;
};
if (!trackId) {
return reply.code(400).send({ error: 'trackId is required' });
}
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const historyId = await dbService.recordPlay(userId, trackId, completed === true, batchId);
return reply.send({ historyId });
});
// Record a skip (transient negative signal).
fastify.post('/history/skip', async (request, reply) => {
const { trackId } = request.body as { trackId: string };
if (!trackId) {
return reply.code(400).send({ error: 'trackId is required' });
}
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
await dbService.recordSkip(userId, trackId);
return reply.send({ status: 'ok' });
});
// Recent play history for the user.
fastify.get('/history', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
return await dbService.getHistory(userId);
});
// Explicit feedback.
fastify.post('/feedback', async (request, reply) => {
const { trackId, action } = request.body as { trackId: string; action: string };
if (!trackId) {
return reply.code(400).send({ error: 'trackId is required' });
}
if (!FEEDBACK_ACTIONS.includes(action as FeedbackAction)) {
return reply.code(400).send({ error: `Invalid action. Allowed: ${FEEDBACK_ACTIONS.join(', ')}` });
}
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
await dbService.recordFeedback(userId, trackId, action as FeedbackAction);
return reply.send({ status: 'ok' });
});
}
+49
View File
@@ -0,0 +1,49 @@
import { FastifyInstance } from 'fastify';
/**
* Image proxy — fetches external artwork URLs server-side and returns them
* with aggressive caching headers so the browser never re-fetches from
* Discogs / Cover Art Archive on repeat page loads.
*/
export default async function imagesRoutes(fastify: FastifyInstance) {
fastify.get('/images/proxy', async (request, reply) => {
const { url } = request.query as { url?: string };
if (!url) {
return reply.code(400).send({ error: 'url query parameter is required' });
}
// Only proxy http(s) URLs — don't be an open proxy for file:// etc.
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return reply.code(400).send({ error: 'Only http/https URLs are supported' });
}
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
return reply.code(response.status).send({ error: `Upstream returned ${response.status}` });
}
const buffer = await response.arrayBuffer();
const contentType = response.headers.get('content-type') || 'image/jpeg';
// Cache aggressively — artwork URLs are immutable (Discogs, Cover Art
// Archive etc. use content-addressed paths). 1 year.
return reply
.headers({
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
'Content-Length': buffer.byteLength,
})
.send(Buffer.from(buffer));
} catch (err: any) {
if (err?.name === 'TimeoutError' || err?.code === 'UND_ERR_CONNECT_TIMEOUT') {
return reply.code(504).send({ error: 'Upstream timed out' });
}
request.log.error({ err, url }, 'Image proxy failed');
return reply.code(502).send({ error: 'Failed to fetch image' });
}
});
}
+185
View File
@@ -0,0 +1,185 @@
import { FastifyInstance } from 'fastify';
import { DbService } from '../services/db.service.js';
export default async function libraryRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
const { dbService } = options;
fastify.get('/tracks', async (request, reply) => {
const query = request.query as any;
const tracks = await dbService.getTracks({
limit: query.limit ? parseInt(query.limit) : undefined,
offset: query.offset ? parseInt(query.offset) : undefined,
sort_by: query.sort_by,
order: query.order,
search: query.search,
});
return tracks;
});
fastify.get('/artists', async (request) => {
const query = request.query as any;
return await dbService.getArtists({
limit: query.limit ? parseInt(query.limit) : undefined,
offset: query.offset ? parseInt(query.offset) : undefined,
});
});
fastify.get('/artists/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const artist = await dbService.getArtistsById(id);
if (!artist) {
return reply.code(404).send({ error: 'Artist not found' });
}
return artist;
});
fastify.get('/artists/:id/similar', async (request, reply) => {
const { id } = request.params as { id: string };
const artist = await dbService.getArtistsById(id);
if (!artist) {
return reply.code(404).send({ error: 'Artist not found' });
}
return await dbService.getSimilarArtists(id);
});
fastify.get('/albums', async (request) => {
const query = request.query as any;
return await dbService.getAlbums({
limit: query.limit ? parseInt(query.limit) : undefined,
offset: query.offset ? parseInt(query.offset) : undefined,
});
});
fastify.get('/albums/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const album = await dbService.getAlbumById(id);
if (!album) {
return reply.code(404).send({ error: 'Album not found' });
}
return album;
});
// Genres
fastify.get('/genres', async () => {
return await dbService.getGenres();
});
fastify.get('/genres/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const genre = await dbService.getGenreById(id);
if (!genre) {
return reply.code(404).send({ error: 'Genre not found' });
}
return genre;
});
fastify.get('/genres/:id/tracks', async (request, reply) => {
const { id } = request.params as { id: string };
const query = request.query as any;
return await dbService.getTracksByGenre(
id,
query.limit ? parseInt(query.limit) : undefined,
query.offset ? parseInt(query.offset) : undefined
);
});
// Favorites
fastify.get('/favorites', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
return await dbService.getFavorites(userId);
});
fastify.post('/favorites/:trackId', async (request, reply) => {
const { trackId } = request.params as { trackId: string };
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
await dbService.addFavorite(userId, trackId);
return reply.send({ status: 'added' });
});
fastify.delete('/favorites/:trackId', async (request, reply) => {
const { trackId } = request.params as { trackId: string };
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
await dbService.removeFavorite(userId, trackId);
return reply.send({ status: 'removed' });
});
// Dislikes
fastify.post('/tracks/:trackId/dislike', async (request, reply) => {
const { trackId } = request.params as { trackId: string };
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
await dbService.dislikeTrack(userId, trackId);
return reply.send({ status: 'disliked' });
});
// Artists CRUD
fastify.post('/artists', async (request, reply) => {
const artist = await dbService.createArtist(request.body as any);
return reply.code(201).send(artist);
});
fastify.put('/artists/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const artist = await dbService.updateArtist(id, request.body as any);
return artist;
});
fastify.delete('/artists/:id', async (request, reply) => {
const { id } = request.params as { id: string };
await dbService.deleteArtist(id);
return reply.send({ status: 'deleted' });
});
// Albums CRUD
fastify.post('/albums', async (request, reply) => {
const album = await dbService.createAlbum(request.body as any);
return reply.code(201).send(album);
});
fastify.put('/albums/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const album = await dbService.updateAlbum(id, request.body as any);
return album;
});
fastify.delete('/albums/:id', async (request, reply) => {
const { id } = request.params as { id: string };
await dbService.deleteAlbum(id);
return reply.send({ status: 'deleted' });
});
// Tracks CRUD
fastify.get('/tracks/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const track = await dbService.getTrackById(id);
if (!track) {
return reply.code(404).send({ error: 'Track not found' });
}
return track;
});
fastify.get('/tracks/:id/lyrics', async (request, reply) => {
const { id } = request.params as { id: string };
const lyrics = await dbService.getTrackLyrics(id);
if (!lyrics) {
return reply.code(404).send({ error: 'No lyrics found' });
}
return lyrics;
});
fastify.post('/tracks', async (request, reply) => {
const track = await dbService.createTrack(request.body as any);
return reply.code(201).send(track);
});
fastify.put('/tracks/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const track = await dbService.updateTrack(id, request.body as any);
return track;
});
fastify.delete('/tracks/:id', async (request, reply) => {
const { id } = request.params as { id: string };
await dbService.deleteTrack(id);
return reply.send({ status: 'deleted' });
});
}
+34
View File
@@ -0,0 +1,34 @@
import { FastifyInstance } from 'fastify';
import { DbService } from '../services/db.service.js';
export default async function quarantineRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
const { dbService } = options;
// List all disliked tracks (HIDDEN + WARNED states)
fastify.get('/dislikes', async () => {
return await dbService.getDislikedTracks();
});
// Restore a disliked track back to LIBRARY
fastify.post('/dislikes/:trackId/restore', async (request, reply) => {
const { trackId } = request.params as { trackId: string };
const entry = await dbService.getDislikeByTrackId(trackId);
if (!entry) {
return reply.code(404).send({ error: 'Dislike record not found' });
}
await dbService.restoreDislike(trackId);
return reply.send({ status: 'restored' });
});
// Hard-delete a disliked track immediately (skips grace period)
fastify.delete('/dislikes/:trackId', async (request, reply) => {
const { trackId } = request.params as { trackId: string };
const entry = await dbService.getDislikeByTrackId(trackId);
if (!entry) {
return reply.code(404).send({ error: 'Dislike record not found' });
}
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
await dbService.permanentlyDeleteTrack(userId, trackId, entry.track_path);
return reply.send({ status: 'deleted' });
});
}
+21
View File
@@ -0,0 +1,21 @@
import { FastifyInstance } from 'fastify';
import { DbService } from '../services/db.service.js';
export default async function searchRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
const { dbService } = options;
fastify.get('/search', async (request, reply) => {
const query = (request.query as any).q;
if (!query) {
return reply.code(400).send({ error: 'Query parameter "q" is required' });
}
try {
// Typesense-first with a Postgres ILIKE fallback (Typesense isn't indexed yet).
return await dbService.searchTracks(String(query));
} catch (error) {
request.log.error(error);
return reply.code(500).send({ error: 'Search failed' });
}
});
}
+53
View File
@@ -0,0 +1,53 @@
import { FastifyInstance } from 'fastify';
import { DbService } from '../services/db.service.js';
const SETTING_KEYS = [
'enrich_metadata',
'enrich_cover_art',
'enrich_genres',
'enrich_lyrics',
'enrich_artist_similarity',
'enrich_audio_analysis',
] as const;
type SettingKey = typeof SETTING_KEYS[number];
export default async function settingsRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
const { dbService } = options;
// GET /api/settings — return all settings as { key: value } map.
fastify.get('/settings', async () => {
const rows = await dbService.pgClient.query('SELECT key, value FROM settings');
const map: Record<string, string> = {};
for (const row of rows.rows) {
map[row.key] = row.value;
}
return map;
});
// PUT /api/settings/:key — update one setting.
// Validates the key against known keys and the value as 'true'/'false'.
fastify.put<{ Params: { key: string }; Body: { value: string } }>(
'/settings/:key',
async (request, reply) => {
const { key } = request.params;
const { value } = request.body;
if (!SETTING_KEYS.includes(key as SettingKey)) {
return reply.code(400).send({ error: `Unknown setting: ${key}` });
}
if (value !== 'true' && value !== 'false') {
return reply.code(400).send({ error: 'Value must be "true" or "false"' });
}
await dbService.pgClient.query(
`INSERT INTO settings (key, value, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
[key, value]
);
return { status: 'ok', key, value };
}
);
}
+140
View File
@@ -0,0 +1,140 @@
import { FastifyInstance } from 'fastify';
import { createReadStream } from 'fs';
import { stat } from 'fs/promises';
import path from 'path';
import { DbService } from '../services/db.service.js';
// Music library root on disk. The worker scanner writes absolute file paths into
// tracks.path rooted here; the backend container must mount the same path so they
// resolve. Path-traversal guard below verifies the resolved file stays inside.
const MUSIC_DIR = path.resolve(process.env.MUSIC_DIR || '/mnt/hdd1/media/Music');
const CONTENT_TYPES: Record<string, string> = {
'.mp3': 'audio/mpeg',
'.flac': 'audio/flac',
'.m4a': 'audio/mp4',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
};
function contentTypeFor(filePath: string): string {
return CONTENT_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
}
// True when resolvedPath is the music root itself or a descendant of it.
function isInsideRoot(resolvedPath: string, root: string): boolean {
return resolvedPath === root || resolvedPath.startsWith(root + path.sep);
}
export default async function streamRoutes(
fastify: FastifyInstance,
options: { dbService: DbService }
) {
const { dbService } = options;
fastify.get('/tracks/:id/stream', async (request, reply) => {
const { id } = request.params as { id: string };
const track = await dbService.getTrackById(id);
if (!track) {
return reply.code(404).send({ error: 'Track not found' });
}
// SECURITY: resolve the path and confirm it stays within MUSIC_DIR. This
// rejects relative paths, symlink-style escapes and any path outside root.
const resolvedPath = path.resolve(track.path);
if (!isInsideRoot(resolvedPath, MUSIC_DIR)) {
return reply.code(403).send({ error: 'Forbidden' });
}
let fileSize: number;
try {
const stats = await stat(resolvedPath);
if (!stats.isFile()) {
return reply.code(404).send({ error: 'File not found' });
}
fileSize = stats.size;
} catch (err: any) {
if (err && err.code === 'ENOENT') {
// File missing on disk; the integrity worker would flag this track MISSING.
return reply.code(404).send({ error: 'File not found on disk' });
}
throw err;
}
const contentType = contentTypeFor(resolvedPath);
const rangeHeader = request.headers.range;
// No Range header: stream the whole file with a 200.
if (!rangeHeader) {
reply
.code(200)
.header('Content-Type', contentType)
.header('Content-Length', fileSize)
.header('Accept-Ranges', 'bytes');
const stream = createReadStream(resolvedPath);
stream.on('error', (err) => {
request.log.error(err);
reply.raw.destroy(err);
});
return reply.send(stream);
}
// Parse "bytes=start-end". Either bound may be omitted.
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
if (!match || (match[1] === '' && match[2] === '')) {
return reply
.code(416)
.header('Content-Range', `bytes */${fileSize}`)
.send({ error: 'Invalid range' });
}
let start: number;
let end: number;
if (match[1] === '') {
// suffix range: last N bytes
const suffixLength = parseInt(match[2], 10);
if (suffixLength <= 0) {
return reply
.code(416)
.header('Content-Range', `bytes */${fileSize}`)
.send({ error: 'Unsatisfiable range' });
}
start = Math.max(fileSize - suffixLength, 0);
end = fileSize - 1;
} else {
start = parseInt(match[1], 10);
end = match[2] === '' ? fileSize - 1 : parseInt(match[2], 10);
}
if (end > fileSize - 1) end = fileSize - 1;
if (
Number.isNaN(start) ||
Number.isNaN(end) ||
start > end ||
start < 0 ||
start >= fileSize
) {
return reply
.code(416)
.header('Content-Range', `bytes */${fileSize}`)
.send({ error: 'Unsatisfiable range' });
}
const chunkSize = end - start + 1;
reply
.code(206)
.header('Content-Type', contentType)
.header('Content-Range', `bytes ${start}-${end}/${fileSize}`)
.header('Accept-Ranges', 'bytes')
.header('Content-Length', chunkSize);
const stream = createReadStream(resolvedPath, { start, end });
stream.on('error', (err) => {
request.log.error(err);
reply.raw.destroy(err);
});
return reply.send(stream);
});
}
+143
View File
@@ -0,0 +1,143 @@
import { FastifyInstance } from 'fastify';
import { createClient, RedisClientType } from 'redis';
import { DbService } from '../services/db.service.js';
import { SessionDirector } from '../services/session-director.service.js';
import { Candidate } from '../services/generators.service.js';
interface ActivePlan {
sessionId: string;
plan: Candidate[];
seedTrackId: string | null;
}
const PLAN_TTL_SEC = 2 * 3600;
function planKey(userId: string): string {
return `v2:plan:${userId}`;
}
export default async function v2Routes(fastify: FastifyInstance, options: { dbService: DbService; sessionDirector: SessionDirector }) {
const { dbService, sessionDirector: director } = options;
const redisClient: RedisClientType = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
});
await redisClient.connect();
fastify.addHook('onClose', async () => { await redisClient.quit(); });
async function getActivePlan(userId: string): Promise<ActivePlan | null> {
const raw = await redisClient.get(planKey(userId));
if (!raw) return null;
return JSON.parse(raw) as ActivePlan;
}
async function setActivePlan(userId: string, plan: ActivePlan): Promise<void> {
await redisClient.setEx(planKey(userId), PLAN_TTL_SEC, JSON.stringify(plan));
}
async function delActivePlan(userId: string): Promise<void> {
await redisClient.del(planKey(userId));
}
/**
* POST /api/v2/vibe/start — start a v2 session
* Body: { seedTrackId? }
* Returns: { sessionId, plan: Candidate[] }
*/
fastify.post('/v2/vibe/start', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const { seedTrackId } = request.body as { seedTrackId?: string };
const sessionId = await dbService.createSessionState(userId, undefined, { energy: 0.5, novelty_hunger: 0.3 });
const plan = await director.buildPlan(userId, sessionId, seedTrackId);
await setActivePlan(userId, { sessionId, plan, seedTrackId: seedTrackId ?? null });
return reply.send({ sessionId, plan: plan.slice(0, 10) });
});
/**
* GET /api/v2/vibe/next — get next track from the plan
* Returns: { track, planRemaining }
*/
fastify.get('/v2/vibe/next', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const active = await getActivePlan(userId);
if (!active || active.plan.length === 0) {
return reply.code(404).send({ error: 'No active plan. POST /api/v2/vibe/start first.' });
}
const next = active.plan.shift()!;
// Enrich with track details
const track = await dbService.getTrackById(next.trackId);
// Replan if running low
if (active.plan.length < 5) {
const refill = await director.replan(userId, active.sessionId, active.plan, [next.trackId], active.seedTrackId ?? undefined);
active.plan.push(...refill);
}
await setActivePlan(userId, active);
return reply.send({ track, explanation: next.explanation, planRemaining: active.plan.length });
});
/**
* POST /api/v2/vibe/feedback — feedback that triggers replan
* Body: { trackId, action: 'completed' | 'skipped' | 'promoted' | 'disliked' }
*/
fastify.post('/v2/vibe/feedback', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const { trackId, action } = request.body as { trackId: string; action: string };
if (!trackId || !action) {
return reply.code(400).send({ error: 'trackId and action are required' });
}
// Route to existing handlers for evidence wiring
if (action === 'completed') {
await dbService.recordPlay(userId, trackId, true);
} else if (action === 'skipped') {
await dbService.recordSkip(userId, trackId);
} else if (action === 'promoted') {
await dbService.addFavorite(userId, trackId);
await dbService.recordFeedback(userId, trackId, 'promoted');
} else if (action === 'disliked') {
await dbService.dislikeTrack(userId, trackId);
}
// Replan the session
const active = await getActivePlan(userId);
if (active) {
const playedTrackIds = [trackId];
const refill = await director.replan(userId, active.sessionId, active.plan, playedTrackIds, active.seedTrackId ?? undefined);
active.plan.push(...refill);
await setActivePlan(userId, active);
}
return reply.send({ status: 'ok', planRemaining: active?.plan.length ?? 0 });
});
/**
* GET /api/v2/vibe/plan — current plan for debugging
*/
fastify.get('/v2/vibe/plan', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const active = await getActivePlan(userId);
if (!active) return reply.send({ plan: [] });
return reply.send({ sessionId: active.sessionId, planRemaining: active.plan.length, plan: active.plan });
});
/**
* GET /api/v2/state — current listener state (debugging)
*/
fastify.get('/v2/state', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const state = await director.buildState(userId);
const fatigue = await director.computeFatigue(userId);
const budgets = await director.getBudgets(userId);
return reply.send({ state, fatigue: Object.fromEntries(
Object.entries(fatigue).map(([k, v]) => [k, v instanceof Map ? Object.fromEntries(v) : v])
), budgets });
});
}
+76
View File
@@ -0,0 +1,76 @@
import { FastifyInstance } from 'fastify';
import { DbService } from '../services/db.service.js';
export default async function vibeRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
const { dbService } = options;
// Start a new vibe session
fastify.post('/start', async (request, reply) => {
const { seedTrackId } = request.body as { seedTrackId: string };
if (!seedTrackId) {
return reply.code(400).send({ error: 'seedTrackId is required' });
}
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const batchId = await dbService.createVibeSession(userId, seedTrackId);
return reply.send({ batchId });
});
// Get the next chunk of tracks for the active session
fastify.get('/next', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const activeSession = await dbService.getActiveVibeSession(userId);
if (!activeSession) {
return reply.code(404).send({ error: 'No active vibe session found' });
}
const tracks = await dbService.getNextVibeChunk(activeSession.batchId);
// Update the session timestamp to keep it alive
await dbService.updateVibeSession(activeSession.batchId);
return tracks;
});
// Start/return a chunk seeded by a genre (id or name) — no active session required.
fastify.get('/from-genre', async (request, reply) => {
const { genre } = request.query as { genre?: string };
if (!genre) {
return reply.code(400).send({ error: 'genre query param is required' });
}
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const tracks = await dbService.getVibeChunkFromGenre(genre, userId);
return tracks;
});
// Current ACTIVE batch metadata for the user, or 404.
fastify.get('/current', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const session = await dbService.getCurrentVibeSession(userId);
if (!session) {
return reply.code(404).send({ error: 'No active vibe session found' });
}
return reply.send(session);
});
// Heartbeat: keep the active batch alive by bumping last_interaction_at.
fastify.post('/heartbeat', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const updated = await dbService.heartbeatVibeSession(userId);
if (!updated) {
return reply.code(404).send({ error: 'No active vibe session found' });
}
return reply.send({ status: 'ok' });
});
// End the vibe session
fastify.post('/end', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const activeSession = await dbService.getActiveVibeSession(userId);
if (activeSession) {
await dbService.endVibeSession(activeSession.batchId);
}
return reply.send({ status: 'session_ended' });
});
}