feat(discovery): acquire recommendations that keep their names

Acquisition ran yt-dlp without --embed-metadata, so every download
arrived untagged. The scanner then stored the video id as the title and
"Unknown Artist" as the artist, the vetted-candidate tag check rejected
the mismatch, and all 18 acquired tracks were hidden and retired.

- Pass --embed-metadata so downloads carry real tags.
- Let a scan take fallback title/artist from the candidate, for sources
  that still ship untagged files.
- Install Deno alongside yt-dlp: YouTube guards some formats with a JS
  challenge yt-dlp must execute, and no other runtime is enabled.
- Dedupe candidates by artist and title. The (source, external_id) key
  misses the same song reaching us under two Deezer release ids.

Also carries the in-flight discovery work this builds on: the
Recommendations page replacing Discover, the discovery source service,
and the acquisition spec tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kami
2026-08-08 18:43:58 +04:00
parent d371bd97f3
commit bfe22745bc
28 changed files with 1241 additions and 221 deletions
+34
View File
@@ -18,6 +18,11 @@ import vibeSessionsRoutes from './routes/vibe-sessions.routes.js';
import discoveryRoutes from './routes/discovery.routes.js';
import imagesRoutes from './routes/images.routes.js';
import { VibeSessionCoordinator } from './services/vibe-session-coordinator.service.js';
import { DiscoveryService } from './services/discovery.service.js';
// Single-listener deployment: the same placeholder the vibe-session routes use
// when no x-user-id header is supplied.
const DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000000';
export interface AppConfig {
port: number;
@@ -91,6 +96,34 @@ export async function buildApp(config: AppConfig) {
dbService.decayBeliefs().catch(() => {});
dbService.deriveForgottenProfile().catch(() => {});
// Close the discovery loop without an operator in it. The worker's cron jobs
// generate candidates; this timer is what decides which of them earn a
// probation slot and enqueues the download. Evaluation is cheap SQL against a
// capped batch, and every acquisition gate (relevance, novelty tolerance,
// backlog, per-artist diversity) lives inside evalCandidates.
const discoveryService = new DiscoveryService(dbService);
const DISCOVERY_EVAL_INTERVAL_MS = 30 * 60 * 1000;
const runDiscoveryEval = async () => {
const results = await discoveryService.evalCandidates(DEFAULT_USER_ID);
let enqueued = 0;
for (const result of results) {
if (!result.shouldAcquire) continue;
try {
await jobService.enqueueDiscoveryAcquisition(result.candidateId);
enqueued++;
} catch (err) {
const reason = err instanceof Error ? err.message : 'failed to enqueue acquisition';
await discoveryService.markEnqueueFailed(result.candidateId, reason);
}
}
if (results.length > 0) {
console.log(`[Discovery] Evaluated ${results.length} candidates, enqueued ${enqueued}`);
}
};
const discoveryEvalTimer = setInterval(() => {
runDiscoveryEval().catch((e) => console.error('[Discovery] eval failed:', e));
}, DISCOVERY_EVAL_INTERVAL_MS);
// Ensure the Typesense 'tracks' collection schema exists on boot so that
// the first search request doesn't hit a 404.
await searchService.ensureCollection();
@@ -176,6 +209,7 @@ export async function buildApp(config: AppConfig) {
clearInterval(fusionTimer);
clearInterval(decayTimer);
clearInterval(forgottenTimer);
clearInterval(discoveryEvalTimer);
} catch (err) {
fastify.log.error(err);
}
+60
View File
@@ -759,4 +759,64 @@ export const MIGRATIONS: Migration[] = [
ON CONFLICT (session_id) DO NOTHING;
`,
},
{
// Two external candidate strategies, kept as separate source_trust keys so
// meta-learning can compare their retention independently: "artists you
// already play just released something" is a much stronger prior than
// "Last.fm thinks this sounds similar", and the trust values say so.
id: '20260806_external_discovery_sources',
sql: `
INSERT INTO source_trust (key, trust, description) VALUES
('new_release', 0.60,
'New release by an artist already played from the local library.'),
('similar_recommendation', 0.45,
'External similarity (Last.fm) seeded from local play history.')
ON CONFLICT (key) DO NOTHING;
`,
},
{
// play_history is the only durable record of what was listened to and when,
// and it had two holes that only show up when you try to read a year back:
//
// 1. ON DELETE CASCADE meant the gated cleanup sweep silently erased the
// plays of every file it removed. A play happened; deleting the file
// later does not un-happen it. The FK becomes SET NULL and the track's
// identity is denormalised onto the row so it stays readable.
// 2. No duration, so listening time was only ever inferable from the
// track's current duration — itself gone once the file is.
id: '20260806_play_history_durable_facts',
sql: `
ALTER TABLE play_history ADD COLUMN IF NOT EXISTS listened_ms INTEGER;
ALTER TABLE play_history ADD COLUMN IF NOT EXISTS track_title TEXT;
ALTER TABLE play_history ADD COLUMN IF NOT EXISTS track_artist TEXT;
DO $$
DECLARE fk_name TEXT;
BEGIN
SELECT con.conname INTO fk_name
FROM pg_constraint con
JOIN pg_class rel ON rel.oid = con.conrelid
JOIN pg_attribute att ON att.attrelid = rel.oid AND att.attnum = con.conkey[1]
WHERE rel.relname = 'play_history'
AND con.contype = 'f'
AND att.attname = 'track_id'
AND con.confdeltype = 'c'
LIMIT 1;
IF fk_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE play_history DROP CONSTRAINT %I', fk_name);
ALTER TABLE play_history
ADD CONSTRAINT play_history_track_id_fkey
FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE SET NULL;
END IF;
END $$;
-- Backfill identity for rows written before the columns existed. Rows whose
-- track was already cascade-deleted are unrecoverable; this at least stops
-- the bleeding from here on.
UPDATE play_history ph
SET track_title = t.title, track_artist = t.artist
FROM tracks t
WHERE t.id = ph.track_id AND ph.track_title IS NULL;
`,
},
];
+9 -1
View File
@@ -302,12 +302,20 @@ CREATE TABLE IF NOT EXISTS recommendation_batch_track (
-- "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.
-- A play is a historical fact: it stays true after the file is gone. Hence
-- ON DELETE SET NULL rather than CASCADE, plus the denormalised title/artist so
-- a row whose track was hard-deleted is still readable. listened_ms is what was
-- actually heard, recorded at play time; tracks.duration is not a substitute
-- because it disappears with the track.
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,
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
batch_id UUID REFERENCES recommendation_batch(id) ON DELETE SET NULL,
completed BOOLEAN NOT NULL DEFAULT false,
listened_ms INTEGER,
track_title TEXT,
track_artist TEXT,
played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
+48
View File
@@ -35,6 +35,54 @@ export default async function discoveryRoutes(fastify: FastifyInstance, options:
return reply.send({ candidates: res.rows });
});
/**
* GET /api/discovery/overview — every recommendation and what became of it.
*
* One row per candidate, acquired or not, so a stalled candidate is as
* visible as a retained track. The play/skip counts come from `evidence`
* rather than tracks.play_count because the probation sweep decides on
* exactly those two signals: showing anything else would be showing a number
* that does not drive the outcome.
*/
fastify.get('/discovery/overview', async (request, reply) => {
const query = request.query as { limit?: string };
const limit = Math.min(Math.max(parseInt(query.limit || '200', 10) || 200, 1), 500);
const rows = await dbService.pgClient.query(
`SELECT dc.id, dc.source, dc.status, dc.title, dc.artist_credit, dc.notes,
dc.first_seen_at, dc.acquired_at, dc.last_error, dc.acquisition_attempts,
t.id AS track_id, t.title AS track_title, t.artist AS track_artist,
t.probation_status, t.probation_entered_at,
(SELECT COUNT(*)::int FROM evidence e
WHERE e.entity_type = 'track' AND e.entity_id = t.id
AND e.signal = 'playback_completed') AS completed_plays,
(SELECT COUNT(*)::int FROM evidence e
WHERE e.entity_type = 'track' AND e.entity_id = t.id
AND e.signal = 'skip_quick') AS quick_skips
FROM discovery_candidates dc
LEFT JOIN tracks t ON t.id = dc.acquired_track_id
ORDER BY COALESCE(dc.acquired_at, dc.first_seen_at) DESC
LIMIT $1`,
[limit]
);
const summary = await dbService.pgClient.query(
`SELECT dc.source,
COUNT(*)::int AS candidates,
COUNT(t.id) FILTER (WHERE t.probation_status = 'probation')::int AS probation,
COUNT(t.id) FILTER (WHERE t.probation_status = 'retained')::int AS retained,
COUNT(t.id) FILTER (WHERE t.probation_status = 'retired')::int AS retired,
COUNT(*) FILTER (WHERE dc.acquired_track_id IS NULL
AND dc.status <> 'candidate')::int AS stalled
FROM discovery_candidates dc
LEFT JOIN tracks t ON t.id = dc.acquired_track_id
GROUP BY dc.source
ORDER BY dc.source`
);
return reply.send({ rows: rows.rows, summary: summary.rows });
});
/**
* POST /api/discovery/eval — evaluate pending candidates for acquisition
*/
+9 -2
View File
@@ -6,16 +6,23 @@ export default async function historyRoutes(fastify: FastifyInstance, options: {
// Record a playback event. completed defaults to false.
fastify.post('/history', async (request, reply) => {
const { trackId, completed, batchId } = request.body as {
const { trackId, completed, batchId, listenedMs } = request.body as {
trackId: string;
completed?: boolean;
batchId?: string;
listenedMs?: number;
};
if (!trackId) {
return reply.code(400).send({ error: 'trackId is required' });
}
// Clamped rather than rejected: a bogus duration must not cost the caller a
// play record, and a 24h ceiling keeps one bad client from dominating any
// listening-time total.
const listened = typeof listenedMs === 'number' && Number.isFinite(listenedMs)
? Math.max(0, Math.min(Math.round(listenedMs), 24 * 60 * 60 * 1000))
: undefined;
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const historyId = await dbService.recordPlay(userId, trackId, completed === true, batchId);
const historyId = await dbService.recordPlay(userId, trackId, completed === true, batchId, listened);
return reply.send({ historyId });
});
+26
View File
@@ -606,6 +606,32 @@ describe('DbService v2 methods', () => {
});
});
describe('recordPlay durability', () => {
it('copies the track identity onto the row and records what was heard', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [{ id: 'history-1' }] });
await service.recordPlay('user-1', 'track-1', false, undefined, 91_000);
const [sql, params] = mockQuery.mock.calls[0];
// A LEFT JOIN, not a plain VALUES: the insert must still happen when the
// track id resolves to nothing.
expect(sql).toContain('LEFT JOIN tracks');
expect(sql).toContain('t.title');
expect(sql).toContain('t.artist');
expect(params).toEqual(['user-1', 'track-1', null, false, 91_000]);
});
it('leaves listened_ms null when the caller has no duration to report', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [{ id: 'history-1' }] });
await service.recordPlay('user-1', 'track-1', false);
expect(mockQuery.mock.calls[0][1][4]).toBeNull();
});
});
describe('upsertClaim', () => {
it('calls INSERT ... ON CONFLICT with correct parameters', async () => {
const { service, mockQuery } = makeService();
+37 -8
View File
@@ -69,6 +69,22 @@ import type {
import { AUDIO_PREFERENCE_BUCKETS, FEEDBACK_ACTIONS } from '../db/types.js';
export * from '../db/types.js';
/**
* Every play_history write goes through this statement.
*
* The title/artist copy is taken in the same round trip via LEFT JOIN, so a
* play stays readable after its track is hard-deleted, and the join never
* suppresses the insert when the track id is unknown.
*/
const PLAY_HISTORY_INSERT = `
INSERT INTO play_history (
user_id, track_id, batch_id, completed, listened_ms, track_title, track_artist
)
SELECT $1::uuid, $2::uuid, $3::uuid, $4::boolean, $5::int, t.title, t.artist
FROM (SELECT 1) AS always
LEFT JOIN tracks t ON t.id = $2::uuid
RETURNING id`;
export class DbService {
/** Exposed so route handlers (e.g. settings) can query the database directly. */
readonly pgClient: Pool;
@@ -483,11 +499,17 @@ export class DbService {
// (permanentlyDeleteTrack, below) and exactly one unlink site in the whole
// system (workers/src/cleanup.service.ts).
async recordPlay(userId: string, trackId: string, completed: boolean, batchId?: string): Promise<string> {
async recordPlay(
userId: string,
trackId: string,
completed: boolean,
batchId?: string,
listenedMs?: number,
): Promise<string> {
if (!completed) {
const res = await this.pgClient.query(
'INSERT INTO play_history (user_id, track_id, batch_id, completed) VALUES ($1, $2, $3, $4) RETURNING id',
[userId, trackId, batchId ?? null, false]
PLAY_HISTORY_INSERT,
[userId, trackId, batchId ?? null, false, listenedMs ?? null]
);
return res.rows[0].id as string;
}
@@ -497,8 +519,8 @@ export class DbService {
return this.withTransaction(async (client) => {
// 1. Record play history
const insertRes = await client.query(
'INSERT INTO play_history (user_id, track_id, batch_id, completed) VALUES ($1, $2, $3, $4) RETURNING id',
[userId, trackId, batchId ?? null, true]
PLAY_HISTORY_INSERT,
[userId, trackId, batchId ?? null, true, listenedMs ?? null]
);
const historyId = insertRes.rows[0].id as string;
@@ -1936,10 +1958,17 @@ export class DbService {
const occurredAt = event.occurred_at?.toISOString?.() ?? new Date().toISOString();
switch (event.type) {
case 'completed':
// The Vibe player is the one caller that knows how much was actually
// heard. position_ms is where playback reached; duration_ms is the
// fallback for a client that only reported the track length.
await client.query(
`INSERT INTO play_history (user_id, track_id, completed, played_at)
VALUES ($1, $2, true, $3::timestamptz)`,
[event.user_id, event.track_id, occurredAt],
`INSERT INTO play_history (
user_id, track_id, completed, played_at, listened_ms, track_title, track_artist
)
SELECT $1::uuid, $2::uuid, true, $3::timestamptz, $4::int, t.title, t.artist
FROM (SELECT 1) AS always
LEFT JOIN tracks t ON t.id = $2::uuid`,
[event.user_id, event.track_id, occurredAt, event.position_ms ?? event.duration_ms ?? null],
);
await client.query(
`UPDATE tracks
+10 -7
View File
@@ -148,17 +148,20 @@ export class DiscoveryService {
const relevance = claimRes.rows[0]?.fused_value ?? 0;
const candidateArtistId = claimRes.rows[0]?.object_id;
// Graph walks identify artists, not a legal/downloadable recording. A
// resolver (or a human) must attach a vetted HTTPS source before the
// worker can acquire anything. Do not guess a search query and download
// an arbitrary track under an artist's name.
// A candidate must name what to fetch: either a vetted HTTPS URL, or a
// track-level search phrase the worker resolves against its allow-listed
// hosts. An artist-only candidate (what a bare graph walk produces) names
// neither, and parks here rather than downloading something arbitrary
// under that artist's name.
const notes = typeof row.notes === 'string' ? safeJson(row.notes) : row.notes;
const resolvedUrl = (notes as { acquisition?: { url?: unknown } } | null)?.acquisition?.url;
if (typeof resolvedUrl !== 'string' || resolvedUrl.trim() === '') {
const acquisition = (notes as { acquisition?: { url?: unknown; query?: unknown } } | null)?.acquisition;
const hasUrl = typeof acquisition?.url === 'string' && acquisition.url.trim() !== '';
const hasQuery = typeof acquisition?.query === 'string' && acquisition.query.trim() !== '';
if (!hasUrl && !hasQuery) {
await this.db.pgClient.query(
`UPDATE discovery_candidates
SET status = 'awaiting_resolution', last_eval_at = NOW(),
last_error = 'no vetted acquisition source attached'
last_error = 'no acquisition url or search query attached'
WHERE id = $1`,
[row.id]
);
+16 -9
View File
@@ -59,9 +59,10 @@ services:
worker:
build:
context: ./workers
# Keep the downloader absent unless an operator intentionally opts in.
# Enabled deliberately: the auto-seed discovery loop cannot acquire a
# candidate without the downloader present.
args:
INSTALL_YTDLP: "false"
INSTALL_YTDLP: "true"
restart: unless-stopped
network_mode: host
environment:
@@ -73,13 +74,19 @@ services:
DISCOGS_TOKEN: ${DISCOGS_TOKEN}
SOCKS_PROXY_URL: ${SOCKS_PROXY_URL}
MUSIC_DIR: /music
# System E acquisition is intentionally disabled by default. To enable it
# an operator must build with INSTALL_YTDLP=true and set all three runtime
# gates below; no acquisition happens merely from graph discovery.
# MUZICK_ACQUISITION_ENABLED: "true"
# MUZICK_ACQUISITION_YTDLP_PATH: /usr/bin/yt-dlp
# MUZICK_ACQUISITION_ALLOWED_HOSTS: example.org
# MUZICK_ACQUISITION_DIR: .recommendations
# System E acquisition, enabled. The allow-list is the only thing deciding
# where audio may come from, and it binds search-resolved URLs too.
# `www.youtube.com` is what yt-dlp reports as webpage_url for a ytsearch
# hit. Narrow this list to shrink the blast radius; empty it to stop
# acquisition without a rebuild.
MUZICK_ACQUISITION_ENABLED: "true"
MUZICK_ACQUISITION_YTDLP_PATH: /usr/bin/yt-dlp
MUZICK_ACQUISITION_ALLOWED_HOSTS: www.youtube.com
MUZICK_ACQUISITION_DIR: .recommendations
#
# Candidate generation crons (defaults shown, both daily).
# NEW_RELEASE_CRON: "30 5 * * *"
# RECOMMENDATION_CRON: "30 6 * * *"
# Hard-deletion gates for the dislike lifecycle (invariant §C). All three
# default to the safe value inside cleanup.service.ts; they are listed here
# as documentation and are intentionally left unset.
+2 -2
View File
@@ -32,7 +32,7 @@ const NAV_COMMANDS: CommandItem[] = [
{ id: 'nav-artists', label: 'Artists', icon: Users, action: () => {}, keywords: ['artists', 'bands'] },
{ id: 'nav-genres', label: 'Genres', icon: Tag, action: () => {}, keywords: ['genres', 'tags', 'categories'] },
{ id: 'nav-vibe', label: 'Vibe', description: 'Endless recommendations', icon: Zap, action: () => {}, keywords: ['vibe', 'recommendations', 'radio'] },
{ id: 'nav-discover', label: 'Discover', description: 'Browse by genre', icon: Compass, action: () => {}, keywords: ['discover', 'explore'] },
{ id: 'nav-recommendations', label: 'Found', description: 'Discovered tracks and their fate', icon: Compass, action: () => {}, keywords: ['found', 'discovery', 'recommendations', 'probation', 'new releases'] },
{ id: 'nav-quarantine', label: 'Quarantine', icon: ShieldAlert, action: () => {}, keywords: ['quarantine', 'disliked', 'trash'] },
{ id: 'nav-jobs', label: 'Jobs', description: 'Background tasks', icon: Terminal, action: () => {}, keywords: ['jobs', 'tasks', 'queue'] },
{ id: 'nav-settings', label: 'Settings', icon: Settings, action: () => {}, keywords: ['settings', 'preferences', 'config'] },
@@ -63,7 +63,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
'nav-artists': '/artists',
'nav-genres': '/genres',
'nav-vibe': '/vibe',
'nav-discover': '/discover',
'nav-recommendations': '/recommendations',
'nav-quarantine': '/quarantine',
'nav-jobs': '/jobs',
'nav-settings': '/settings',
+1 -1
View File
@@ -35,7 +35,7 @@ const NAV_GROUPS: NavGroup[] = [
label: 'AI',
items: [
{ to: '/vibe', icon: Zap, label: 'Vibe' },
{ to: '/discover', icon: Compass, label: 'Discover' },
{ to: '/recommendations', icon: Compass, label: 'Found' },
],
},
{
+1 -1
View File
@@ -17,8 +17,8 @@ const PAGE_TITLES: Record<string, string> = {
'/albums': 'Albums',
'/artists': 'Artists',
'/genres': 'Genres',
'/recommendations': 'Found',
'/vibe': 'Vibe',
'/discover': 'Discover',
'/search': 'Search',
'/settings': 'Settings',
'/quarantine': 'Quarantine',
+40 -4
View File
@@ -22,9 +22,11 @@ export function VibeAura({ profile, ambient = false }: { profile: VibeProfile; a
const energy = clamp(profile.energy, 0.5);
const novelty = clamp(profile.noveltyHunger ?? profile.explorationCoefficient, 0.3);
const { energyLabel, discoveryLabel } = describeProfile(energy, novelty);
// Warm range only (amber → gold → orange). The old 205 base put a teal-blue
// glow in a warm brown room, which read as a different app's accent.
const hue = Math.round(28 + novelty * 30 - energy * 12);
// Hue is discovery alone: ember red when the Vibe stays familiar, gold when it
// reaches. Energy is deliberately absent — it already drives four motion
// channels below, and mixing it in here cancelled half the novelty swing.
// Warm range only; the old 205 base put a teal-blue glow in a warm brown room.
const hue = Math.round(15 + novelty * 40);
const style = {
'--vibe-hue': String(hue),
'--vibe-pulse': `${(4.8 - energy * 2.3).toFixed(2)}s`,
@@ -53,13 +55,47 @@ export function VibeAura({ profile, ambient = false }: { profile: VibeProfile; a
);
if (ambient) {
// Displacing clean gradients by animated fractal noise is what separates a
// plasma from a blurred blob. baseFrequency is animated in SMIL rather than
// CSS because no CSS property reaches inside an SVG filter primitive.
return (
<div
className="vibe-aura-blob pointer-events-none absolute left-1/2 top-[28%] z-0 h-[190px] w-[230px] -translate-x-1/2 -translate-y-1/2 opacity-40 mix-blend-screen sm:h-[400px] sm:w-[480px] sm:opacity-70"
className="vibe-aura-blob pointer-events-none absolute z-0 h-[380px] w-[420px] -translate-x-1/2 -translate-y-1/2 opacity-85 mix-blend-screen sm:h-[560px] sm:w-[660px] sm:opacity-95"
style={style}
role="img"
aria-label={`Current Vibe: ${energyLabel} energy and ${discoveryLabel} discovery`}
>
<svg aria-hidden className="absolute h-0 w-0">
<filter id="vibe-plasma" x="-30%" y="-30%" width="160%" height="160%">
<feTurbulence
type="fractalNoise"
baseFrequency="0.009 0.014"
numOctaves={3}
seed={7}
result="noise"
>
<animate
attributeName="baseFrequency"
dur={`${(18 - energy * 8).toFixed(1)}s`}
values="0.009 0.014; 0.021 0.007; 0.009 0.014"
repeatCount="indefinite"
/>
</feTurbulence>
<feDisplacementMap
in="SourceGraphic"
in2="noise"
scale={String(Math.round(46 + energy * 70))}
xChannelSelector="R"
yChannelSelector="G"
/>
</filter>
</svg>
<div className="vibe-aura-stack">
<div className="vibe-aura-layer vibe-aura-rays-layer" />
<div className="vibe-aura-layer vibe-aura-swirl-layer" />
<div className="vibe-aura-layer vibe-aura-core-layer" />
</div>
</div>
);
}
+76 -16
View File
@@ -244,27 +244,87 @@ html { scroll-behavior: smooth; }
.vibe-aura-spark { width: 4px; height: 4px; animation: vibe-aura-spark 2.1s ease-in-out infinite; }
.vibe-aura-spark-one { left: -4px; top: 5px; }
.vibe-aura-spark-two { right: -3px; bottom: 7px; animation-delay: -1s; }
/* Ambient variant: one soft organic blob of warm light behind the page. The
scaled-up creature read as concentric rings at that size, which fought the
content instead of sitting behind it. Gradient goes on the sized element —
no inset-0 fill layer. */
@keyframes vibe-aura-drift {
from { transform: scale(var(--vibe-scale)) translate3d(-2%, -1%, 0) rotate(-4deg); }
to { transform: scale(calc(var(--vibe-scale) * 1.12)) translate3d(3%, 2%, 0) rotate(5deg); }
}
/* Ambient variant: a plasma in the page background, in the lineage of a media
player visualizer. Three layers — a hot core, a cooler counter-rotating
companion, and a fan of rays — stacked and pushed through one SVG turbulence
displacement pass (see VibeAura.tsx), which is what turns clean gradients
into churning flame. Centering lives on the wrapper, never here: these
keyframes animate `transform` and would overwrite it. */
/* Anchor lives here, not in utility classes: the offsets differ per breakpoint
and the -50% centering pair sits on the same element, so keeping both in one
place is what stops the two from fighting. Off to the right on purpose — the
reading column stays clear and the plasma bleeds past the track rows. */
.vibe-aura-blob {
border-radius: 46% 54% 62% 38% / 55% 43% 57% 45%;
background:
radial-gradient(closest-side at 40% 36%, hsl(var(--vibe-hue) 92% 60% / .55), transparent 72%),
radial-gradient(closest-side at 66% 64%, hsl(calc(var(--vibe-hue) + 28) 86% 52% / .4), transparent 74%);
filter: blur(44px);
animation: vibe-aura-drift var(--vibe-orbit) ease-in-out infinite alternate;
left: 62%;
top: 52%;
}
@media (min-width: 640px) {
.vibe-aura-blob { filter: blur(72px); }
.vibe-aura-blob { left: 80%; top: 56%; }
}
.vibe-aura-stack {
position: absolute;
inset: 0;
filter: url(#vibe-plasma) blur(14px);
}
.vibe-aura-layer {
position: absolute;
inset: 0;
border-radius: 50%;
mix-blend-mode: screen;
}
@keyframes vibe-aura-churn {
0% { transform: scale(var(--vibe-scale)) translate3d(-7%, -4%, 0) rotate(0deg); }
33% { transform: scale(calc(var(--vibe-scale) * 1.22)) translate3d(6%, -8%, 0) rotate(120deg); }
66% { transform: scale(calc(var(--vibe-scale) * 0.9)) translate3d(8%, 7%, 0) rotate(240deg); }
100% { transform: scale(var(--vibe-scale)) translate3d(-7%, -4%, 0) rotate(360deg); }
}
@keyframes vibe-aura-spin {
from { transform: rotate(0deg) scale(var(--vibe-scale)); }
to { transform: rotate(-360deg) scale(var(--vibe-scale)); }
}
@keyframes vibe-aura-breathe {
from { opacity: .55; transform: scale(.86); }
to { opacity: 1; transform: scale(1.12); }
}
/* Hot core — near-white centre falling to the profile hue. */
.vibe-aura-core-layer {
background:
radial-gradient(closest-side at 47% 44%, hsl(calc(var(--vibe-hue) + 24) 100% 82% / .95), hsl(var(--vibe-hue) 100% 58% / .75) 38%, transparent 70%),
radial-gradient(closest-side at 58% 58%, hsl(calc(var(--vibe-hue) - 12) 100% 50% / .8), transparent 66%);
animation: vibe-aura-churn var(--vibe-orbit) ease-in-out infinite;
}
/* Companion — turns the other way so the two shear against each other. */
.vibe-aura-swirl-layer {
background:
radial-gradient(closest-side at 62% 40%, hsl(calc(var(--vibe-hue) - 20) 100% 54% / .7), transparent 68%),
radial-gradient(closest-side at 36% 66%, hsl(calc(var(--vibe-hue) - 44) 96% 50% / .58), transparent 72%);
animation: vibe-aura-churn calc(var(--vibe-orbit) * 1.6) ease-in-out infinite reverse;
}
/* Ray fan — the spikes the displacement pass bends into filaments. Masked to a
ring so the centre stays a clean hot core. */
.vibe-aura-rays-layer {
background: repeating-conic-gradient(
from 0deg,
transparent 0deg 5deg,
hsl(calc(var(--vibe-hue) + 8) 100% 66% / .5) 5deg 7.5deg
);
-webkit-mask-image: radial-gradient(closest-side, transparent 18%, #000 46%, transparent 82%);
mask-image: radial-gradient(closest-side, transparent 18%, #000 46%, transparent 82%);
animation: vibe-aura-spin calc(var(--vibe-orbit) * 2.4) linear infinite;
}
/* Pulse at the profile's tempo. This lives on the stack, not on the outer
.vibe-aura-blob: that element carries the -translate-x-1/2 -translate-y-1/2
centering, and an animation setting `transform` overwrites it, which anchors
the plasma by its top-left corner instead of its middle. */
.vibe-aura-stack {
animation: vibe-aura-breathe var(--vibe-pulse) ease-in-out infinite alternate;
}
@media (min-width: 640px) {
.vibe-aura-stack { filter: url(#vibe-plasma) blur(20px); }
}
@media (prefers-reduced-motion: reduce) {
.vibe-aura-halo, .vibe-aura-core, .vibe-aura-orbit, .vibe-aura-spark, .vibe-aura-blob { animation: none; }
.vibe-aura-halo, .vibe-aura-core, .vibe-aura-orbit, .vibe-aura-spark,
.vibe-aura-stack, .vibe-aura-layer { animation: none; }
}
/* ── Component base classes ────────────────────────────────────────────────── */
-137
View File
@@ -1,137 +0,0 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Disc3, Sparkles, AlertCircle } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import { genreService } from '../services/genreService';
import { startVibeSession } from '../services/vibeSession';
import { TrackRow } from '../components/TrackRow';
import { PageContainer } from '../components/PageContainer';
import { PageHeader } from '../components/PageHeader';
import { EmptyState } from '../components/EmptyState';
import { SkeletonGrid, SkeletonRows } from '../components/LoadingState';
import type { Genre, Track } from '../types';
export default function Discover() {
const [selected, setSelected] = useState<Genre | null>(null);
const [startingVibe, setStartingVibe] = useState(false);
const [vibeError, setVibeError] = useState<string | null>(null);
const navigate = useNavigate();
const genres = useQuery<Genre[]>({
queryKey: ['genres'],
queryFn: () => genreService.listGenres(),
});
const genreTracks = useQuery<Track[]>({
queryKey: ['genre-tracks', selected?.id],
queryFn: () => genreService.getGenreTracks(selected!.id),
enabled: !!selected,
});
const startGenreVibe = async () => {
const tracks = genreTracks.data;
if (!tracks || tracks.length === 0 || startingVibe) return;
const seed = tracks[0];
setStartingVibe(true);
setVibeError(null);
try {
await startVibeSession(seed);
await navigate({ to: '/vibe' });
} catch {
setVibeError('Could not start a Vibe from this genre. Please try again.');
} finally {
setStartingVibe(false);
}
};
return (
<PageContainer>
<PageHeader
title="Discover"
subtitle="Pick a genre, then play it or seed a vibe from it."
meta={genres.data?.length ? `${genres.data.length} genres` : undefined}
/>
{genres.isLoading ? (
<SkeletonGrid count={12} />
) : genres.isError ? (
<EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load genres" subtitle="Something went wrong. Try reloading the page." />
) : (genres.data ?? []).length === 0 ? (
<EmptyState compact icon={<Disc3 size={28} />} title="No genres yet" subtitle="Genres appear after you scan and enrich your library." />
) : (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6">
{(genres.data ?? []).map((genre) => {
const active = selected?.id === genre.id;
return (
<button
key={genre.id}
onClick={() => setSelected(genre)}
className={`flex h-16 flex-col justify-center gap-0.5 rounded-lg border px-3 text-left transition-colors ${
active
? 'border-accent/60 bg-accent/10'
: 'border-border bg-surface0/60 hover:bg-surface1'
}`}
>
<div className={`truncate text-sm font-medium ${active ? 'text-accent' : 'text-text'}`} title={genre.name}>
{genre.name}
</div>
{typeof genre.track_count === 'number' && (
<div className="font-mono text-xs tabular-nums text-machine">
{genre.track_count.toLocaleString()} tracks
</div>
)}
</button>
);
})}
</div>
)}
{selected && (
<section className="space-y-3">
<div className="flex items-center justify-between border-b border-border pb-2">
<h2 className="flex items-baseline gap-2 text-lg font-medium text-text">
{selected.name}
<span className="font-mono text-xs tabular-nums text-machine">
{(genreTracks.data?.length ?? 0).toLocaleString()} tracks
</span>
</h2>
<button
onClick={() => void startGenreVibe()}
disabled={startingVibe || !genreTracks.data || genreTracks.data.length === 0}
className="flex items-center gap-2 rounded-lg border border-accent/60 bg-accent/10 px-3 py-1.5 text-sm font-medium text-accent transition-colors hover:bg-accent/20 disabled:opacity-50"
>
<Sparkles size={16} />
{startingVibe ? 'Starting…' : 'Start a vibe'}
</button>
</div>
{vibeError && (
<p className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
{vibeError}
</p>
)}
{genreTracks.isLoading ? (
<SkeletonRows count={6} />
) : genreTracks.isError ? (
<EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load tracks" subtitle="Something went wrong. Try reloading the page." />
) : (genreTracks.data ?? []).length === 0 ? (
<EmptyState compact icon={<Disc3 size={28} />} title="No tracks in this genre" />
) : (
<div className="track-list">
{(genreTracks.data ?? []).map((track, i) => (
<TrackRow
key={`${track.id}-${i}`}
track={track}
queue={genreTracks.data ?? []}
index={i}
showActions={false}
/>
))}
</div>
)}
</section>
)}
</PageContainer>
);
}
+239
View File
@@ -0,0 +1,239 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AlertCircle, Compass } from 'lucide-react';
import { discoveryService } from '../services/discoveryService';
import type { DiscoveryRow, DiscoverySourceSummary } from '../services/discoveryService';
import { Badge } from '../components/ethos/Badge';
import { PageContainer } from '../components/PageContainer';
import { PageHeader } from '../components/PageHeader';
import { EmptyState } from '../components/EmptyState';
import { SkeletonRows } from '../components/LoadingState';
// Mirrors the probation sweep in workers/src/index.ts. Shown rather than hidden:
// a track on probation is N plays away from staying, and the operator should be
// able to see exactly how far. If the sweep's thresholds change, change these.
const KEEP_AFTER_PLAYS = 3;
const DROP_AFTER_SKIPS = 3;
type Filter = 'all' | 'probation' | 'retained' | 'retired' | 'stalled';
const FILTERS: { id: Filter; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'probation', label: 'On probation' },
{ id: 'retained', label: 'Kept' },
{ id: 'retired', label: 'Removed' },
{ id: 'stalled', label: 'Never arrived' },
];
/** A candidate that was evaluated but never became a track. */
const isStalled = (row: DiscoveryRow) => !row.track_id && row.status !== 'candidate';
function matches(row: DiscoveryRow, filter: Filter): boolean {
if (filter === 'all') return true;
if (filter === 'stalled') return isStalled(row);
return row.probation_status === filter;
}
function sourceLabel(source: string): string {
if (source === 'new_release') return 'New release';
if (source === 'similar_recommendation') return 'Similar';
if (source === 'graph_exploration') return 'Graph';
return source;
}
function statusBadge(row: DiscoveryRow) {
if (row.probation_status === 'retained') return <Badge color="green">Kept</Badge>;
if (row.probation_status === 'retired') return <Badge color="neutral">Removed</Badge>;
if (row.probation_status === 'probation') return <Badge color="amber" dot>On probation</Badge>;
if (isStalled(row)) return <Badge color="red">{row.status.replace(/_/g, ' ')}</Badge>;
return <Badge color="neutral">Queued</Badge>;
}
function shortDate(value: string | null): string {
if (!value) return '—';
return new Date(value).toISOString().slice(0, 10);
}
/** Where this candidate came from, in one line, without a second request. */
function provenance(row: DiscoveryRow): string {
const notes = row.notes ?? {};
if (notes.album) return `from ${notes.album}`;
if (notes.seed_artist) return `because you play ${notes.seed_artist}`;
return sourceLabel(row.source);
}
function displayName(row: DiscoveryRow): { title: string; artist: string } {
const credited = row.artist_credit?.[0]?.name ?? '';
return {
title: row.track_title ?? row.title ?? 'Untitled',
artist: row.track_artist ?? credited,
};
}
function SummaryStrip({ summary }: { summary: DiscoverySourceSummary[] }) {
if (summary.length === 0) return null;
return (
<div className="grid gap-2 sm:grid-cols-2">
{summary.map((s) => (
<div key={s.source} className="rounded-lg border border-border bg-surface0/40 p-3">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm font-medium text-text">{sourceLabel(s.source)}</span>
<span className="flex-none font-mono text-xs tabular-nums text-machine">
{s.candidates} found
</span>
</div>
<dl className="mt-2 flex flex-wrap gap-x-4 gap-y-1 font-mono text-xs tabular-nums">
<div className="flex gap-1.5">
<dt className="font-sans text-secondary">kept</dt>
<dd className="text-green">{s.retained}</dd>
</div>
<div className="flex gap-1.5">
<dt className="font-sans text-secondary">trying</dt>
<dd className="text-amber">{s.probation}</dd>
</div>
<div className="flex gap-1.5">
<dt className="font-sans text-secondary">removed</dt>
<dd className="text-machine">{s.retired}</dd>
</div>
<div className="flex gap-1.5">
<dt className="font-sans text-secondary">never arrived</dt>
<dd className={s.stalled > 0 ? 'text-red' : 'text-machine'}>{s.stalled}</dd>
</div>
</dl>
</div>
))}
</div>
);
}
export default function Recommendations() {
const [filter, setFilter] = useState<Filter>('all');
const { data, isLoading, isError } = useQuery({
queryKey: ['discovery-overview'],
queryFn: () => discoveryService.overview(),
refetchInterval: 60_000,
});
const rows = data?.rows ?? [];
const visible = useMemo(() => rows.filter((row) => matches(row, filter)), [rows, filter]);
const totals = data?.summary.reduce(
(acc, s) => ({
candidates: acc.candidates + s.candidates,
retained: acc.retained + s.retained,
probation: acc.probation + s.probation,
}),
{ candidates: 0, retained: 0, probation: 0 }
);
return (
<PageContainer>
<PageHeader
title="Recommendations"
subtitle="What discovery brought in, and what your skips did with it."
meta={
totals
? `${totals.candidates} found · ${totals.retained} kept · ${totals.probation} on probation`
: undefined
}
/>
{isLoading ? (
<SkeletonRows count={5} />
) : isError ? (
<EmptyState
compact
icon={<AlertCircle size={28} />}
title="Couldn't load recommendations"
subtitle="Something went wrong. Try reloading the page."
/>
) : rows.length === 0 ? (
<EmptyState
icon={<Compass size={28} />}
title="Nothing discovered yet"
subtitle="The new-release and similarity scans run daily. Acquisition also has to be enabled on the worker before a candidate can become a track."
/>
) : (
<>
<SummaryStrip summary={data?.summary ?? []} />
<div className="flex flex-wrap gap-1.5">
{FILTERS.map((f) => {
const count = rows.filter((row) => matches(row, f.id)).length;
const active = filter === f.id;
return (
<button
key={f.id}
onClick={() => setFilter(f.id)}
className={`flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition-colors ${
active
? 'border-accent/60 bg-accent/10 text-accent'
: 'border-border text-secondary hover:bg-surface1'
}`}
>
{f.label}
<span className="font-mono tabular-nums text-[11px] text-machine">{count}</span>
</button>
);
})}
</div>
{visible.length === 0 ? (
<EmptyState compact icon={<Compass size={28} />} title="Nothing in this state" />
) : (
<ul className="track-list rounded-lg border border-border bg-surface0/40 px-1.5">
{visible.map((row) => {
const { title, artist } = displayName(row);
const plays = row.completed_plays ?? 0;
const skips = row.quick_skips ?? 0;
// Stacked below 640px: at that width the name, the badge and the
// mono readout cannot share a row without the title collapsing
// to two characters.
return (
<li
key={row.id}
className="flex flex-col gap-1 py-2 sm:min-h-14 sm:flex-row sm:flex-wrap sm:items-center sm:gap-2.5"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-text">{title}</span>
{statusBadge(row)}
</div>
<div className="truncate text-xs text-secondary">
{artist ? `${artist} · ` : ''}
{provenance(row)}
</div>
{row.last_error && (
<div className="mt-0.5 truncate font-mono text-[11px] text-red" title={row.last_error}>
{row.last_error}
</div>
)}
</div>
<div className="flex items-center gap-3 font-mono text-xs tabular-nums text-machine sm:flex-none">
{row.probation_status === 'probation' ? (
<span title="Completed plays that keep it, quick skips that drop it">
{plays}/{KEEP_AFTER_PLAYS} plays · {skips}/{DROP_AFTER_SKIPS} skips
</span>
) : row.track_id ? (
<span title="Completed plays · quick skips">
{plays} plays · {skips} skips
</span>
) : (
<span title="Acquisition attempts">
{row.acquisition_attempts} attempts
</span>
)}
<span className="text-muted" title="Acquired, or first seen">
{shortDate(row.acquired_at ?? row.first_seen_at)}
</span>
</div>
</li>
);
})}
</ul>
)}
</>
)}
</PageContainer>
);
}
+5 -5
View File
@@ -13,7 +13,7 @@ import AlbumDetail from './pages/AlbumDetail';
import Tracks from './pages/Tracks';
import Genres from './pages/Genres';
import Vibe from './pages/Vibe';
import Discover from './pages/Discover';
import Recommendations from './pages/Recommendations';
import Search from './pages/Search';
import Settings from './pages/Settings';
import Quarantine from './pages/Quarantine';
@@ -73,10 +73,10 @@ export const vibeRoute = createRoute({
component: Vibe,
});
export const discoverRoute = createRoute({
export const recommendationsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/discover',
component: Discover,
path: '/recommendations',
component: Recommendations,
});
export const searchRoute = createRoute({
@@ -115,7 +115,7 @@ const routeTree = rootRoute.addChildren([
tracksRoute,
genresRoute,
vibeRoute,
discoverRoute,
recommendationsRoute,
searchRoute,
settingsRoute,
quarantineRoute,
+41
View File
@@ -0,0 +1,41 @@
import api from './api';
/** One candidate and whatever became of it. `track_id` is null until acquired. */
export interface DiscoveryRow {
id: string;
source: string;
status: string;
title: string | null;
artist_credit: { name?: string; artist_id?: string }[] | null;
notes: { acquisition?: { query?: string; url?: string }; album?: string; seed_artist?: string; seed_title?: string } | null;
first_seen_at: string;
acquired_at: string | null;
last_error: string | null;
acquisition_attempts: number;
track_id: string | null;
track_title: string | null;
track_artist: string | null;
probation_status: 'probation' | 'retained' | 'retired' | null;
probation_entered_at: string | null;
completed_plays: number | null;
quick_skips: number | null;
}
export interface DiscoverySourceSummary {
source: string;
candidates: number;
probation: number;
retained: number;
retired: number;
stalled: number;
}
export const discoveryService = {
// GET /api/discovery/overview -> every candidate plus per-source totals
async overview(): Promise<{ rows: DiscoveryRow[]; summary: DiscoverySourceSummary[] }> {
const res = await api.get<{ rows: DiscoveryRow[]; summary: DiscoverySourceSummary[] }>(
'/discovery/overview'
);
return res.data;
},
};
+4 -2
View File
@@ -2,16 +2,18 @@ import api from './api';
import type { FeedbackAction, HistoryEntry } from '../types';
export const historyService = {
// POST /api/history { trackId, completed?, batchId? } -> { historyId }
// POST /api/history { trackId, completed?, batchId?, listenedMs? } -> { historyId }
async recordPlay(
trackId: string,
completed?: boolean,
batchId?: string
batchId?: string,
listenedMs?: number
): Promise<{ historyId: string }> {
const res = await api.post<{ historyId: string }>('/history', {
trackId,
completed,
batchId,
listenedMs,
});
return res.data;
},
+15 -2
View File
@@ -2,8 +2,21 @@ FROM node:20-slim
# yt-dlp is intentionally opt-in. Enabling its image build alone does not make
# acquisition live: the worker additionally requires explicit runtime gates.
ARG INSTALL_YTDLP=false
RUN apt-get update && apt-get install -y ffmpeg \
&& if [ "$INSTALL_YTDLP" = "true" ]; then apt-get install -y yt-dlp; fi \
# Debian's yt-dlp package lags years behind and fails on current YouTube, so
# take the self-contained upstream binary instead. It bundles its own Python.
# ponytail: tracks latest; pin the tag here if a release ever breaks the loop.
RUN apt-get update && apt-get install -y ffmpeg curl unzip \
&& if [ "$INSTALL_YTDLP" = "true" ]; then \
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux \
-o /usr/bin/yt-dlp \
&& chmod 755 /usr/bin/yt-dlp \
&& /usr/bin/yt-dlp --version \
# YouTube guards some formats with an obfuscated JS challenge that
# yt-dlp must execute. Deno is the only runtime it enables by default,
# and without one those videos fail extraction.
&& curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- --yes \
&& deno --version; \
fi \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
+40
View File
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseAcquisitionSpec, validateAcquisitionUrl } from './acquisition.service.js';
const HOSTS = new Set(['www.youtube.com']);
test('accepts an allow-listed HTTPS url', () => {
const spec = parseAcquisitionSpec(
{ acquisition: { url: 'https://www.youtube.com/watch?v=abc', expectedTitle: 'T', expectedArtist: 'A' } },
HOSTS
);
assert.equal(spec.url, 'https://www.youtube.com/watch?v=abc');
assert.equal(spec.query, undefined);
assert.equal(spec.expectedArtist, 'A');
});
test('accepts a search query and strips newlines that could forge print output', () => {
const spec = parseAcquisitionSpec(
{ acquisition: { query: 'Boards of Canada\nRoygbiv', expectedTitle: 'Roygbiv' } },
HOSTS
);
assert.equal(spec.query, 'Boards of Canada Roygbiv');
assert.equal(spec.url, undefined);
});
test('rejects a candidate naming neither a url nor a query', () => {
assert.throws(() => parseAcquisitionSpec({ acquisition: { expectedTitle: 'T' } }, HOSTS), /url or a query/);
assert.throws(() => parseAcquisitionSpec({ acquisition: { query: ' ' } }, HOSTS), /url or a query/);
assert.throws(() => parseAcquisitionSpec({}, HOSTS), /no resolved acquisition source/);
});
test('search-resolved urls face the same gates as supplied ones', () => {
assert.throws(() => validateAcquisitionUrl('https://evil.example/x', HOSTS), /not allow-listed/);
assert.throws(() => validateAcquisitionUrl('http://www.youtube.com/x', HOSTS), /must use HTTPS/);
assert.throws(() => validateAcquisitionUrl('https://u:p@www.youtube.com/x', HOSTS), /must not contain credentials/);
assert.equal(
validateAcquisitionUrl('https://WWW.YouTube.com/watch?v=1', HOSTS),
'https://www.youtube.com/watch?v=1'
);
});
+83 -15
View File
@@ -13,7 +13,10 @@ type CandidateRow = {
};
type AcquisitionSpec = {
url: string;
/** A vetted, allow-listed HTTPS URL. Mutually exclusive with `query`. */
url?: string;
/** A search phrase to resolve into such a URL at download time. */
query?: string;
expectedTitle?: string;
expectedArtist?: string;
};
@@ -72,18 +75,17 @@ function matchesExpected(actual: string, expected: string | undefined): boolean
return left === right || left.includes(right) || right.includes(left);
}
/** Parse only a deliberately supplied HTTPS source URL; never accept argv/query strings. */
export function parseAcquisitionSpec(notes: unknown, allowedHosts: Set<string>): AcquisitionSpec {
const notesValue = typeof notes === 'string' ? JSON.parse(notes) : notes;
const candidate = (notesValue as { acquisition?: unknown } | null)?.acquisition;
if (!candidate || typeof candidate !== 'object') {
throw new Error('candidate has no resolved acquisition source');
}
const { url, expectedTitle, expectedArtist } = candidate as Record<string, unknown>;
if (typeof url !== 'string') throw new Error('acquisition source URL is required');
/**
* Every URL that reaches the downloader passes through here: HTTPS only, no
* embedded credentials, host on the allow-list. Applied to operator-supplied
* URLs and to search-resolved ones alike, so a search cannot widen the hosts a
* download may come from.
*/
export function validateAcquisitionUrl(raw: unknown, allowedHosts: Set<string>): string {
if (typeof raw !== 'string') throw new Error('acquisition source URL is required');
let parsed: URL;
try {
parsed = new URL(url);
parsed = new URL(raw);
} catch {
throw new Error('acquisition source URL is invalid');
}
@@ -92,11 +94,41 @@ export function parseAcquisitionSpec(notes: unknown, allowedHosts: Set<string>):
if (!allowedHosts.has(parsed.hostname.toLowerCase())) {
throw new Error(`acquisition host is not allow-listed: ${parsed.hostname}`);
}
return {
url: parsed.toString(),
return parsed.toString();
}
/**
* Parse either a vetted HTTPS URL or a search phrase to resolve into one.
*
* The search form is what automated discovery emits: a graph walk or a
* similarity lookup names an artist and a title, never a downloadable file. A
* resolved search result is trusted no further than an operator-supplied URL —
* same host allow-list, and `matchesExpected` still rejects the download if the
* file's tags disagree with the candidate.
*/
export function parseAcquisitionSpec(notes: unknown, allowedHosts: Set<string>): AcquisitionSpec {
const notesValue = typeof notes === 'string' ? JSON.parse(notes) : notes;
const candidate = (notesValue as { acquisition?: unknown } | null)?.acquisition;
if (!candidate || typeof candidate !== 'object') {
throw new Error('candidate has no resolved acquisition source');
}
const { url, query, expectedTitle, expectedArtist } = candidate as Record<string, unknown>;
const expected = {
expectedTitle: typeof expectedTitle === 'string' ? expectedTitle.slice(0, 500) : undefined,
expectedArtist: typeof expectedArtist === 'string' ? expectedArtist.slice(0, 500) : undefined,
};
if (url !== undefined) {
return { url: validateAcquisitionUrl(url, allowedHosts), ...expected };
}
if (typeof query === 'string' && query.trim() !== '') {
// Newlines would let a crafted candidate forge extra --print output lines
// when the resolver parses stdout.
const cleaned = query.replace(/[\r\n]+/g, ' ').trim().slice(0, 300);
if (cleaned === '') throw new Error('acquisition search query is empty');
return { query: cleaned, ...expected };
}
throw new Error('acquisition source needs either a url or a query');
}
async function runDownloader(executable: string, args: string[], timeoutMs: number): Promise<string> {
@@ -153,6 +185,24 @@ export class AcquisitionService {
);
}
/**
* Turn a search phrase into one allow-listed URL, without downloading.
*
* ponytail: first search result only. Ranking alternatives needs a quality
* signal this system does not have, and the tag check downstream already
* rejects a wrong hit. Revisit if mismatches become common.
*/
private async resolveQueryToUrl(query: string): Promise<string> {
const stdout = await runDownloader(this.config.ytDlpPath, [
'--no-playlist', '--no-progress', '--skip-download',
'--print', 'webpage_url',
'--', `ytsearch1:${query}`,
], this.config.timeoutMs);
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
if (lines.length !== 1) throw new Error('search did not resolve to exactly one result');
return validateAcquisitionUrl(lines[0], this.config.allowedHosts);
}
async acquire(candidateId: string): Promise<AcquisitionResult> {
const rowResult = await this.pgPool.query<CandidateRow>(
`SELECT id, source, notes, status FROM discovery_candidates WHERE id = $1`, [candidateId]
@@ -212,13 +262,19 @@ export class AcquisitionService {
// Arguments are fixed by us. The sole untrusted value is the validated URL
// and spawn() is invoked with shell:false, so no command interpolation is
// possible. One URL / one item is deliberate: playlists are out of scope.
const sourceUrl = spec.url ?? await this.resolveQueryToUrl(spec.query as string);
const outputTemplate = path.join(candidateDir, '%(id)s.%(ext)s');
const stdout = await runDownloader(this.config.ytDlpPath, [
'--no-playlist', '--no-progress', '--restrict-filenames',
'--extract-audio', '--audio-format', 'mp3', '--audio-quality', '5',
// Without this the mp3 carries no tags at all, the scanner falls back to
// "<video id>.mp3" / "Unknown Artist", and the tag check below rejects
// every download.
'--embed-metadata',
'--output', outputTemplate,
'--print', 'after_move:filepath',
'--', spec.url,
'--', sourceUrl,
], this.config.timeoutMs);
const reportedPaths = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
if (reportedPaths.length !== 1) throw new Error('downloader did not report exactly one output file');
@@ -235,6 +291,11 @@ export class AcquisitionService {
await this.setStatus(candidate.id, 'scanning', null);
const scan = await this.scanner.scanDirectory(candidateDir, {
sourceType: 'RECOMMENDATION', probationStatus: 'probation', candidateId: candidate.id,
// A source that embeds no tags leaves nothing to verify against. The
// candidate's own vetted names are then both the display names and
// what the check below compares, so the download is accepted and the
// track stays on probation, where listening decides its fate.
fallbackTitle: spec.expectedTitle, fallbackArtist: spec.expectedArtist,
});
if (scan.trackIds.length !== 1) {
throw new Error(`scanner created ${scan.trackIds.length} tracks; expected exactly one`);
@@ -270,7 +331,14 @@ export class AcquisitionService {
ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
DO UPDATE SET confidence = EXCLUDED.confidence,
last_reinforced_at = NOW(), raw = EXCLUDED.raw`,
[trackId, candidate.id, candidate.source, JSON.stringify({ expectedTitle: spec.expectedTitle, expectedArtist: spec.expectedArtist })]
[trackId, candidate.id, candidate.source, JSON.stringify({
expectedTitle: spec.expectedTitle,
expectedArtist: spec.expectedArtist,
// Which URL a search actually landed on is the only way to audit a
// bad acquisition after the fact.
sourceUrl,
resolvedFromQuery: spec.query ?? null,
})]
);
await client.query('COMMIT');
} catch (err) {
+288
View File
@@ -0,0 +1,288 @@
// External discovery sources: where recommendation candidates come from.
//
// Two deliberately separate strategies, one per source_trust key:
//
// new_release — an artist you actually play released something new.
// Seeded from local play counts, resolved via Deezer.
// similar_recommendation — Last.fm's "sounds like" neighbours of the tracks
// you play most. Weaker prior, wider reach.
//
// Both write track-level rows into discovery_candidates carrying a search spec
// (artist + title) rather than a URL: the acquisition worker resolves that to a
// concrete allow-listed URL at download time. Nothing here touches the
// filesystem or the player; a candidate is only ever an intent to try a track.
import type { Pool } from 'pg';
import { DeezerClient, LastFmClient } from './integrations/index.js';
/** A track we might want to try, before any acquisition source is resolved. */
interface CandidateSeed {
source: 'new_release' | 'similar_recommendation';
/** Stable dedup key within the source. */
externalId: string;
title: string;
artist: string;
/** Local artist this candidate hangs off: the releasing or the seed artist. */
relatedArtistId: string;
/** 0..1 prior on this candidate being worth a probation slot. */
relevance: number;
raw: Record<string, unknown>;
}
interface TopArtist {
id: string;
name: string;
plays: number;
}
interface TopTrack {
title: string;
artist: string;
artistId: string;
plays: number;
}
export interface DiscoverySourcesResult {
considered: number;
inserted: number;
}
const NEW_RELEASE_WINDOW_DAYS = 120;
export class DiscoverySourcesService {
constructor(
private readonly pgPool: Pool,
private readonly deezer = new DeezerClient(),
private readonly lastfm = new LastFmClient(),
) {}
// ---------------------------------------------------------------
// Shared plumbing
// ---------------------------------------------------------------
/** Artists ranked by how much of their local catalog actually gets played. */
private async topArtists(limit: number): Promise<TopArtist[]> {
const res = await this.pgPool.query<{ id: string; name: string; plays: string }>(
`SELECT a.id, COALESCE(a.canonical_name, a.name) AS name, SUM(t.play_count)::text AS plays
FROM tracks t
JOIN albums al ON al.id = t.album_id
JOIN artists a ON a.id = al.artist_id
WHERE t.state = 'LIBRARY' AND t.deleted_at IS NULL AND t.play_count > 0
GROUP BY a.id, COALESCE(a.canonical_name, a.name)
ORDER BY SUM(t.play_count) DESC
LIMIT $1`,
[limit]
);
return res.rows.map((r) => ({ id: r.id, name: r.name, plays: Number(r.plays) }));
}
/** Most-played individual tracks — the seeds similarity lookups start from. */
private async topTracks(limit: number): Promise<TopTrack[]> {
const res = await this.pgPool.query<{ title: string; artist: string; artist_id: string; play_count: number }>(
`SELECT t.title, t.artist, al.artist_id, t.play_count
FROM tracks t
JOIN albums al ON al.id = t.album_id
WHERE t.state = 'LIBRARY' AND t.deleted_at IS NULL
AND t.play_count > 0 AND al.artist_id IS NOT NULL
ORDER BY t.play_count DESC
LIMIT $1`,
[limit]
);
return res.rows.map((r) => ({
title: r.title,
artist: r.artist,
artistId: r.artist_id,
plays: r.play_count,
}));
}
/**
* True when this artist+title is already a local track, in any state.
*
* Any state is deliberate: a retired or quarantined track must not be
* re-acquired, otherwise every sweep re-downloads what the listener already
* skipped away.
*/
private async alreadyKnown(artist: string, title: string): Promise<boolean> {
const res = await this.pgPool.query<{ exists: boolean }>(
`SELECT EXISTS (
SELECT 1 FROM tracks
WHERE lower(artist) = lower($1) AND lower(title) = lower($2)
) AS exists`,
[artist, title]
);
return res.rows[0]?.exists ?? false;
}
/**
* Insert a candidate plus the relevance claim `evalCandidates` reads.
*
* The claim's object is the local artist the candidate came from. For a new
* release that is the releasing artist; for a similarity hit it is the seed.
* Either way it is what the existing per-artist diversity cap should count.
*/
private async insertCandidate(seed: CandidateSeed): Promise<boolean> {
// The (source, external_id) unique key does not catch the same song reaching
// us twice under different ids — a Deezer single and the album that carries
// it, or a Last.fm hit for something a new release already proposed. Name
// equality does, in any candidate state, for the same reason alreadyKnown
// ignores track state.
const duplicate = await this.pgPool.query<{ exists: boolean }>(
`SELECT EXISTS (
SELECT 1 FROM discovery_candidates
WHERE lower(title) = lower($1)
AND lower(artist_credit->0->>'name') = lower($2)
) AS exists`,
[seed.title, seed.artist]
);
if (duplicate.rows[0]?.exists) return false;
const notes = {
discovery_source: seed.source,
...seed.raw,
// The acquisition worker turns this into a concrete URL. No URL is
// recorded here because nothing has vetted one yet.
acquisition: {
query: `${seed.artist} ${seed.title}`,
expectedTitle: seed.title,
expectedArtist: seed.artist,
},
};
const dcRes = await this.pgPool.query<{ id: string }>(
`INSERT INTO discovery_candidates (source, external_id, title, artist_credit, notes)
VALUES ($1, $2, $3, $4::jsonb, $5::jsonb)
ON CONFLICT (source, external_id) DO NOTHING
RETURNING id`,
[
seed.source,
seed.externalId,
seed.title,
JSON.stringify([{ name: seed.artist, artist_id: seed.relatedArtistId }]),
JSON.stringify(notes),
]
);
if (dcRes.rows.length === 0) return false;
await this.pgPool.query(
`INSERT INTO claims (
subject_type, subject_id, predicate, object_type, object_id,
source, confidence, raw
) VALUES ('discovery_candidate', $1::uuid, 'discovery_candidate', 'artist', $2::uuid,
$3, $4, $5::jsonb)
ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
DO UPDATE SET confidence = EXCLUDED.confidence, last_reinforced_at = NOW()`,
[
dcRes.rows[0].id,
seed.relatedArtistId,
seed.source,
Math.max(0, Math.min(1, seed.relevance)),
JSON.stringify({ discovery_source: seed.source, ...seed.raw }),
]
);
return true;
}
// ---------------------------------------------------------------
// new_release
// ---------------------------------------------------------------
/**
* For the `artistLimit` most-played local artists, find releases from the
* last NEW_RELEASE_WINDOW_DAYS whose tracks we do not own.
*
* ponytail: `tracksPerAlbum` tracks per new album, not the whole thing. A
* probation slot per track is the expensive resource, and one representative
* track is enough to learn whether the release lands. Raise it if retention
* on this source turns out high.
*/
async discoverNewReleases(artistLimit = 15, tracksPerAlbum = 2): Promise<DiscoverySourcesResult> {
const since = new Date(Date.now() - NEW_RELEASE_WINDOW_DAYS * 86_400_000)
.toISOString()
.slice(0, 10);
let considered = 0;
let inserted = 0;
for (const artist of await this.topArtists(artistLimit)) {
const albums = await this.deezer.getArtistAlbumsSince(artist.name, since);
for (const album of albums) {
const titles = await this.deezer.getAlbumTracks(album.id);
let takenFromAlbum = 0;
for (const title of titles) {
if (takenFromAlbum >= tracksPerAlbum) break;
considered++;
if (await this.alreadyKnown(artist.name, title)) continue;
const ok = await this.insertCandidate({
source: 'new_release',
externalId: `deezer:${album.id}:${title.toLowerCase()}`,
title,
artist: artist.name,
relatedArtistId: artist.id,
// A new release by someone already in heavy rotation is the
// strongest prior this system has short of an explicit request.
relevance: 0.75,
raw: {
album: album.title,
release_date: album.releaseDate,
record_type: album.recordType,
seed_artist_plays: artist.plays,
},
});
if (ok) {
inserted++;
takenFromAlbum++;
}
}
}
}
return { considered, inserted };
}
// ---------------------------------------------------------------
// similar_recommendation
// ---------------------------------------------------------------
/**
* Ask Last.fm what sounds like the tracks played most, and keep the hits by
* artists other than the seed's — a different track by the same artist is a
* library gap, not a discovery.
*/
async discoverRecommendations(seedLimit = 10, perSeed = 5): Promise<DiscoverySourcesResult> {
let considered = 0;
let inserted = 0;
for (const seed of await this.topTracks(seedLimit)) {
const similar = await this.lastfm.getSimilarTracks(seed.artist, seed.title, perSeed * 4);
let takenFromSeed = 0;
for (const hit of similar) {
if (takenFromSeed >= perSeed) break;
considered++;
if (hit.artist.toLowerCase() === seed.artist.toLowerCase()) continue;
if (await this.alreadyKnown(hit.artist, hit.name)) continue;
const ok = await this.insertCandidate({
source: 'similar_recommendation',
externalId: `lastfm:${hit.artist.toLowerCase()}:${hit.name.toLowerCase()}`,
title: hit.name,
artist: hit.artist,
relatedArtistId: seed.artistId,
// Last.fm's own match score, floored so a weak-but-present match
// still clears the acquisition gate's relevance > 0.3.
relevance: Math.max(0.35, Math.min(0.7, hit.match)),
raw: {
seed_artist: seed.artist,
seed_title: seed.title,
seed_plays: seed.plays,
lastfm_match: hit.match,
},
});
if (ok) {
inserted++;
takenFromSeed++;
}
}
}
return { considered, inserted };
}
}
+54 -2
View File
@@ -1,6 +1,6 @@
import { Worker, Job } from 'bullmq';
import { connection, QUEUE_NAME, queue } from './queue.js';
import { MetadataRefreshJob, AudioAnalysisJob, AudioAnalysisSweepJob, LibraryScanJob, ArtistSimilarityJob, ArtistImageJob, AlbumCoverJob, ReprocessArtistsJob, AcquisitionJob } from './types.js';
import { MetadataRefreshJob, AudioAnalysisJob, AudioAnalysisSweepJob, LibraryScanJob, ArtistSimilarityJob, ArtistImageJob, AlbumCoverJob, ReprocessArtistsJob, AcquisitionJob, DiscoverySourceJob } from './types.js';
import { Pool } from 'pg';
import { ScannerService } from './scanner.service.js';
import { IntegrityService } from './integrity.service.js';
@@ -9,6 +9,7 @@ import { AudioFeaturesService } from './audio-features.service.js';
import { CleanupSweepService } from './cleanup.service.js';
import { reprocessArtists } from './reprocess-artists.service.js';
import { AcquisitionService } from './acquisition.service.js';
import { DiscoverySourcesService } from './discovery-sources.service.js';
import { AUDIO_ANALYSIS_JOB_OPTIONS, AUDIO_ANALYSIS_VERSION, audioAnalysisJobId } from './audio-analysis.js';
// Cron for the periodic integrity sweep (default: daily at 03:00). Configurable
@@ -22,6 +23,12 @@ const CLEANUP_SWEEP_CRON = process.env.CLEANUP_SWEEP_CRON || '0 */6 * * *';
// 24h must transition to RESOLVED so returning users start fresh sessions.
const VIBE_REAP_CRON = process.env.VIBE_REAP_CRON || '0 * * * *';
const PROBATION_SWEEP_CRON = process.env.PROBATION_SWEEP_CRON || '15 * * * *';
// Candidate generation runs daily, not hourly: both sources are seeded from
// play counts, which barely move in an hour, and every extra run is external
// API traffic that returns the same rows. New releases are checked at a
// different hour from similarity so the two never contend for the proxy.
const NEW_RELEASE_CRON = process.env.NEW_RELEASE_CRON || '30 5 * * *';
const RECOMMENDATION_CRON = process.env.RECOMMENDATION_CRON || '30 6 * * *';
// A small daily backfill is intentionally bounded. It refreshes stale v1
// measurements over time without turning a worker restart into a library-wide
// ffmpeg/Essentia batch.
@@ -74,6 +81,7 @@ async function initWorker() {
const scannerService = new ScannerService(pgPool, queue);
const acquisitionService = new AcquisitionService(pgPool, scannerService);
const discoverySources = new DiscoverySourcesService(pgPool);
const enrichmentService = new EnrichmentService(pgPool);
const audioFeaturesService = new AudioFeaturesService(pgPool);
await audioFeaturesService.ensureSchema();
@@ -293,6 +301,18 @@ async function initWorker() {
if (result.status === 'failed') throw new Error(result.reason);
return result;
}
case 'discover_new_releases': {
const payload = job.data as DiscoverySourceJob;
const result = await discoverySources.discoverNewReleases(payload.seeds, payload.perSeed);
console.log(`[Discovery] New releases considered=${result.considered} inserted=${result.inserted}`);
return result;
}
case 'discover_recommendations': {
const payload = job.data as DiscoverySourceJob;
const result = await discoverySources.discoverRecommendations(payload.seeds, payload.perSeed);
console.log(`[Discovery] Recommendations considered=${result.considered} inserted=${result.inserted}`);
return result;
}
case 'probation_sweep': {
// Keep probation moving without exposing an operator-only HTTP endpoint
// as the sole lifecycle driver. These conditions mirror DiscoveryService.
@@ -311,8 +331,26 @@ async function initWorker() {
AND (SELECT COUNT(*) FROM evidence e WHERE e.entity_type = 'track'
AND e.entity_id = t.id AND e.signal = 'skip_quick') >= 3`
);
const result = { retained: retained.rowCount ?? 0, retired: retired.rowCount ?? 0 };
// Per-source outcomes, so which strategy is worth its bandwidth is a
// fact rather than a hunch. Logged every sweep because the interesting
// number is the trend, and the counts are a single grouped scan.
const bySource = await pgPool.query<{ source: string; probation: string; retained: string; retired: string }>(
`SELECT dc.source,
COUNT(*) FILTER (WHERE t.probation_status = 'probation')::text AS probation,
COUNT(*) FILTER (WHERE t.probation_status = 'retained')::text AS retained,
COUNT(*) FILTER (WHERE t.probation_status = 'retired')::text AS retired
FROM discovery_candidates dc
JOIN tracks t ON t.id = dc.acquired_track_id
GROUP BY dc.source
ORDER BY dc.source`
);
const result = {
retained: retained.rowCount ?? 0,
retired: retired.rowCount ?? 0,
bySource: bySource.rows,
};
console.log(`[Probation] Sweep retained=${result.retained} retired=${result.retired}`);
console.log(`[Probation] Lifetime by source: ${JSON.stringify(result.bySource)}`);
return result;
}
default:
@@ -363,6 +401,20 @@ async function initWorker() {
);
console.log(`[Probation] Sweep scheduled with cron: ${PROBATION_SWEEP_CRON}`);
await queue.upsertJobScheduler(
'discover-new-releases',
{ pattern: NEW_RELEASE_CRON },
{ name: 'discover_new_releases', data: { reason: 'scheduled' } satisfies DiscoverySourceJob }
);
console.log(`[Discovery] New-release scan scheduled with cron: ${NEW_RELEASE_CRON}`);
await queue.upsertJobScheduler(
'discover-recommendations',
{ pattern: RECOMMENDATION_CRON },
{ name: 'discover_recommendations', data: { reason: 'scheduled' } satisfies DiscoverySourceJob }
);
console.log(`[Discovery] Recommendation scan scheduled with cron: ${RECOMMENDATION_CRON}`);
await queue.upsertJobScheduler(
'audio-analysis-sweep',
{ pattern: AUDIO_ANALYSIS_SWEEP_CRON },
+75
View File
@@ -22,6 +22,16 @@ export interface DeezerAlbum {
coverBig: string;
}
/** An album in an artist's discography (only the fields we read). */
export interface DeezerAlbumRelease {
id: number;
title: string;
/** ISO date, YYYY-MM-DD. */
releaseDate: string;
/** 'album' | 'single' | 'ep' | 'compilation' as reported by Deezer. */
recordType: string;
}
interface DeezerSearchResult {
artist?: { name?: string };
title?: string;
@@ -116,4 +126,69 @@ export class DeezerClient {
return null;
}
}
/** Resolve an artist name to a Deezer artist id via exact fold-matched search. */
private async resolveArtistId(artist: string): Promise<number | null> {
if (artist.trim() === '') return null;
const qs = new URLSearchParams({ q: artist });
try {
const data = await requestJson<{ data?: { id?: number; name?: string }[] }>(
`${this.baseUrl}/search/artist?${qs.toString()}`,
{ userAgent: this.userAgent, minIntervalMs: this.minIntervalMs }
);
const want = foldName(artist);
for (const a of data.data ?? []) {
if (foldName(a.name ?? '') === want && typeof a.id === 'number') return a.id;
}
return null;
} catch (err) {
console.warn('[Deezer] resolveArtistId failed:', (err as Error).message);
return null;
}
}
/**
* Albums by `artist` released on/after `sinceIso` (YYYY-MM-DD), newest first.
*
* ponytail: reads only the first page (25 albums). Deezer returns albums
* newest-first, so a release-date cutoff never needs page two unless an
* artist dropped 25 albums inside the window. Paginate if that ever happens.
*/
async getArtistAlbumsSince(artist: string, sinceIso: string): Promise<DeezerAlbumRelease[]> {
const artistId = await this.resolveArtistId(artist);
if (artistId === null) return [];
try {
const data = await requestJson<{
data?: { id?: number; title?: string; release_date?: string; record_type?: string }[];
}>(`${this.baseUrl}/artist/${artistId}/albums?limit=25`, {
userAgent: this.userAgent,
minIntervalMs: this.minIntervalMs,
});
return (data.data ?? [])
.filter((a) => typeof a.id === 'number' && a.title && a.release_date && a.release_date >= sinceIso)
.map((a) => ({
id: a.id as number,
title: a.title as string,
releaseDate: a.release_date as string,
recordType: a.record_type ?? 'album',
}));
} catch (err) {
console.warn('[Deezer] getArtistAlbumsSince failed:', (err as Error).message);
return [];
}
}
/** Track titles on a Deezer album, in tracklist order. */
async getAlbumTracks(albumId: number): Promise<string[]> {
try {
const data = await requestJson<{ data?: { title?: string }[] }>(
`${this.baseUrl}/album/${albumId}/tracks?limit=50`,
{ userAgent: this.userAgent, minIntervalMs: this.minIntervalMs }
);
return (data.data ?? []).map((t) => t.title ?? '').filter((t) => t !== '');
} catch (err) {
console.warn('[Deezer] getAlbumTracks failed:', (err as Error).message);
return [];
}
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ export { ITunesClient, upscaleITunesArtwork } from './itunes.client.js';
export type { ITunesAlbum } from './itunes.client.js';
export { DeezerClient } from './deezer.client.js';
export type { DeezerAlbum } from './deezer.client.js';
export type { DeezerAlbum, DeezerAlbumRelease } from './deezer.client.js';
export { WikimediaClient } from './wikimedia.client.js';
+12 -4
View File
@@ -18,6 +18,13 @@ export interface ScanContext {
sourceType?: 'MANUAL' | 'RECOMMENDATION';
probationStatus?: 'probation' | 'retained' | 'retired';
candidateId?: string;
/**
* Names to use when the file carries no title/artist tags. An acquired
* download often has none, and the filename ("VTKqlmCpTmQ.mp3") plus
* "Unknown Artist" is worse than the vetted candidate's own names.
*/
fallbackTitle?: string;
fallbackArtist?: string;
}
export interface ScanResult {
@@ -31,7 +38,7 @@ export interface ScanResult {
* in the title ("Song (feat. X)") are folded in either way. First artist is the
* main artist; the rest are featured. See ./utils/artist-names for the rules.
*/
function parseArtistsFromMetadata(common: any): { main: string; featured: string[] } {
function parseArtistsFromMetadata(common: any, fallbackArtist = 'Unknown Artist'): { main: string; featured: string[] } {
const rawTitle = common.title || '';
// Structured array present: still split each entry (tags sometimes put a whole
@@ -52,7 +59,7 @@ function parseArtistsFromMetadata(common: any): { main: string; featured: string
}
}
return parseArtists(common.artist || 'Unknown Artist', rawTitle);
return parseArtists(common.artist || fallbackArtist, rawTitle);
}
function hashFile(filePath: string): Promise<string> {
@@ -136,8 +143,9 @@ export class ScannerService {
const { common, format } = metadata;
// 1. Ensure Artist(s) exist.
const trackTitle = common.title || path.basename(filePath);
const { main: mainArtistRaw, featured: featuredArtistNames } = parseArtistsFromMetadata(common);
const trackTitle = common.title || context.fallbackTitle || path.basename(filePath);
const { main: mainArtistRaw, featured: featuredArtistNames } =
parseArtistsFromMetadata(common, context.fallbackArtist || 'Unknown Artist');
const { id: artistId, name: resolvedArtist } = await this.resolveOrCreateArtist(mainArtistRaw);
+14 -1
View File
@@ -58,6 +58,18 @@ export interface AcquisitionJob {
candidateId: string;
}
/**
* External candidate generation. Two job names share this payload because the
* knobs are the same shape; the strategies themselves stay separate.
*/
export interface DiscoverySourceJob {
reason?: string;
/** new_release: artists to check. similar_recommendation: seed tracks. */
seeds?: number;
/** Candidates to keep per album (new_release) or per seed track (similar). */
perSeed?: number;
}
export type JobPayload =
| MetadataRefreshJob
| ArtistSimilarityJob
@@ -69,4 +81,5 @@ export type JobPayload =
| LibraryScanJob
| IntegritySweepJob
| ReprocessArtistsJob
| AcquisitionJob;
| AcquisitionJob
| DiscoverySourceJob;