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:
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user