Files
muzick/workers/src/scanner.service.ts
T
kami 0a01085ed0 fix(discovery): give every track a place in the graph, not just the ones MusicBrainz knows
track_artists_v2 is a view over claim_fusion, and only the MusicBrainz
spine wrote credited_main_on claims. The scanner filled track_artists
and left the spine alone, so the tag-derived backfill migration from
July was the only thing that ever put local credits in the graph.

Every track scanned since was therefore invisible to all but one Vibe
generator — 740 of them, including 20 of the 26 acquired
recommendations, all of which had perfectly good artist tags sitting
unused. Obscure music is exactly what MusicBrainz does not know and
exactly what a self-hosted library is full of.

The scanner now writes those credits itself. Source is `tag`, the
lowest trust in the spine, so a later MusicBrainz claim about the same
edge still outranks it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
2026-08-10 14:00:04 +04:00

444 lines
20 KiB
TypeScript

import fs from 'fs/promises';
import { createReadStream } from 'fs';
import { createHash } from 'crypto';
import path from 'path';
import mm from 'music-metadata';
import type { Queryable } from './db.js';
import { Queue } from 'bullmq';
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;
/**
* Names to use when the file carries no title/artist tags. An acquired
* download often has none, and the filename ("VTKqlmCpTmQ.mp3") plus
* "Unknown Artist" is worse than the vetted candidate's own names.
*/
fallbackTitle?: string;
fallbackArtist?: 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),
* otherwise falls back to splitting the single `artist` string. Feature markers
* in the title ("Song (feat. X)") are folded in either way. First artist is the
* main artist; the rest are featured. See ./utils/artist-names for the rules.
*/
function parseArtistsFromMetadata(common: any, fallbackArtist = 'Unknown Artist'): { main: string; featured: string[] } {
const rawTitle = common.title || '';
// Structured array present: still split each entry (tags sometimes put a whole
// "A & B" collaboration in one slot) and fold in any title feature.
if (common.artists && Array.isArray(common.artists) && common.artists.length > 0) {
const fromArray = common.artists.flatMap((a: string) => splitArtistNames(a));
const fromTitleParse = parseArtists('', rawTitle);
const all: string[] = [];
const seen = new Set<string>();
for (const name of [...fromArray, ...fromTitleParse.featured]) {
const n = name.trim();
if (!n || seen.has(n.toLowerCase())) continue;
seen.add(n.toLowerCase());
all.push(n);
}
if (all.length > 0) {
return { main: all[0], featured: all.slice(1) };
}
}
return parseArtists(common.artist || fallbackArtist, rawTitle);
}
function hashFile(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = createHash('md5');
const stream = createReadStream(filePath);
stream.on('data', (d) => hash.update(d));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
/**
* Resolve the album artist from metadata tags. The `albumartist` tag is the
* standard way to identify who "owns" the album as a whole — "Various Artists"
* for compilations, "Soundtrack" for OSTs, or the primary artist for normal
* albums. Without it, compilations where each track has a different artist
* would create duplicate album rows (one per track artist).
*
* Falls back to the track's main artist when the tag is absent (single-artist
* albums where the tag is redundant).
*
* Also splits the albumartist string via splitArtistNames so that "A & B" as
* albumartist uses just the first-billed artist as the album's identity —
* consistent with how normalize_artist treats track artists.
*/
function resolveAlbumArtist(common: any, fallback: string): string {
// music-metadata exposes albumartist as either a string or an array.
const raw = common.albumartist ?? common.albumArtist;
if (!raw) return fallback;
const str = Array.isArray(raw) ? raw[0] : String(raw);
if (str.trim() === '') return fallback;
const parts = splitArtistNames(str);
return parts.length > 0 ? parts[0] : fallback;
}
export class ScannerService {
// Per-scan dedupe sets for per-artist / per-album jobs. Belt-and-braces
// alongside the BullMQ jobId dedupe (jobId dedupes across overlapping scans /
// 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, context: ScanContext = {}): Promise<ScanResult> {
console.log(`[Scanner] Starting scan in: ${directory}`);
this.enqueuedArtists.clear();
this.enqueuedAlbums.clear();
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, 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, context, trackIds);
} else if (this.isMusicFile(entry.name)) {
const trackId = await this.processFile(fullPath, context);
if (trackId) trackIds.push(trackId);
}
}
}
private isMusicFile(fileName: string): boolean {
const extensions = ['.mp3', '.flac', '.m4a', '.wav', '.ogg'];
return extensions.includes(path.extname(fileName).toLowerCase());
}
private async processFile(filePath: string, context: ScanContext): Promise<string | null> {
try {
console.log(`[Scanner] Processing: ${filePath}`);
const metadata = await mm.parseFile(filePath);
const { common, format } = metadata;
// 1. Ensure Artist(s) exist.
const trackTitle = common.title || context.fallbackTitle || path.basename(filePath);
const { main: mainArtistRaw, featured: featuredArtistNames } =
parseArtistsFromMetadata(common, context.fallbackArtist || 'Unknown Artist');
const { id: artistId, name: resolvedArtist } = await this.resolveOrCreateArtist(mainArtistRaw);
// Upsert featured artists into artists table and collect their ids.
const featuredIds: string[] = [];
for (const featName of featuredArtistNames) {
const feat = await this.resolveOrCreateArtist(featName);
featuredIds.push(feat.id);
}
// 2. Ensure Album exists.
// The album is keyed by (album_artist_id, title), NOT (track_artist_id,
// title). This prevents duplicate album rows for compilations where
// each track has a different artist but they all belong to one album.
// We resolve the ALBUM artist from the `albumartist` tag when present
// ("Various Artists", "Soundtrack", or the primary artist), falling
// back to the track's main artist when the tag is absent.
const albumTitle = common.album || 'Unknown Album';
const albumArtistRaw = resolveAlbumArtist(common, mainArtistRaw);
const albumArtistId = albumArtistRaw === mainArtistRaw
? artistId
: (await this.resolveOrCreateArtist(albumArtistRaw)).id;
const existingAlbum = await this.pgClient.query(
'SELECT id FROM albums WHERE artist_id = $1 AND lower(title) = lower($2) LIMIT 1',
[albumArtistId, albumTitle]
);
let albumId: string;
if (existingAlbum.rows.length > 0) {
albumId = String(existingAlbum.rows[0].id);
} else {
const albumRes = await this.pgClient.query(
'INSERT INTO albums (artist_id, title) VALUES ($1, $2) ON CONFLICT (artist_id, title) DO UPDATE SET title = EXCLUDED.title RETURNING id',
[albumArtistId, albumTitle]
);
albumId = String(albumRes.rows[0].id);
}
// 3. Upsert Track
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, 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()),
-- Existing recommendation rows stay recommendations during every
-- ordinary scan. This is load-bearing for probation and Vibe.
-- A dislike is a listener verdict on a file that is still on disk,
-- so finding that file again says nothing new: HIDDEN and DELETED
-- survive a rescan. Without this every scan restored every disliked
-- track to LIBRARY and the Vibe served it again.
state = CASE WHEN tracks.source_type = 'RECOMMENDATION'
THEN tracks.state
WHEN tracks.state IN ('HIDDEN', 'DELETED')
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, state, sourceType, probationStatus]
);
const trackId = String(trackRes.rows[0].id);
// 4. Populate track_artists junction table.
await this.pgClient.query(
`INSERT INTO track_artists (track_id, artist_id, role)
VALUES ($1, $2, 'main')
ON CONFLICT (track_id, artist_id, role) DO NOTHING`,
[trackId, artistId]
);
for (const featId of featuredIds) {
await this.pgClient.query(
`INSERT INTO track_artists (track_id, artist_id, role)
VALUES ($1, $2, 'featured')
ON CONFLICT (track_id, artist_id, role) DO NOTHING`,
[trackId, featId]
);
}
// 5. Put the same credit into the claim graph. Every Vibe generator but
// the fallback reaches tracks through claim_fusion, and only MusicBrainz
// was writing those edges — so a track MusicBrainz has never heard of was
// invisible to the recommender no matter how well tagged the file was.
// `tag` is the lowest-trust source in the spine, so a later MusicBrainz
// claim about the same edge still outranks this one.
await this.writeTagCredits(trackId, String(artistId), featuredIds.map(String));
console.log(`[Scanner] Successfully processed: ${trackTitle}`);
// 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;
}
}
/**
* Resolve an artist by its case-insensitive normalized identity, creating the
* row only when no existing artist matches. This is what prevents case /
* punctuation variants ("Booker" vs "BOOKER", "Acryl madness" vs "Acryl
* Madness") from becoming separate rows (and duplicate albums). Returns the
* artist id and the stored display name.
*/
private async resolveOrCreateArtist(rawName: string): Promise<{ id: string; name: string }> {
const norm = await this.pgClient.query('SELECT normalize_artist($1) AS name', [rawName]);
let name: string = norm.rows[0].name;
// Comma heuristic: prefer the pre-comma part if it already exists.
if (name.includes(',')) {
const before = name.split(',')[0].trim();
const existing = await this.pgClient.query(
'SELECT id FROM artists WHERE lower(normalized_name) = lower(normalize_artist($1)) LIMIT 1',
[before]
);
if (existing.rows.length > 0) name = before;
}
// Case-insensitive lookup by normalized identity; prefer the most "complete"
// row when several somehow match.
const found = await this.pgClient.query(
`SELECT id, name FROM artists
WHERE lower(normalized_name) = lower(normalize_artist($1))
ORDER BY CASE WHEN mbid IS NOT NULL THEN 0 ELSE 1 END,
CASE WHEN image_path IS NOT NULL AND image_path <> '' THEN 0 ELSE 1 END,
created_at
LIMIT 1`,
[name]
);
if (found.rows.length > 0) {
return { id: String(found.rows[0].id), name: String(found.rows[0].name) };
}
// `canonical_name` is NOT NULL in schema.sql with no default, so it MUST be
// supplied here — omitting it makes every artist insert fail on a fresh
// volume (and processFile swallows the error, so the scan silently yields an
// empty library). It holds the DISPLAY name: we store the raw tag name, not
// the normalize_artist() output, because that function truncates on `/` and
// a standalone `x` ("AC/DC" -> "AC", "Felix Mendelssohn" -> "Feli"). The
// enrichment path later overwrites canonical_name with the MusicBrainz name;
// until then the raw tag is the most faithful display value we have.
const inserted = await this.pgClient.query(
'INSERT INTO artists (name, canonical_name) VALUES ($1, $2) RETURNING id, name',
[name, rawName.trim() || name]
);
return { id: String(inserted.rows[0].id), name: String(inserted.rows[0].name) };
}
/**
* Record the file's own artist credits as claims. Confidence is 1.0 because
* the tag says this without ambiguity; how much that is worth is decided by
* the trust attached to the `tag` source, not here.
*/
private async writeTagCredits(trackId: string, artistId: string, featuredIds: string[]): Promise<void> {
const credits: Array<[string, string]> = [
[artistId, 'credited_main_on'],
...featuredIds.map((id): [string, string] => [id, 'featured_on']),
];
for (const [objectId, predicate] of credits) {
try {
await this.pgClient.query(
`INSERT INTO claims (
subject_type, subject_id, predicate, object_type, object_id, source, confidence
) VALUES ('track', $1::uuid, $2, 'artist', $3::uuid, 'tag', 1.0)
ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
DO UPDATE SET last_reinforced_at = NOW()`,
[trackId, predicate, objectId]
);
} catch (err) {
// A missing edge costs this track its place in the graph; it must not
// cost the whole scan the file.
console.error(`[Scanner] Failed to write ${predicate} claim for track ${trackId}:`, err);
}
}
}
private async enqueueEnrichment(trackId: string, artistId: string, albumId: string) {
const keep = {
removeOnComplete: { age: 86400, count: 5000 },
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.
try {
const payload: MetadataRefreshJob = { trackId, refreshType: 'full' };
await this.queue.add('metadata_refresh', payload, { jobId: `meta-${trackId}`, ...keep });
} catch (err) {
console.error(`[Scanner] Failed to enqueue metadata_refresh for track ${trackId}:`, err);
}
// album_cover per album, deduped per scan (Set) + jobId across scans.
if (!this.enqueuedAlbums.has(albumId)) {
this.enqueuedAlbums.add(albumId);
try {
const payload: AlbumCoverJob = { albumId };
await this.queue.add('album_cover', payload, { jobId: `album-cover-${albumId}`, ...keep });
} catch (err) {
console.error(`[Scanner] Failed to enqueue album_cover for album ${albumId}:`, err);
}
}
// artist_similarity + artist_image per artist, deduped per scan (Set) AND
// across overlapping scans / pending jobs via their jobIds.
if (this.enqueuedArtists.has(artistId)) {
return;
}
this.enqueuedArtists.add(artistId);
try {
const payload: ArtistSimilarityJob = { artistId };
await this.queue.add('artist_similarity', payload, { jobId: `artist-sim-${artistId}`, ...keep });
} catch (err) {
console.error(`[Scanner] Failed to enqueue artist_similarity for artist ${artistId}:`, err);
}
try {
const payload: ArtistImageJob = { artistId };
await this.queue.add('artist_image', payload, { jobId: `artist-image-${artistId}`, ...keep });
} catch (err) {
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;
}
}
}