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
+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