feat: enhance discovery, vibe sessions, and library enrichment
This commit is contained in:
+102
-11
@@ -5,9 +5,25 @@ import path from 'path';
|
||||
import mm from 'music-metadata';
|
||||
import type { Queryable } from './db.js';
|
||||
import { Queue } from 'bullmq';
|
||||
import { MetadataRefreshJob, ArtistSimilarityJob, ArtistImageJob, AlbumCoverJob } from './types.js';
|
||||
import { MetadataRefreshJob, ArtistSimilarityJob, ArtistImageJob, AlbumCoverJob, AudioAnalysisJob } from './types.js';
|
||||
import { AUDIO_ANALYSIS_JOB_OPTIONS, audioAnalysisJobId, AUDIO_ANALYSIS_VERSION } from './audio-analysis.js';
|
||||
import { splitArtistNames, parseArtists } from './utils/artist-names.js';
|
||||
|
||||
/**
|
||||
* Scanner provenance is supplied by the acquisition worker, not inferred from
|
||||
* tags. The optional candidate id lets the acquisition service make an exact
|
||||
* candidate -> scanned-track association after a successful scan.
|
||||
*/
|
||||
export interface ScanContext {
|
||||
sourceType?: 'MANUAL' | 'RECOMMENDATION';
|
||||
probationStatus?: 'probation' | 'retained' | 'retired';
|
||||
candidateId?: string;
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
trackIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse main + featured artists from music-metadata. Prefers the structured
|
||||
* `artists[]` array when the tag provides it (each entry already one artist),
|
||||
@@ -79,26 +95,31 @@ export class ScannerService {
|
||||
// pending jobs; these avoid even issuing the redundant add() within one scan).
|
||||
private enqueuedArtists = new Set<string>();
|
||||
private enqueuedAlbums = new Set<string>();
|
||||
private audioAnalysisEnabled = false;
|
||||
|
||||
constructor(private pgClient: Queryable, private queue: Queue) {}
|
||||
|
||||
async scanDirectory(directory: string) {
|
||||
async scanDirectory(directory: string, context: ScanContext = {}): Promise<ScanResult> {
|
||||
console.log(`[Scanner] Starting scan in: ${directory}`);
|
||||
this.enqueuedArtists.clear();
|
||||
this.enqueuedAlbums.clear();
|
||||
await this.walk(directory);
|
||||
this.audioAnalysisEnabled = await this.loadAudioAnalysisSetting();
|
||||
const trackIds: string[] = [];
|
||||
await this.walk(directory, context, trackIds);
|
||||
console.log(`[Scanner] Scan completed.`);
|
||||
return { trackIds };
|
||||
}
|
||||
|
||||
private async walk(dir: string) {
|
||||
private async walk(dir: string, context: ScanContext, trackIds: string[]) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await this.walk(fullPath);
|
||||
await this.walk(fullPath, context, trackIds);
|
||||
} else if (this.isMusicFile(entry.name)) {
|
||||
await this.processFile(fullPath);
|
||||
const trackId = await this.processFile(fullPath, context);
|
||||
if (trackId) trackIds.push(trackId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,7 +129,7 @@ export class ScannerService {
|
||||
return extensions.includes(path.extname(fileName).toLowerCase());
|
||||
}
|
||||
|
||||
private async processFile(filePath: string) {
|
||||
private async processFile(filePath: string, context: ScanContext): Promise<string | null> {
|
||||
try {
|
||||
console.log(`[Scanner] Processing: ${filePath}`);
|
||||
const metadata = await mm.parseFile(filePath);
|
||||
@@ -159,19 +180,44 @@ export class ScannerService {
|
||||
const duration = format.duration || 0;
|
||||
const fileHash = await hashFile(filePath);
|
||||
|
||||
// A recommendation scan sets provenance at creation time. A routine
|
||||
// library rescan must never erase that provenance or reset probation.
|
||||
const sourceType = context.sourceType ?? 'MANUAL';
|
||||
const state = sourceType === 'RECOMMENDATION' ? 'RECOMMENDED' : 'LIBRARY';
|
||||
const probationStatus = sourceType === 'RECOMMENDATION'
|
||||
? (context.probationStatus ?? 'probation')
|
||||
: 'retained';
|
||||
const trackRes = await this.pgClient.query(
|
||||
`INSERT INTO tracks (path, hash, title, artist, album_id, duration, state)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'LIBRARY')
|
||||
`INSERT INTO tracks (
|
||||
path, hash, title, artist, album_id, duration, state, source_type,
|
||||
probation_status, probation_entered_at
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7::track_state, $8::track_source_type,
|
||||
$9, CASE WHEN $8::track_source_type = 'RECOMMENDATION' THEN NOW() ELSE NULL END
|
||||
)
|
||||
ON CONFLICT (path) DO UPDATE SET
|
||||
hash = EXCLUDED.hash,
|
||||
title = EXCLUDED.title,
|
||||
artist = EXCLUDED.artist,
|
||||
album_id = EXCLUDED.album_id,
|
||||
duration = EXCLUDED.duration,
|
||||
mtime = EXTRACT(EPOCH FROM NOW())
|
||||
mtime = EXTRACT(EPOCH FROM NOW()),
|
||||
-- Existing recommendation rows stay recommendations during every
|
||||
-- ordinary scan. This is load-bearing for probation and Vibe.
|
||||
state = CASE WHEN tracks.source_type = 'RECOMMENDATION'
|
||||
THEN tracks.state ELSE EXCLUDED.state END,
|
||||
source_type = CASE WHEN tracks.source_type = 'RECOMMENDATION'
|
||||
THEN tracks.source_type ELSE $8::track_source_type END,
|
||||
probation_status = CASE WHEN tracks.source_type = 'RECOMMENDATION'
|
||||
THEN tracks.probation_status ELSE $9 END,
|
||||
probation_entered_at = CASE WHEN tracks.source_type = 'RECOMMENDATION'
|
||||
THEN tracks.probation_entered_at
|
||||
WHEN $8::track_source_type = 'RECOMMENDATION' THEN NOW()
|
||||
ELSE tracks.probation_entered_at END
|
||||
RETURNING id
|
||||
`,
|
||||
[filePath, fileHash, trackTitle, resolvedArtist, albumId, duration]
|
||||
[filePath, fileHash, trackTitle, resolvedArtist, albumId, duration, state, sourceType, probationStatus]
|
||||
);
|
||||
const trackId = String(trackRes.rows[0].id);
|
||||
|
||||
@@ -196,8 +242,10 @@ export class ScannerService {
|
||||
// Trigger external-API enrichment for this track + artist + album.
|
||||
// Best-effort: an enqueue failure must never abort the scan of remaining files.
|
||||
await this.enqueueEnrichment(trackId, String(artistId), String(albumId));
|
||||
return trackId;
|
||||
} catch (err) {
|
||||
console.error(`[Scanner] Error processing ${filePath}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +306,35 @@ export class ScannerService {
|
||||
removeOnFail: { age: 86400, count: 5000 },
|
||||
} as const;
|
||||
|
||||
// Audio decoding is CPU/memory intensive. It must never be performed inline
|
||||
// with metadata refreshes: enqueue one deduplicated, retryable job only when
|
||||
// the user has enabled it and the file has changed or lacks the current
|
||||
// analysis version.
|
||||
if (this.audioAnalysisEnabled) {
|
||||
try {
|
||||
const current = await this.pgClient.query<{ current: boolean }>(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM tracks t
|
||||
JOIN track_audio_features af ON af.track_id = t.id
|
||||
WHERE t.id = $1
|
||||
AND af.analysis_version >= $2
|
||||
AND af.source_hash = t.hash
|
||||
) AS current`,
|
||||
[trackId, AUDIO_ANALYSIS_VERSION]
|
||||
);
|
||||
if (!current.rows[0]?.current) {
|
||||
const payload: AudioAnalysisJob = { trackId };
|
||||
await this.queue.add('audio_analysis', payload, {
|
||||
jobId: audioAnalysisJobId(trackId),
|
||||
...AUDIO_ANALYSIS_JOB_OPTIONS,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Scanner] Failed to enqueue audio_analysis for track ${trackId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// metadata_refresh per track. jobId `meta-<trackId>` collapses duplicate
|
||||
// pending jobs across re-scans; the handler (enrichTrack) is idempotent so
|
||||
// re-enqueues are always safe. BullMQ 5.x rejects colons in custom ids.
|
||||
@@ -298,4 +375,18 @@ export class ScannerService {
|
||||
console.error(`[Scanner] Failed to enqueue artist_image for artist ${artistId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadAudioAnalysisSetting(): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.pgClient.query<{ value: string }>(
|
||||
"SELECT value FROM settings WHERE key = 'enrich_audio_analysis'"
|
||||
);
|
||||
return result.rows[0]?.value === 'true';
|
||||
} catch (err) {
|
||||
// Safe default: a schema/startup problem must not fan out expensive DSP
|
||||
// work across a scan.
|
||||
console.warn('[Scanner] Audio analysis disabled: unable to read setting:', (err as Error).message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user