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
+102 -11
View File
@@ -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;
}
}
}