/**
 * DJ Analysis Service
 * BPM: a Python ffmpeg-based detector (bpm_detector_simple.py) — headless-safe.
 *   (Replaced realtime-bpm-analyzer, which needs browser Web Audio and was
 *    dead after the headless migration; see dj/bpmDetector.ts.)
 * Key/energy: essentia.js, run in a child-fork worker (EssentiaWorkerProxy).
 */ import { spawn, execFile } from "child_process";
import { promisify } from "util";
import { getDatabase } from "./database.js";
import { poolClient } from "./services/poolClient.js";
import { getFingerprinter } from "./fingerprinter.js";
import { getMetadataEngine } from "./metadataEngine.js";
import { serverLogger } from "../utils/serverLogger.js";
import PQueue from "p-queue";
import { getEssentiaWorkerPool } from "./dj/essentiaWorkerProxy.js";
import { getFFmpegPath } from "../utils/ffmpegPaths.js";
import { audioFeatureExtraction } from "./audioFeatureExtraction.js";
import { BPM_DETECTOR_SCRIPT, BPM_DETECTOR_TIMEOUT_MS, resolvePython3Path, parseBpmDetectorOutput, buildDetectorPath } from "./dj/bpmDetector.js";
const execFileAsync = promisify(execFile);
// Camelot Wheel for harmonic mixing
const CAMELOT_WHEEL = {
    // Major keys (B side)
    C: "8B",
    G: "9B",
    D: "10B",
    A: "11B",
    E: "12B",
    B: "1B",
    "F#": "2B",
    Gb: "2B",
    Db: "3B",
    "C#": "3B",
    Ab: "4B",
    "G#": "4B",
    Eb: "5B",
    "D#": "5B",
    Bb: "6B",
    "A#": "6B",
    F: "7B",
    // Minor keys (A side)
    Am: "8A",
    Em: "9A",
    Bm: "10A",
    "F#m": "11A",
    Gbm: "11A",
    "C#m": "12A",
    Dbm: "12A",
    "G#m": "1A",
    Abm: "1A",
    "D#m": "2A",
    Ebm: "2A",
    Bbm: "3A",
    "A#m": "3A",
    Fm: "4A",
    Cm: "5A",
    Gm: "6A",
    Dm: "7A"
};
// Get compatible keys for harmonic mixing
function getHarmonicKeys(camelotCode) {
    if (!camelotCode) return [];
    const number = parseInt(camelotCode);
    const letter = camelotCode.slice(-1);
    const compatible = [
        camelotCode,
        `${number}${letter === "A" ? "B" : "A"}`,
        `${number === 12 ? 1 : number + 1}${letter}`,
        `${number === 1 ? 12 : number - 1}${letter}`
    ];
    return compatible;
}
class DJAnalysisService {
    queue;
    // Essentia analysis runs in a child_process.fork() worker so its
    // historical process.abort() (uncatchable SIGABRT from WASM on getStats
    // timeout) only kills the worker, not the SOUND server. See
    // dj/essentiaWorkerProxy.ts for the lifecycle + kill-switch handling.
    essentiaProxy;
    constructor(){
        // Concurrency is env-tunable (SOUND_ANALYSIS_CONCURRENCY, default 2) so a
        // bulk backfill can be sped up on capable hosts without a rebuild. Each track
        // fans out to ffmpeg + the Python BPM detector + the essentia worker, so keep
        // this in line with the container's CPU budget (cpus limit).
        this.queue = new PQueue({
            concurrency: Number(process.env.SOUND_ANALYSIS_CONCURRENCY) || 2
        });
        this.essentiaProxy = getEssentiaWorkerPool();
    }
    /**
   * STREAM PRIORITY (WAVE 0-byte-stall fix): pause the analysis queue while the
   * server is actively streaming, so the on-demand transcode ffmpeg isn't starved
   * by backfill ffmpeg/essentia competing for the container's CPU budget. Idempotent
   * and a no-op when the queue is empty (backfill done), so it's cheap to poll on a
   * timer. In-flight jobs finish; only NEW jobs are held — bounding contention to at
   * most one in-flight track when a stream begins.
   */ setStreamingActive(active) {
        if (active && !this.queue.isPaused) {
            this.queue.pause();
            if (this.queue.pending > 0 || this.queue.size > 0) {
                serverLogger.info(`[Analysis] paused for active stream(s) — ${this.queue.pending} in-flight, ${this.queue.size} queued`);
            }
        } else if (!active && this.queue.isPaused) {
            this.queue.start();
            serverLogger.info("[Analysis] resumed — no active streams");
        }
    }
    // How long after a playback request (stream / shuffle-next) we keep the backfill
    // paused. The StreamPriority watcher only sees a stream while its file ReadStream
    // is open; on fast LAN the file transfers in seconds then the device plays from
    // its buffer, so activeStreams reads 0 for most of a track and analysis wrongly
    // resumes — colliding with the next-track DB ops (shuffle/next + recordPlay) and
    // backing up the single DB worker until it wedges. This window keeps analysis
    // paused across each track boundary even on LAN; analysis still runs mid-track.
    STREAM_ACTIVITY_WINDOW_MS = 60_000;
    lastStreamActivityAt = 0;
    /** A playback request just arrived — pause analysis now + (re)start the window. */ noteStreamingActivity() {
        this.lastStreamActivityAt = Date.now();
        this.setStreamingActive(true);
    }
    /** True while still inside the post-activity window (read by the StreamPriority watcher). */ hasRecentStreamingActivity() {
        return Date.now() - this.lastStreamActivityAt < this.STREAM_ACTIVITY_WINDOW_MS;
    }
    /**
   * Analyze a track for DJ features
   */ async analyzeTrack(trackPath) {
        return this.queue.add(async ()=>{
            try {
                serverLogger.info("Starting DJ analysis", {
                    trackPath
                });
                // Populate integrated loudness (LUFS) + true peak on the `tracks` row for
                // client-side ReplayGain normalization. Runs FIRST and independently of the
                // BPM/key path below, because loudness is unrelated to (and not provided by)
                // POOL enrichment — so it must fill even when the POOL short-circuit skips
                // local compute. Idempotent + resumable: no-ops when tracks.loudness is set.
                await this.populateLoudness(trackPath).catch((e)=>{
                    serverLogger.warn("Loudness population failed (non-fatal)", {
                        trackPath,
                        error: e instanceof Error ? e.message : String(e)
                    });
                });
                // Loudness-only fast path. The library backfill re-selects every row where
                // tracks.loudness IS NULL — including the ~all rows that ALREADY have BPM +
                // key in audio_analysis (BPM/key is ~100% complete; only loudness is new).
                // For those, loudness was just filled above and there is nothing left to do:
                // skip the entire enrichment retry below (POOL by-name HTTP, on a miss an
                // fpcalc fingerprint decode + AcoustID + POOL by-MBID HTTP, on a full miss a
                // local essentia/BPM decode). Re-deriving values we already store is the
                // dominant per-track cost of the loudness backfill, so skipping it is the
                // single biggest speedup. Purely additive — it can never alter a stored
                // BPM/key; never-analyzed and keyless rows fall through to full analysis.
                const db = getDatabase();
                const existing = db.db.prepare(`SELECT bpm, musical_key, camelot_code, energy, danceability,
                    bpm_confidence, key_confidence
             FROM audio_analysis WHERE track_path = ?`).get(trackPath);
                if (existing && existing.bpm != null && existing.musical_key != null) {
                    serverLogger.info("DJ analysis already present; loudness-only pass", {
                        trackPath
                    });
                    return {
                        bpm: existing.bpm,
                        key: existing.musical_key,
                        camelotCode: existing.camelot_code ?? undefined,
                        energy: existing.energy ?? 0,
                        danceability: existing.danceability ?? 0,
                        harmonicKeys: existing.camelot_code ? getHarmonicKeys(existing.camelot_code) : [],
                        confidence: {
                            bpm: existing.bpm_confidence ?? 0,
                            key: existing.key_confidence ?? 0
                        }
                    };
                }
                // Enrichment-first: if POOL's shared cache already has BPM + key for this
                // track, store it and skip the expensive local decode + essentia entirely.
                const enriched = await this.tryEnrichFromPool(trackPath).catch((e)=>{
                    serverLogger.warn("[POOL] enrichment check failed; falling back to local", {
                        trackPath,
                        error: e instanceof Error ? e.message : String(e)
                    });
                    return null;
                });
                if (enriched) {
                    serverLogger.info("DJ analysis filled from POOL (skipped local compute)", {
                        trackPath,
                        bpm: enriched.bpm,
                        key: enriched.key
                    });
                    return enriched;
                }
                // Get audio buffer for analysis (used by key/energy; BPM reads the file directly)
                const audioBuffer = await this.loadAudioBuffer(trackPath);
                // Analyze BPM via the headless Python ffmpeg detector
                const bpmResult = await this.analyzeBPM(audioBuffer, trackPath);
                // Analyze key using Essentia.js
                const keyResult = await this.analyzeKey(audioBuffer);
                // Calculate energy and danceability
                const energy = this.calculateEnergy(bpmResult.bpm, audioBuffer);
                const danceability = this.calculateDanceability(bpmResult.bpm, energy);
                // Get harmonic compatible keys
                const harmonicKeys = keyResult.camelotCode ? getHarmonicKeys(keyResult.camelotCode) : [];
                // Store in database
                await this.storeAnalysis(trackPath, {
                    bpm: bpmResult.bpm,
                    key: keyResult.key,
                    camelotCode: keyResult.camelotCode,
                    energy,
                    danceability,
                    harmonicKeys,
                    confidence: {
                        bpm: bpmResult.confidence,
                        key: keyResult.confidence
                    }
                });
                // Phase 3: contribute this LOCALLY-computed result back to POOL (anonymous,
                // opt-in) so the next server skips the same work. Fire-and-forget.
                this.contributeToPool(trackPath, {
                    bpm: bpmResult.bpm,
                    key: keyResult.key,
                    camelotCode: keyResult.camelotCode,
                    danceability,
                    confidence: {
                        bpm: bpmResult.confidence,
                        key: keyResult.confidence
                    }
                });
                serverLogger.info("DJ analysis complete", {
                    trackPath,
                    bpm: bpmResult.bpm,
                    key: keyResult.key,
                    camelotCode: keyResult.camelotCode
                });
                return {
                    bpm: bpmResult.bpm,
                    key: keyResult.key,
                    camelotCode: keyResult.camelotCode,
                    energy,
                    danceability,
                    harmonicKeys,
                    confidence: {
                        bpm: bpmResult.confidence,
                        key: keyResult.confidence
                    }
                };
            } catch (error) {
                serverLogger.error("DJ analysis failed", {
                    trackPath,
                    error
                });
                throw error;
            }
        });
    }
    /**
   * Enrichment-first: fill BPM + key from POOL's shared cache instead of computing
   * locally, when a confident match exists. Two sources, tried cheapest-first:
   *
   *   1. By name (GetSongBPM) — artist+title lookup, no audio decode at all.
   *   2. By fingerprint (AcousticBrainz) — on a by-name miss, identify the EXACT
   *      recording via Chromaprint → AcoustID → MusicBrainz Recording ID, then ask
   *      POOL for that MBID's features. Costs an fpcalc decode (~≤120s of audio) but
   *      is still far cheaper than the local essentia key analysis, and only runs on
   *      the ~half of tracks the by-name lookup misses. Also backfills danceability.
   *
   * Only short-circuits on a hit that has BOTH bpm AND key — the slow/hang-prone part
   * is essentia KEY, so a key is what makes skipping local analysis worthwhile.
   * Disable all enrichment with SOUND_POOL_ENRICH=0; disable only the (CPU-costing)
   * fingerprint source with SOUND_FINGERPRINT_ENRICH=0.
   */ async tryEnrichFromPool(trackPath) {
        if (process.env.SOUND_POOL_ENRICH === "0" || !poolClient.isEnabled()) return null;
        const meta = getDatabase().db.prepare("SELECT artist, title, duration FROM tracks WHERE path = ?").get(trackPath);
        const durationMs = meta?.duration && meta.duration > 0 ? Math.round(meta.duration * 1000) : undefined;
        // Source 1 — by name (GetSongBPM). Cheapest: no decode.
        if (meta?.artist && meta?.title) {
            const af = await poolClient.getAudioFeatures(meta.artist, meta.title, durationMs);
            const result = this.featuresToResult(af);
            if (result) {
                await this.storeAnalysis(trackPath, result);
                serverLogger.info("DJ analysis filled from POOL by name", {
                    trackPath,
                    source: af?.source
                });
                return result;
            }
        }
        // Source 2 — by acoustic fingerprint (AcousticBrainz, exact-recording match).
        if (process.env.SOUND_FINGERPRINT_ENRICH === "0") return null;
        try {
            const fp = await getFingerprinter().generateFingerprint(trackPath);
            if (!fp.chromaprint) return null; // no fpcalc / unsupported file → local fallback
            const id = await getMetadataEngine().identifyRecordingMbid(fp);
            if (!id) return null;
            const af = await poolClient.getAudioFeaturesByMbid(id.mbid);
            const result = this.featuresToResult(af);
            if (result) {
                await this.storeAnalysis(trackPath, result);
                serverLogger.info("DJ analysis filled from POOL by fingerprint/MBID", {
                    trackPath,
                    mbid: id.mbid,
                    acoustidScore: id.score,
                    source: af?.source
                });
                return result;
            }
        } catch (e) {
            serverLogger.warn("[POOL] fingerprint enrichment failed; falling back to local", {
                trackPath,
                error: e instanceof Error ? e.message : String(e)
            });
        }
        return null;
    }
    /**
   * Convert a POOL audio-features response into a DJAnalysisResult, or null when it's
   * not a confident, usable hit (requires found + bpm + key + confidence >= 0.8).
   * danceability is filled when POOL provides it (AcousticBrainz source); energy is
   * always left unset (NULL "unknown") since no public source gives our energy metric
   * — better than a misleading 0 that energy-based features (e.g. Sonic Radio) would
   * read as real.
   */ featuresToResult(af) {
        if (!af?.found || !af.bpm || !af.musicalKey || af.confidence < 0.8) return null;
        const camelotCode = af.camelot ?? "";
        return {
            bpm: af.bpm,
            key: af.musicalKey,
            camelotCode,
            danceability: typeof af.danceability === "number" ? af.danceability : undefined,
            harmonicKeys: camelotCode ? getHarmonicKeys(camelotCode) : [],
            confidence: {
                bpm: af.confidence,
                key: af.confidence
            }
        };
    }
    /**
   * Phase 3 — contribute a LOCALLY-computed analysis back to POOL's shared cache so
   * the next SOUND server skips the same essentia/BPM work. ANONYMOUS: sends only the
   * public song fact (artist+title -> bpm/key/camelot/danceability); never any server,
   * user, registry, or path identity. Opt-in via SOUND_POOL_CONTRIBUTE=1. Only called
   * from the LOCAL-analysis path (never for POOL-enriched results, which would just
   * echo POOL's own data back). Fire-and-forget — never blocks or fails analysis.
   */ contributeToPool(trackPath, result) {
        if (process.env.SOUND_POOL_CONTRIBUTE !== "1" || !poolClient.isEnabled()) return;
        // Only useful contributions: a consuming server needs BOTH bpm and key to skip.
        if (!result.bpm || !result.key) return;
        const meta = getDatabase().db.prepare("SELECT artist, title, duration FROM tracks WHERE path = ?").get(trackPath);
        if (!meta?.artist || !meta?.title) return;
        const durationMs = meta.duration && meta.duration > 0 ? Math.round(meta.duration * 1000) : undefined;
        void poolClient.contributeAudioFeatures({
            artist: meta.artist,
            title: meta.title,
            durationMs,
            bpm: result.bpm,
            musicalKey: result.key,
            camelot: result.camelotCode,
            danceability: result.danceability
        }).then((r)=>{
            if (r?.stored) serverLogger.info("Contributed local analysis to POOL", {
                trackPath,
                reason: r.reason
            });
        }).catch((e)=>{
            serverLogger.warn("[POOL] contribute failed (non-fatal)", {
                trackPath,
                error: e instanceof Error ? e.message : String(e)
            });
        });
    }
    /**
   * Load audio file into a Float32Array for analysis.
   *
   * The previous implementation read the raw file bytes and reinterpreted
   * them as Int16LE PCM samples — for any compressed format (FLAC/MP3/M4A,
   * which is the entire library) this produced random noise, so BPM and
   * key detection always returned garbage. Verified against a
   * /api/dj/analyze call: bpm=0, key="" on a known good FLAC.
   *
   * Now: decode through ffmpeg to mono 16-bit PCM at 44100 Hz, then convert
   * Int16 → Float32 normalized to [-1, 1]. This buffer feeds key detection
   * (essentia worker) and the energy calculation; BPM no longer uses it
   * (the Python detector reads the file directly via ffmpeg). Returns an
   * empty Float32Array on ffmpeg failure (unsupported codec, corrupt file,
   * missing input) rather than throwing — the key-detection worker handles
   * empty buffers gracefully.
   *
   * Memory: a 5-minute track decodes to ~26 MB of Float32. The decoded
   * buffer lives only for the duration of one analyzeTrack() call and is
   * GC'd immediately after. The PQueue concurrency cap of 2 bounds peak
   * memory at ~52 MB.
   */ async loadAudioBuffer(trackPath) {
        return new Promise((resolve)=>{
            const ffmpegPath = getFFmpegPath();
            if (!ffmpegPath) {
                serverLogger.warn("ffmpeg not available, returning empty audio buffer", {
                    trackPath
                });
                resolve(new Float32Array(0));
                return;
            }
            const proc = spawn(ffmpegPath, [
                "-i",
                trackPath,
                // Cap the analysis decode to a representative window. Key detection
                // (essentia) doesn't need a whole 60–90 min DJ set, and BPM is derived
                // independently by the Python detector reading the file itself. Without
                // this cap, loadAudioBuffer decodes the ENTIRE track to PCM and does the
                // Buffer.concat + Int16→Float32 conversion + IPC of the full Float32Array
                // to the essentia worker ON THE MAIN THREAD — for long tracks that blocks
                // the event loop for tens of seconds, tripping the health Watchdog
                // (timeoutMs 5s × maxFailures 3) into a process-restart loop that stalled
                // analyze-library at ~115 tracks. 180s bounds it to ~8M samples (<0.5s).
                // Tunable via SOUND_ANALYSIS_MAX_SECONDS.
                "-t",
                String(Number(process.env.SOUND_ANALYSIS_MAX_SECONDS) || 180),
                "-f",
                "s16le",
                "-ac",
                "1",
                "-ar",
                "44100",
                "-loglevel",
                "error",
                "-"
            ]);
            const chunks = [];
            let stderrBuf = "";
            proc.stdout.on("data", (chunk)=>{
                chunks.push(chunk);
            });
            proc.stderr.on("data", (chunk)=>{
                // Capture for diagnostics on failure; ffmpeg emits its banner
                // and any errors here. -loglevel error keeps it quiet on success.
                stderrBuf += chunk.toString("utf8");
            });
            proc.on("error", (err)=>{
                serverLogger.warn("ffmpeg spawn failed for DJ analysis", {
                    trackPath,
                    error: err.message
                });
                resolve(new Float32Array(0));
            });
            proc.on("close", (code)=>{
                if (code !== 0) {
                    serverLogger.warn("ffmpeg decode failed for DJ analysis", {
                        trackPath,
                        exitCode: code,
                        stderr: stderrBuf.slice(0, 500)
                    });
                    resolve(new Float32Array(0));
                    return;
                }
                const pcm = Buffer.concat(chunks);
                const sampleCount = Math.floor(pcm.length / 2);
                const out = new Float32Array(sampleCount);
                for(let i = 0; i < sampleCount; i++){
                    // Int16 range is [-32768, 32767]. Dividing by 32768 gives
                    // [-1, 1) — close enough for analysis (the rare +1 sample is
                    // mapped to the sample below).
                    out[i] = pcm.readInt16LE(i * 2) / 32768;
                }
                resolve(out);
            });
        });
    }
    /**
   * Analyze BPM via the Python ffmpeg-based detector (headless-safe).
   *
   * BPM is derived from the file directly (the script decodes via ffmpeg),
   * so the decoded JS `audioBuffer` isn't used here — it's kept in the
   * signature for symmetry with analyzeKey/calculateEnergy which do use it.
   */ async analyzeBPM(_audioBuffer, trackPath) {
        if (!trackPath) {
            return {
                bpm: 0,
                confidence: 0
            };
        }
        try {
            const python3 = resolvePython3Path();
            const { stdout, stderr } = await execFileAsync(python3, [
                BPM_DETECTOR_SCRIPT,
                trackPath
            ], {
                timeout: BPM_DETECTOR_TIMEOUT_MS,
                // The script shells out to bare `ffmpeg`; buildDetectorPath prepends
                // the bundled bin dir (+ Homebrew/system dirs on darwin/linux) using
                // the platform's own path delimiter so it resolves on Windows too.
                env: {
                    ...process.env,
                    PATH: buildDetectorPath()
                }
            });
            if (stderr) {
                serverLogger.warn("BPM detector stderr", {
                    trackPath,
                    stderr: stderr.toString().slice(0, 200)
                });
            }
            return parseBpmDetectorOutput(stdout.toString());
        } catch (error) {
            serverLogger.error("BPM analysis failed", {
                trackPath,
                error: error instanceof Error ? error.message : String(error)
            });
            return {
                bpm: 0,
                confidence: 0
            };
        }
    }
    /**
   * Analyze key using Essentia.js
   */ async analyzeKey(audioBuffer) {
        // Route through the worker proxy. Returns null on:
        //   - SOUND_DISABLE_ESSENTIA=true
        //   - worker can't load essentia
        //   - worker is in respawn-throttled cooldown (recent crash)
        //   - per-request timeout
        //   - worker crashed mid-analysis (caught by on('exit'))
        // We surface those as a soft "no key detected" rather than failing the
        // whole DJ analysis pipeline — BPM and energy stay valid.
        const keyData = await this.essentiaProxy.analyze(audioBuffer);
        if (!keyData) {
            return {
                confidence: 0
            };
        }
        const key = `${keyData.key}${keyData.scale === "major" ? "" : "m"}`;
        const camelotCode = CAMELOT_WHEEL[key];
        return {
            key,
            camelotCode,
            confidence: keyData.strength || 0.7
        };
    }
    /**
   * Calculate energy level (0-1)
   */ calculateEnergy(bpm, audioBuffer) {
        // Simple energy calculation based on BPM and RMS
        let rms = 0;
        for(let i = 0; i < audioBuffer.length; i++){
            rms += audioBuffer[i] * audioBuffer[i];
        }
        rms = Math.sqrt(rms / audioBuffer.length);
        // Normalize BPM component (60-200 BPM range)
        const bpmEnergy = Math.min(1, Math.max(0, (bpm - 60) / 140));
        // Combine RMS and BPM
        return (rms + bpmEnergy) / 2;
    }
    /**
   * Calculate danceability (0-1)
   */ calculateDanceability(bpm, energy) {
        // Optimal dance BPM is 120-130
        let bpmScore = 1;
        if (bpm < 120) {
            bpmScore = Math.max(0, (bpm - 60) / 60);
        } else if (bpm > 130) {
            bpmScore = Math.max(0, (200 - bpm) / 70);
        }
        // Combine BPM score with energy
        return bpmScore * 0.7 + energy * 0.3;
    }
    /**
   * Store analysis results in database
   */ async storeAnalysis(trackPath, analysis) {
        const db = getDatabase();
        // Write to `audio_analysis` — the canonical table every consumer reads:
        // djRoutes (/dj/tracks, /dj/analysis-progress, /dj/analysis/:id,
        // /dj/generate-mix), similarTracksRoutes, smartPlaylistsRoutes and
        // playbackContractRoutes. It's created in database.ts's core schema (with a
        // tracks FK + indexes), so we only INSERT here.
        //
        // This service previously wrote a private `dj_analysis` table that NOTHING
        // read, so every analyzed BPM/key vanished from the app's point of view —
        // the progress endpoint counted `audio_analysis` and saw it frozen. Column
        // map: key→musical_key, confidence.{bpm,key}→{bpm,key}_confidence. valence
        // isn't computed by this pipeline; harmonic_keys is derived at query time.
        const stmt = db.db.prepare(`
      INSERT OR REPLACE INTO audio_analysis
        (track_path, bpm, musical_key, camelot_code, energy, danceability,
         valence, bpm_confidence, key_confidence, analyzed_at)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `);
        stmt.run(trackPath, analysis.bpm ?? null, analysis.key ?? null, analysis.camelotCode ?? null, analysis.energy ?? null, analysis.danceability ?? null, null, analysis.confidence?.bpm ?? null, analysis.confidence?.key ?? null, Math.floor(Date.now() / 1000));
    }
    /**
   * Populate integrated loudness (LUFS) + true peak (dBTP) on the `tracks` row so
   * WAVE clients can do ReplayGain-style volume normalization. Loudness lives on
   * `tracks` (NOT audio_analysis) — it's a per-file acoustic fact, alongside the
   * scan-time replaygain_* tags.
   *
   * Resumable + idempotent: no-ops when tracks.loudness is already set, so it's
   * safe to call on every analyzeTrack pass and the backfill re-selects only the
   * rows that still need it (WHERE loudness IS NULL).
   *
   * Two sources, cheapest first:
   *   1. Existing ReplayGain tags (no audio decode). RG reference is -18 LUFS, so
   *      integrated LUFS ≈ -18 - replaygain_track_gain(dB). replaygain_track_peak
   *      is a linear ratio → true peak(dBTP) ≈ 20*log10(peak). (Sample peak, not a
   *      strict 4x-oversampled true peak, but a good clip-safety approximation.)
   *   2. ffmpeg loudnorm measurement (extractLoudness via measureLoudness) when no
   *      RG gain tag is present. input_i → loudness, input_tp → loudness_true_peak.
   */ async populateLoudness(trackPath) {
        const db = getDatabase();
        const row = db.db.prepare("SELECT loudness, replaygain_track_gain, replaygain_track_peak FROM tracks WHERE path = ?").get(trackPath);
        // Track missing (deleted between enqueue and run) or loudness already known → skip.
        if (!row || row.loudness != null) return;
        let loudness = null;
        let truePeak = null;
        // Source 1 — derive from ReplayGain tags (cheap, no decode).
        if (typeof row.replaygain_track_gain === "number" && Number.isFinite(row.replaygain_track_gain)) {
            loudness = -18 - row.replaygain_track_gain;
            if (typeof row.replaygain_track_peak === "number" && Number.isFinite(row.replaygain_track_peak) && row.replaygain_track_peak > 0) {
                truePeak = 20 * Math.log10(row.replaygain_track_peak);
            }
        } else {
            // Source 2 — measure with ffmpeg loudnorm.
            const measured = await audioFeatureExtraction.measureLoudness(trackPath);
            loudness = measured.integratedLufs;
            truePeak = measured.truePeak;
        }
        // Nothing usable (no RG gain AND ffmpeg couldn't measure) → leave NULL so a
        // later pass can retry.
        if (loudness == null) return;
        db.db.prepare("UPDATE tracks SET loudness = ?, loudness_true_peak = ? WHERE path = ?").run(loudness, truePeak, trackPath);
        serverLogger.info("Stored loudness", {
            trackPath,
            loudnessLufs: loudness,
            truePeakDb: truePeak,
            source: typeof row.replaygain_track_gain === "number" ? "replaygain-tag" : "ffmpeg-loudnorm"
        });
    }
    /**
   * Find tracks with similar BPM
   */ async findSimilarBPM(targetBPM, tolerance = 5) {
        const db = getDatabase();
        const stmt = db.db.prepare(`
      SELECT
        t.*,
        a.bpm,
        a.musical_key AS key,
        a.camelot_code,
        a.energy,
        a.danceability,
        ABS(a.bpm - ?) as bpm_diff
      FROM tracks t
      JOIN audio_analysis a ON t.path = a.track_path
      WHERE a.bpm BETWEEN ? AND ?
      ORDER BY bpm_diff ASC
      LIMIT 50
    `);
        return stmt.all(targetBPM, targetBPM - tolerance, targetBPM + tolerance);
    }
    /**
   * Find harmonically compatible tracks
   */ async findHarmonicMatches(camelotCode) {
        const db = getDatabase();
        const compatibleKeys = getHarmonicKeys(camelotCode);
        const placeholders = compatibleKeys.map(()=>"?").join(",");
        const stmt = db.db.prepare(`
      SELECT
        t.*,
        a.bpm,
        a.musical_key AS key,
        a.camelot_code,
        a.energy,
        a.danceability
      FROM tracks t
      JOIN audio_analysis a ON t.path = a.track_path
      WHERE a.camelot_code IN (${placeholders})
      ORDER BY a.energy DESC
      LIMIT 50
    `);
        return stmt.all(...compatibleKeys);
    }
    /**
   * Batch analyze all tracks in library
   */ async analyzeLibrary(progressCallback) {
        const db = getDatabase();
        // Get tracks needing analysis: never analyzed, OR analyzed for BPM only
        // (musical_key IS NULL), OR missing loudness (t.loudness IS NULL — for
        // ReplayGain normalization; populateLoudness fills it inside analyzeTrack).
        // The musical_key case backfills keys onto rows written by the BPM-only
        // stopgap (scripts/analyze-one.sh) without redoing tracks that already have
        // a key; the loudness case is cheap when RG tags exist (no decode) and
        // skips quickly for rows that already have BPM+key+loudness.
        //
        // ORDER BY play priority: the loudness backfill is a multi-day, ffmpeg-decode-
        // bound pass (one decode per track, even on the BPM/key fast path), so fill the
        // tracks users actually play FIRST. The played head of the library gets
        // ReplayGain within the hour while the long tail fills over days; a track with
        // no play history sorts last (arbitrary order among itself). Uses the indexed
        // playback_history(track_id) as a one-time aggregate at backfill start.
        const tracks = db.db.prepare(`
      SELECT t.path
      FROM tracks t
      LEFT JOIN audio_analysis a ON t.path = a.track_path
      LEFT JOIN (
        SELECT track_id, COUNT(*) AS plays, MAX(played_at) AS last_played
        FROM playback_history
        GROUP BY track_id
      ) ph ON ph.track_id = t.path
      WHERE a.track_path IS NULL OR a.musical_key IS NULL OR t.loudness IS NULL
      ORDER BY
        CASE WHEN ph.plays IS NULL THEN 1 ELSE 0 END,
        ph.plays DESC,
        ph.last_played DESC
    `).all();
        const total = tracks.length;
        serverLogger.info(`Starting DJ analysis for ${total} tracks`);
        // Enqueue every track WITHOUT awaiting between adds. analyzeTrack() wraps
        // this.queue.add(), so the PQueue (concurrency = SOUND_ANALYSIS_CONCURRENCY)
        // runs that many in parallel. The previous `for … await analyzeTrack()`
        // awaited each task's completion before enqueuing the next, which serialized
        // the whole backfill to concurrency 1 (the concurrency setting did nothing).
        // We float the per-track promises and wait for the queue to drain via onIdle().
        let completed = 0;
        for (const track of tracks){
            void this.analyzeTrack(track.path).then(()=>{
                completed++;
                if (progressCallback) progressCallback(completed / total * 100);
            }).catch((error)=>{
                serverLogger.error("Failed to analyze track", {
                    path: track.path,
                    error
                });
            });
        }
        await this.queue.onIdle();
        serverLogger.info("Library DJ analysis complete", {
            analyzed: completed
        });
    }
}
// Singleton instance
let djAnalysisService = null;
export function getDJAnalysisService() {
    if (!djAnalysisService) {
        djAnalysisService = new DJAnalysisService();
    }
    return djAnalysisService;
}
export { djAnalysisService };

//# sourceMappingURL=djAnalysisService.js.map