feat: enhance discovery, vibe sessions, and library enrichment
This commit is contained in:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user