initial state: muzick music player + recommendation engine
This commit is contained in:
@@ -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' });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user