Files
muzick/workers/src/scanner.service.ts
T
kami ee43995e96 fix: give the worker a pg Pool and real transactions
The worker ran every job through a single pg Client while BullMQ was
configured with concurrency: 10. A Client is one connection with one
protocol stream and no queueing: ten concurrent jobs interleave on it, and
any BEGIN/COMMIT is shared by all of them, so an unrelated job's failure can
roll back another's work and a rollback can discard a third's committed
intent.

Switched to a Pool, added a small withTransaction(pool, fn) helper that
takes a dedicated connection per transaction, and threaded a Queryable
interface through the services so they accept either a pool or a pooled
client. Both reprocess_artists merge blocks — the artist merge and the
duplicate-album merge — now run inside withTransaction; previously a failure
partway through left artists merged and their tracks unmoved.

integrity.service and cleanup.service get only the constructor type change
here so this commit compiles; their own fixes follow in the next two
commits. cleanup.service's BEGIN/COMMIT-on-a-Pool is therefore still wrong
at this commit and is replaced wholesale by the hard-delete commit.

REVIEW-2026-07-30.md finding 4 (and the concurrency note in finding 3).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 23:58:31 +04:00

302 lines
13 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 } from './types.js';
import { splitArtistNames, parseArtists } from './utils/artist-names.js';
/**
* 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): { 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 || 'Unknown Artist', 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>();
constructor(private pgClient: Queryable, private queue: Queue) {}
async scanDirectory(directory: string) {
console.log(`[Scanner] Starting scan in: ${directory}`);
this.enqueuedArtists.clear();
this.enqueuedAlbums.clear();
await this.walk(directory);
console.log(`[Scanner] Scan completed.`);
}
private async walk(dir: 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);
} else if (this.isMusicFile(entry.name)) {
await this.processFile(fullPath);
}
}
}
private isMusicFile(fileName: string): boolean {
const extensions = ['.mp3', '.flac', '.m4a', '.wav', '.ogg'];
return extensions.includes(path.extname(fileName).toLowerCase());
}
private async processFile(filePath: string) {
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 || path.basename(filePath);
const { main: mainArtistRaw, featured: featuredArtistNames } = parseArtistsFromMetadata(common);
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);
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')
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())
RETURNING id
`,
[filePath, fileHash, trackTitle, resolvedArtist, albumId, duration]
);
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]
);
}
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));
} catch (err) {
console.error(`[Scanner] Error processing ${filePath}:`, err);
}
}
/**
* 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) };
}
private async enqueueEnrichment(trackId: string, artistId: string, albumId: string) {
const keep = {
removeOnComplete: { age: 86400, count: 5000 },
removeOnFail: { age: 86400, count: 5000 },
} as const;
// 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);
}
}
}