feat: enhance discovery, vibe sessions, and library enrichment
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled

This commit is contained in:
kami
2026-08-01 14:40:48 +04:00
parent a0c9f42a89
commit 4c48d11e9d
54 changed files with 4136 additions and 521 deletions
+51 -36
View File
@@ -13,7 +13,6 @@ import {
DeezerClient,
upscaleITunesArtwork,
} from './integrations/index.js';
import { AudioFeaturesService } from './audio-features.service.js';
import { MbSpineWriter } from './mb-spine-writer.js';
import {
normalizeForMatching,
@@ -43,6 +42,13 @@ interface TrackRow {
artist_id: string | null;
}
/** Stored as BullMQ's job return value so a completed job never masquerades
* as an enrichment hit when a toggle was off or every provider had no match. */
export interface EnrichmentJobOutcome {
outcome: 'updated' | 'unchanged' | 'skipped' | 'not_found' | 'no_result';
detail?: string;
}
/**
* Wires the external-integration clients into real metadata enrichment.
*
@@ -63,11 +69,7 @@ export class EnrichmentService {
private readonly theaudiodb = new TheAudioDbClient();
private readonly itunes = new ITunesClient();
private readonly deezer = new DeezerClient();
private readonly audioFeatures: AudioFeaturesService;
constructor(private pgClient: Queryable) {
this.audioFeatures = new AudioFeaturesService(pgClient);
}
constructor(private pgClient: Queryable) {}
/**
* Self-provision the enrichment-specific schema additions. Idempotent; mirrors
@@ -540,15 +542,22 @@ export class EnrichmentService {
* provider that returns nothing or throws is logged and skipped without
* affecting the others. Safe to re-run (stable, no duplicate rows).
*/
async enrichTrack(trackId: string): Promise<void> {
async enrichTrack(trackId: string): Promise<EnrichmentJobOutcome> {
const track = await this.loadTrack(trackId);
if (!track) {
console.warn(`[Enrich] track not found: ${trackId}`);
return;
return { outcome: 'not_found', detail: 'Track no longer exists.' };
}
// Load enrichment settings. Default all to true (best-effort).
const settings = await this.loadSettings();
const trackEnrichmentEnabled = settings.enrich_metadata
|| settings.enrich_genres
|| settings.enrich_lyrics;
if (!trackEnrichmentEnabled) {
console.log(`[Enrich] track ${trackId}: skipped (all track enrichment toggles disabled)`);
return { outcome: 'skipped', detail: 'All track enrichment toggles are disabled.' };
}
const summary: string[] = [];
const album = track.album_title ?? undefined;
@@ -810,22 +819,14 @@ export class EnrichmentService {
// NOTE: Album cover art is fetched by the separate `album_cover` job (Discogs
// + Cover Art Archive), not inline here. See refreshAlbumCover().
// --- g. Audio features from embedded tags --------------------------------
if (settings.enrich_audio_analysis) {
try {
await this.audioFeatures.ensureSchema();
await this.audioFeatures.extractAndPersist(trackId);
summary.push('audio_features');
} catch (err) {
console.warn('[Enrich] Audio features step failed:', (err as Error).message);
}
} // enrich_audio_analysis
console.log(
`[Enrich] track ${trackId} enriched: ${
summary.length > 0 ? summary.join(', ') : 'nothing'
}`
);
return summary.length > 0
? { outcome: 'updated', detail: summary.join(', ') }
: { outcome: 'no_result', detail: 'No provider returned usable metadata.' };
}
/**
@@ -838,6 +839,7 @@ export class EnrichmentService {
*/
private async loadSettings(): Promise<{
enrich_metadata: boolean;
enrich_artist_images: boolean;
enrich_genres: boolean;
enrich_cover_art: boolean;
enrich_lyrics: boolean;
@@ -846,7 +848,7 @@ export class EnrichmentService {
}> {
const rows = await this.pgClient.query(
`SELECT key, value FROM settings
WHERE key IN ('enrich_metadata','enrich_genres','enrich_cover_art',
WHERE key IN ('enrich_metadata','enrich_artist_images','enrich_genres','enrich_cover_art',
'enrich_lyrics','enrich_artist_similarity','enrich_audio_analysis')`
);
const map: Record<string, boolean> = {};
@@ -855,6 +857,9 @@ export class EnrichmentService {
}
return {
enrich_metadata: map.enrich_metadata ?? true,
// This migration is intentionally opt-in: image cleanup must never be
// followed by an unrequested external API fan-out on the next scan.
enrich_artist_images: map.enrich_artist_images ?? false,
enrich_genres: map.enrich_genres ?? true,
enrich_cover_art: map.enrich_cover_art ?? true,
enrich_lyrics: map.enrich_lyrics ?? true,
@@ -867,25 +872,32 @@ export class EnrichmentService {
* Fetch/refresh a single artist's image via the fallback chain. Runs as the
* dedicated `artist_image` job so image lookups don't run inline with track
* enrichment. Best-effort and idempotent — getArtistImage() short-circuits
* when a good image already exists. Gated by enrich_metadata (same toggle the
* inline step used).
* when a good image already exists. Gated by the dedicated
* enrich_artist_images toggle, independently of structural metadata.
*/
async refreshArtistImage(artistId: string): Promise<void> {
async refreshArtistImage(artistId: string): Promise<EnrichmentJobOutcome> {
const settings = await this.loadSettings();
if (!settings.enrich_metadata) return;
if (!settings.enrich_artist_images) {
console.log(`[Enrich] artist image ${artistId}: skipped (enrich_artist_images disabled)`);
return { outcome: 'skipped', detail: 'enrich_artist_images is disabled.' };
}
const res = await this.pgClient.query<{ name: string; canonical_name: string | null; mbid: string | null }>(
`SELECT name, canonical_name, mbid FROM artists WHERE id = $1`,
const res = await this.pgClient.query<{ name: string; canonical_name: string | null; mbid: string | null; image_path: string | null }>(
`SELECT name, canonical_name, mbid, image_path FROM artists WHERE id = $1`,
[artistId]
);
const artist = res.rows[0];
if (!artist) {
console.warn(`[Enrich] artist not found for image: ${artistId}`);
return;
return { outcome: 'not_found', detail: 'Artist no longer exists.' };
}
if (artist.image_path) return { outcome: 'unchanged', detail: 'Artist already has an image.' };
const url = await this.getArtistImage(artistId, artist.mbid, artist.canonical_name ?? artist.name);
console.log(`[Enrich] artist image ${artistId}: ${url ? 'set' : 'none'}`);
return url
? { outcome: 'updated', detail: 'Artist image was set.' }
: { outcome: 'no_result', detail: 'No verified artist image was found.' };
}
/**
@@ -906,9 +918,11 @@ export class EnrichmentService {
* release-group cover.
* Each step short-circuits on the first hit.
*/
async refreshAlbumCover(albumId: string): Promise<void> {
async refreshAlbumCover(albumId: string): Promise<EnrichmentJobOutcome> {
const settings = await this.loadSettings();
if (!settings.enrich_cover_art) return;
if (!settings.enrich_cover_art) {
return { outcome: 'skipped', detail: 'enrich_cover_art is disabled.' };
}
const albumRes = await this.pgClient.query<{
title: string;
@@ -922,9 +936,9 @@ export class EnrichmentService {
const album = albumRes.rows[0];
if (!album) {
console.warn(`[Enrich] album not found for cover: ${albumId}`);
return;
return { outcome: 'not_found', detail: 'Album no longer exists.' };
}
if (album.artwork_id) return; // already has cover — nothing to do
if (album.artwork_id) return { outcome: 'unchanged', detail: 'Album already has artwork.' };
let artistName = '';
if (album.artist_id) {
@@ -947,7 +961,7 @@ export class EnrichmentService {
[coverUrl, albumId]
);
console.log(`[Enrich] album cover ${albumId}: caa-release-group`);
return;
return { outcome: 'updated', detail: 'Cover Art Archive release-group.' };
}
} catch (err) {
console.warn('[Enrich] album cover CAA release-group step failed:', (err as Error).message);
@@ -965,7 +979,7 @@ export class EnrichmentService {
[coverUrl, albumId]
);
console.log(`[Enrich] album cover ${albumId}: itunes`);
return;
return { outcome: 'updated', detail: 'iTunes artwork.' };
}
} catch (err) {
console.warn('[Enrich] album cover iTunes step failed:', (err as Error).message);
@@ -982,7 +996,7 @@ export class EnrichmentService {
[deezerAlbum.coverXl, albumId]
);
console.log(`[Enrich] album cover ${albumId}: deezer`);
return;
return { outcome: 'updated', detail: 'Deezer artwork.' };
}
} catch (err) {
console.warn('[Enrich] album cover Deezer step failed:', (err as Error).message);
@@ -1006,7 +1020,7 @@ export class EnrichmentService {
);
}
console.log(`[Enrich] album cover ${albumId}: discogs`);
return;
return { outcome: 'updated', detail: 'Discogs artwork.' };
}
} catch (err) {
console.warn('[Enrich] album cover Discogs step failed:', (err as Error).message);
@@ -1032,7 +1046,7 @@ export class EnrichmentService {
[coverUrl, albumId]
);
console.log(`[Enrich] album cover ${albumId}: caa-release`);
return;
return { outcome: 'updated', detail: 'Cover Art Archive release.' };
}
}
}
@@ -1041,6 +1055,7 @@ export class EnrichmentService {
}
console.log(`[Enrich] album cover ${albumId}: none`);
return { outcome: 'no_result', detail: 'No cover provider returned artwork.' };
}
async refreshArtistSimilarity(artistId: string): Promise<void> {