import { Router } from 'express';
import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs/promises';
import { createReadStream } from 'fs';
import { getFFmpegPath, getFFprobePath, isFFmpegAvailable } from '../utils/ffmpegPaths.js';
import { getUserDataPath } from '../utils/platformPaths.js';
import { validateSafeFilePath, isAudioFile } from '../utils/pathValidation.js';
import { getAllowedMusicPaths } from '../utils/security.js';
import { validateApiKey } from './mobileStream.js';
const router = Router();
const HLS_SEGMENT_SECONDS = 10;
// Cache directory for HLS segments - use proper app data path, not cwd
const HLS_CACHE_DIR = path.join(getUserDataPath(), 'hls-cache');
// Tracks in-flight ffmpeg segment generation, keyed by cacheKey.
// The segment handler consults this to decide whether to wait for a
// missing segment or 404 immediately.
const inflightTranscodes = new Map();
/**
 * Build a cache key that invalidates when the source file is replaced.
 * Includes mtime so a re-ripped file can't hit a stale playlist/segments.
 */ function buildCacheKey(trackPath, mtimeMs) {
    const base = Buffer.from(trackPath).toString('base64').replace(/[^a-zA-Z0-9]/g, '_');
    return `${base}_${Math.floor(mtimeMs)}`;
}
/**
 * Probe the track's duration in seconds. Fast (single metadata read,
 * typically under 200ms) compared to a full transcode.
 */ async function probeDurationSeconds(filePath) {
    return new Promise((resolve)=>{
        const probe = spawn(getFFprobePath(), [
            '-v',
            'error',
            '-show_entries',
            'format=duration',
            '-of',
            'default=noprint_wrappers=1:nokey=1',
            filePath
        ]);
        let out = '';
        let settled = false;
        const done = (result)=>{
            if (settled) return;
            settled = true;
            resolve(result);
        };
        probe.stdout.on('data', (d)=>{
            out += d.toString();
        });
        probe.on('close', (code)=>{
            if (code !== 0) return done(null);
            const n = parseFloat(out.trim());
            done(Number.isFinite(n) && n > 0 ? n : null);
        });
        probe.on('error', ()=>done(null));
        // Hard ceiling — local ffprobe should finish in <1s for sane files.
        setTimeout(()=>{
            try {
                probe.kill('SIGKILL');
            } catch  {}
            done(null);
        }, 5000);
    });
}
/**
 * Build an m3u8 playlist for a given duration without invoking ffmpeg.
 * Segment filenames match what ffmpeg will write (`${cacheKey}_%03d.ts`).
 */ function synthesizeM3U8(cacheKey, durationSec) {
    const segCount = Math.max(1, Math.ceil(durationSec / HLS_SEGMENT_SECONDS));
    const lines = [
        '#EXTM3U',
        '#EXT-X-VERSION:3',
        `#EXT-X-TARGETDURATION:${HLS_SEGMENT_SECONDS}`,
        '#EXT-X-MEDIA-SEQUENCE:0',
        '#EXT-X-PLAYLIST-TYPE:VOD'
    ];
    for(let i = 0; i < segCount; i++){
        const isLast = i === segCount - 1;
        const segDur = isLast ? Math.max(0.001, durationSec - i * HLS_SEGMENT_SECONDS) : HLS_SEGMENT_SECONDS;
        lines.push(`#EXTINF:${segDur.toFixed(3)},`);
        lines.push(`${cacheKey}_${String(i).padStart(3, '0')}.ts`);
    }
    lines.push('#EXT-X-ENDLIST');
    return lines.join('\n') + '\n';
}
/**
 * Spawn ffmpeg in the background to write the actual segment files.
 * Idempotent: repeated calls for the same cacheKey return the same promise.
 */ // Cap concurrent HLS transcodes. Each ffmpeg is CPU-heavy on the shared NAS; an
// unbounded fan-out of cold HLS starts pins every core and trips the health
// watchdog into a restart loop — the exact failure mobileStream's cap prevents.
const MAX_HLS_TRANSCODES = 2;
function startSegmentGeneration(fullPath, cacheKey) {
    const existing = inflightTranscodes.get(cacheKey);
    if (existing) return existing;
    // Over the concurrency cap: don't spawn an (N+1)th ffmpeg. The segment stays
    // missing; the segment handler detects this deferred-by-cap state (no in-flight
    // entry for this key WHILE inflightTranscodes.size >= MAX_HLS_TRANSCODES) and
    // returns 503 + Retry-After — NOT a fatal 404 — so the client retries and its
    // retry starts the transcode once a slot frees. inflightTranscodes.size is the
    // live active count (each entry is removed on completion).
    if (inflightTranscodes.size >= MAX_HLS_TRANSCODES) {
        console.info('[HLS] Transcode cap reached, deferring:', {
            cacheKey,
            active: inflightTranscodes.size
        });
        return Promise.resolve();
    }
    const ffmpegPath = getFFmpegPath();
    const segmentPattern = path.join(HLS_CACHE_DIR, `${cacheKey}_%03d.ts`);
    // ffmpeg insists on writing a playlist; we write ours ourselves and
    // ignore this scratch file. Kept under the same cache dir so it's
    // cleared by DELETE /hls/cache.
    const scratchPlaylist = path.join(HLS_CACHE_DIR, `${cacheKey}.ffmpeg.m3u8`);
    const args = [
        '-i',
        fullPath,
        '-vn',
        // Cap per-process CPU so N concurrent transcodes can't pin every core on the
        // shared NAS. 2 (not 1) keeps first-segment latency under the client's
        // cold-start grace.
        '-threads',
        '2',
        '-c:a',
        'aac',
        '-b:a',
        '256k',
        '-f',
        'hls',
        '-hls_time',
        String(HLS_SEGMENT_SECONDS),
        '-hls_list_size',
        '0',
        '-hls_segment_filename',
        segmentPattern,
        scratchPlaylist
    ];
    console.info('[HLS] Background transcode start:', {
        cacheKey,
        fullPath
    });
    const proc = spawn(ffmpegPath, args);
    let stderrTail = '';
    proc.stderr.on('data', (d)=>{
        stderrTail += d.toString();
        // Keep only the last ~2KB so long-running transcodes don't grow memory.
        if (stderrTail.length > 2048) stderrTail = stderrTail.slice(-2048);
    });
    const promise = new Promise((resolve, reject)=>{
        proc.on('close', (code)=>{
            if (code === 0) {
                console.info('[HLS] Background transcode complete:', cacheKey);
                resolve();
            } else {
                console.error('[HLS] Background transcode failed:', {
                    cacheKey,
                    code,
                    stderr: stderrTail.slice(-500)
                });
                reject(new Error(`ffmpeg exited with code ${code}`));
            }
        });
        proc.on('error', (err)=>{
            console.error('[HLS] Background transcode spawn error:', {
                cacheKey,
                err
            });
            reject(err);
        });
    }).finally(()=>{
        inflightTranscodes.delete(cacheKey);
    });
    inflightTranscodes.set(cacheKey, promise);
    return promise;
}
/**
 * Poll for a file to appear on disk, up to timeoutMs.
 */ async function waitForFile(filePath, timeoutMs) {
    const deadline = Date.now() + timeoutMs;
    while(Date.now() < deadline){
        try {
            await fs.access(filePath);
            return true;
        } catch  {}
        await new Promise((r)=>setTimeout(r, 200));
    }
    return false;
}
/**
 * Resolve a `:trackPath(*)` route param to a validated absolute source path,
 * or null if it fails security validation. Mirrors the playlist handler's
 * decode + allow-list + audio-file checks so a segment request can safely
 * (re)start transcoding on its own — without depending on a prior playlist
 * request having seeded inflightTranscodes.
 */ function resolveTrackSourcePath(rawTrackPath, scanPath) {
    if (!rawTrackPath) return null;
    let trackPath = rawTrackPath;
    try {
        if (trackPath.includes('%')) trackPath = decodeURIComponent(trackPath);
    } catch  {}
    if (!trackPath.startsWith('/')) trackPath = '/' + trackPath;
    const allowedPaths = getAllowedMusicPaths(scanPath);
    if (!validateSafeFilePath(trackPath, allowedPaths)) return null;
    if (!isAudioFile(trackPath)) return null;
    return trackPath;
}
// API key authentication middleware.
//
// HLS routes are mounted at /api and served over the PUBLIC Cloudflare tunnel,
// so a presence-only check exposed the entire library to any non-empty key
// (and left DELETE /hls/cache as an unauthenticated cache-wipe DoS). This reuses
// the exact validator the mobile transcode path uses — owner constant-time
// compare + family-member lookup, dev-mode passthrough only when no key is
// configured — so HLS and mobile auth share one source of truth and can't drift.
// WAVE attaches config.apiKey as `apiKey=` on HLS URLs; the extra param names
// mirror the mobile path for callers that use key/token/api_key.
const authenticateRequest = (req, res, next)=>{
    const headerKey = req.headers['x-api-key'];
    const apiKey = req.query.apiKey || req.query.key || req.query.token || req.query.api_key || (Array.isArray(headerKey) ? headerKey[0] : headerKey);
    if (!validateApiKey(apiKey, req)) {
        return res.status(401).json({
            error: 'Unauthorized - Invalid API key'
        });
    }
    next();
};
/**
 * HLS Streaming for iOS and Web
 *
 * Converts audio files to HLS format on-the-fly for native playback.
 * iOS has special optimizations for HLS that don't count against background CPU limits.
 * Web uses HLS for tunnel-safe streaming through Cloudflare.
 */ // Ensure cache directory exists
async function ensureCacheDir() {
    try {
        await fs.mkdir(HLS_CACHE_DIR, {
            recursive: true
        });
        console.info('[HLS] Cache directory ready:', HLS_CACHE_DIR);
    } catch (error) {
        console.error('[HLS] Failed to create cache directory:', error);
    }
}
ensureCacheDir();
// HLS cache eviction.
//
// Each track served via HLS produces an .m3u8 playlist plus 10-second .ts
// segments. A 5-minute song = ~30 segments at ~256 kbps AAC = ~10 MB on
// disk. With no eviction, sustained listening fills the cache directory
// indefinitely — observed multi-GB after a few weeks of normal use, and
// directly contributes to system-disk pressure on the Mac mini (a
// recurring jetsam trigger).
//
// Strategy: cap by total directory size. Periodically sweep, sort entries
// by mtime, and unlink oldest until under the cap. The mtime is updated
// when ffmpeg writes each segment, so frequently-played tracks stay warm.
// Files for in-flight transcodes are skipped.
const HLS_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2 GB cap
const HLS_CACHE_SWEEP_INTERVAL_MS = 30 * 60 * 1000; // every 30 min
async function sweepHlsCache() {
    try {
        let entries;
        try {
            const names = await fs.readdir(HLS_CACHE_DIR);
            entries = await Promise.all(names.map(async (name)=>{
                const full = path.join(HLS_CACHE_DIR, name);
                const st = await fs.stat(full);
                // Derive the cacheKey for in-flight check. m3u8 + .ts both follow
                // `<cacheKey>...` pattern; for .ts it's `<cacheKey>_NNN.ts`, for
                // m3u8 it's `<cacheKey>.m3u8` or `<cacheKey>.ffmpeg.m3u8`.
                let cacheKey = name.replace(/\.m3u8$/, "").replace(/\.ffmpeg$/, "");
                cacheKey = cacheKey.replace(/_\d+\.ts$/, "");
                return {
                    path: full,
                    size: st.size,
                    mtime: st.mtimeMs,
                    cacheKey
                };
            }));
        } catch (err) {
            // Cache dir may not exist yet on first call — ignore.
            return;
        }
        let total = entries.reduce((s, e)=>s + e.size, 0);
        if (total <= HLS_CACHE_MAX_BYTES) {
            return; // under cap, nothing to do
        }
        // Sort oldest first.
        entries.sort((a, b)=>a.mtime - b.mtime);
        let evicted = 0;
        let evictedBytes = 0;
        for (const e of entries){
            if (total <= HLS_CACHE_MAX_BYTES) break;
            // Don't touch files for tracks currently transcoding.
            if (inflightTranscodes.has(e.cacheKey)) continue;
            try {
                await fs.unlink(e.path);
                total -= e.size;
                evicted++;
                evictedBytes += e.size;
            } catch  {
            // Already gone or inaccessible — skip
            }
        }
        if (evicted > 0) {
            console.info("[HLS] Cache sweep evicted", {
                files: evicted,
                bytes: evictedBytes,
                remainingBytes: total,
                capBytes: HLS_CACHE_MAX_BYTES
            });
        }
    } catch (err) {
        console.warn("[HLS] Cache sweep failed:", err);
    }
}
// Run a sweep at startup (catches any growth from prior sessions) and then
// on a regular interval. unref() so the timer doesn't keep the process
// alive on its own.
sweepHlsCache();
const hlsCacheSweepTimer = setInterval(sweepHlsCache, HLS_CACHE_SWEEP_INTERVAL_MS);
hlsCacheSweepTimer.unref?.();
/**
 * Serve HLS segment
 * IMPORTANT: This route MUST come before the playlist route to prevent wildcard matching issues
 */ router.get('/hls/:trackPath(*)/segment/:segmentFile', authenticateRequest, async (req, res)=>{
    try {
        const segmentFile = req.params.segmentFile;
        // Validate segment filename shape: `<cacheKey>_NNN.ts` where cacheKey is
        // alphanumeric+underscore. Rejects traversal and other junk by construction.
        if (!/^[A-Za-z0-9_]+_\d+\.ts$/.test(segmentFile)) {
            return res.status(400).json({
                error: 'Invalid segment file'
            });
        }
        // SECURITY: Defense-in-depth check on top of the regex above — explicitly
        // reject any path-traversal or separator chars in the segment filename.
        if (segmentFile.includes('..') || segmentFile.includes('/') || segmentFile.includes('\\') || segmentFile.includes('\0')) {
            console.warn('[HLS] Path traversal attempt in segment filename:', segmentFile);
            return res.status(403).json({
                error: 'Invalid segment filename'
            });
        }
        const segmentPath = path.join(HLS_CACHE_DIR, segmentFile);
        // Derive the cacheKey by stripping the `_NNN.ts` suffix.
        const cacheKey = segmentFile.replace(/_\d+\.ts$/, '');
        // SECURITY: Verify resolved path stays inside the HLS cache directory.
        const resolvedSegmentPath = path.resolve(segmentPath);
        const resolvedCacheDir = path.resolve(HLS_CACHE_DIR);
        if (!resolvedSegmentPath.startsWith(resolvedCacheDir + path.sep) && resolvedSegmentPath !== resolvedCacheDir) {
            console.warn('[HLS] Segment path escaped cache directory:', resolvedSegmentPath);
            return res.status(403).json({
                error: 'Access denied'
            });
        }
        let exists = false;
        try {
            await fs.access(segmentPath);
            exists = true;
        } catch  {}
        // Cold start: the segment isn't on disk yet. Make sure a transcode is
        // actually running for this track before we wait. This is what unbreaks
        // first-play over the tunnel: previously, if a segment was requested and
        // no transcode happened to be in flight (e.g. the segment was requested
        // before/without a playlist hit, or segments were swept from disk), we
        // 404'd immediately. Deriving the source path from the route lets a
        // segment request kick off its own generation.
        if (!exists && !inflightTranscodes.has(cacheKey)) {
            const srcPath = resolveTrackSourcePath(req.params.trackPath, req.app.locals.appState?.getLastScanPath?.());
            if (srcPath) {
                // Fire and forget — sets inflightTranscodes synchronously so the wait
                // below engages. Errors are logged inside startSegmentGeneration.
                startSegmentGeneration(srcPath, cacheKey).catch(()=>{});
            }
        }
        // If ffmpeg is generating segments for this track, wait for this one
        // rather than failing. Cold seg0 of a long lossless track on a busy NAS
        // CPU can take well over 15s, so give it real headroom — still under the
        // native HLS clients' per-segment patience (AVPlayer, hls.js).
        if (!exists && inflightTranscodes.has(cacheKey)) {
            exists = await waitForFile(segmentPath, 30000);
        }
        if (!exists) {
            // Distinguish "not ready yet" from "genuinely absent". A 404 is FATAL to
            // native HLS players (AVPlayer) — they abandon HLS and fall back to the
            // tunnel-hostile progressive `/api/stream/mobile/` transcode, which is
            // exactly the "spotty playback" failure. Return 503 + Retry-After (client
            // retries the same segment and stays on HLS) whenever the segment is still
            // coming: EITHER a transcode is actively in flight, OR this track's
            // transcode was DEFERRED because we're at MAX_HLS_TRANSCODES (startSegment
            // Generation is a no-op when capped, so has(cacheKey) is false even though
            // work is pending — the client's retry starts it once a slot frees). Only
            // a genuinely-absent segment WITH spare transcode capacity gets the 404.
            const transcoding = inflightTranscodes.has(cacheKey);
            const deferredByCap = !transcoding && inflightTranscodes.size >= MAX_HLS_TRANSCODES;
            const retryable = transcoding || deferredByCap;
            console.warn('[HLS] Segment not ready:', {
                segmentFile,
                transcoding,
                deferredByCap
            });
            res.setHeader('Retry-After', '1');
            return res.status(retryable ? 503 : 404).json({
                error: retryable ? 'Segment not ready, retrying' : 'Segment not found'
            });
        }
        res.setHeader('Content-Type', 'video/mp2t');
        res.setHeader('Cache-Control', 'public, max-age=31536000'); // Cache segments forever
        const stream = createReadStream(segmentPath);
        stream.on('error', (err)=>{
            console.error('[HLS] Segment stream error:', err);
            if (!res.headersSent) {
                res.status(500).json({
                    error: 'Failed to read segment'
                });
            } else {
                res.destroy();
            }
        });
        stream.pipe(res);
    } catch (error) {
        console.error('[HLS] Segment delivery failed:', error);
        if (!res.headersSent) {
            res.status(500).json({
                error: 'Failed to serve segment'
            });
        }
    }
});
/**
 * Generate HLS playlist (m3u8) for a track.
 *
 * The playlist is synthesized from a fast ffprobe duration read and returned
 * to the client immediately (~100-300ms). Actual segment transcoding runs
 * in the background; the segment handler waits briefly for segments to
 * appear on disk. This keeps us inside the client's 10s request budget on
 * Cloudflare tunnels even for long lossless tracks that would take 20-30s
 * to fully transcode.
 */ router.get('/hls/:trackPath(*)/playlist.m3u8', authenticateRequest, async (req, res)=>{
    try {
        // Express decodes route params, so %2FVolumes becomes /Volumes
        // The path from the manifest is already absolute (/Volumes/...)
        // Don't prepend '/' if the path already starts with '/'
        let trackPath = req.params.trackPath;
        // URL-decode in case of double encoding (e.g., %252F -> %2F -> /)
        try {
            // Check if it looks like it's still encoded
            if (trackPath.includes('%')) {
                trackPath = decodeURIComponent(trackPath);
            }
        } catch  {
        // Already decoded, ignore
        }
        // Ensure path is absolute (starts with /)
        if (!trackPath.startsWith('/')) {
            trackPath = '/' + trackPath;
        }
        const fullPath = trackPath;
        // SECURITY: Validate path against allowed directories and check for traversal.
        const allowedPaths = getAllowedMusicPaths(req.app.locals.appState?.getLastScanPath?.());
        if (!validateSafeFilePath(fullPath, allowedPaths)) {
            console.warn('[HLS] Path traversal or unauthorized access blocked:', fullPath);
            return res.status(403).json({
                error: 'Access denied'
            });
        }
        // SECURITY: Only allow audio files.
        if (!isAudioFile(fullPath)) {
            console.warn('[HLS] Non-audio file access blocked:', fullPath);
            return res.status(403).json({
                error: 'Invalid file type'
            });
        }
        console.info('[HLS] Playlist request:', {
            raw: req.params.trackPath,
            decoded: trackPath
        });
        // Stat the file once — we need both existence and mtime (mtime goes
        // into the cache key so replacing a file invalidates old segments).
        let mtimeMs;
        try {
            const st = await fs.stat(fullPath);
            if (!st.isFile()) {
                return res.status(404).json({
                    error: 'Track not found'
                });
            }
            mtimeMs = st.mtimeMs;
        } catch  {
            return res.status(404).json({
                error: 'Track not found'
            });
        }
        if (!isFFmpegAvailable()) {
            console.error('[HLS] ffmpeg is not available!');
            return res.status(500).json({
                error: 'ffmpeg not available for HLS transcoding'
            });
        }
        const cacheKey = buildCacheKey(trackPath, mtimeMs);
        const playlistPath = path.join(HLS_CACHE_DIR, `${cacheKey}.m3u8`);
        // Load or synthesize the playlist. The file on disk contains bare
        // segment names (`${cacheKey}_NNN.ts`); we rewrite them to full
        // authenticated URLs per-request since apiKey may vary.
        let playlist;
        try {
            playlist = await fs.readFile(playlistPath, 'utf-8');
        } catch  {
            const durationSec = await probeDurationSeconds(fullPath);
            if (!durationSec) {
                console.error('[HLS] ffprobe failed to read duration:', fullPath);
                return res.status(500).json({
                    error: 'Failed to probe track duration'
                });
            }
            playlist = synthesizeM3U8(cacheKey, durationSec);
            // Best-effort persist so subsequent requests skip the probe.
            try {
                await fs.writeFile(playlistPath, playlist, 'utf-8');
            } catch (writeErr) {
                console.warn('[HLS] Failed to persist synthesized playlist (continuing):', writeErr);
            }
            console.info('[HLS] Synthesized playlist:', {
                cacheKey,
                durationSec,
                segments: playlist.match(/\.ts$/gm)?.length
            });
        }
        // Ensure segments are being produced. If segment 0 is missing and no
        // transcode is in flight, kick one off. This covers: fresh request,
        // and the case where the m3u8 file survived but segments were
        // cleared (disk cleanup, etc.).
        const firstSegmentPath = path.join(HLS_CACHE_DIR, `${cacheKey}_000.ts`);
        let firstSegmentExists = false;
        try {
            await fs.access(firstSegmentPath);
            firstSegmentExists = true;
        } catch  {}
        if (!firstSegmentExists && !inflightTranscodes.has(cacheKey)) {
            // Fire and forget — don't await. Errors are logged inside.
            startSegmentGeneration(fullPath, cacheKey).catch(()=>{});
        }
        // Rewrite segment references to full authenticated URLs.
        const apiKey = req.query.apiKey || req.headers['x-api-key'];
        const encodedTrackPath = trackPath.split('/').map((s)=>encodeURIComponent(s)).join('/');
        const rewritten = playlist.replace(/^([^#\n].*\.ts)$/gm, (match)=>{
            return `/api/hls${encodedTrackPath}/segment/${match}?apiKey=${apiKey}`;
        });
        res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
        res.setHeader('Cache-Control', 'public, max-age=3600');
        res.send(rewritten);
    } catch (error) {
        const errorMessage = error instanceof Error ? error.message : String(error);
        console.error('[HLS] Playlist generation failed:', errorMessage);
        if (!res.headersSent) {
            res.status(500).json({
                error: 'Failed to generate HLS playlist',
                details: errorMessage,
                ffmpegPath: getFFmpegPath(),
                cacheDir: HLS_CACHE_DIR
            });
        }
    }
});
/**
 * Clear HLS cache
 */ router.delete('/hls/cache', authenticateRequest, async (req, res)=>{
    try {
        const files = await fs.readdir(HLS_CACHE_DIR);
        await Promise.all(files.map((file)=>fs.unlink(path.join(HLS_CACHE_DIR, file))));
        console.info('[HLS] Cache cleared:', files.length, 'files');
        res.json({
            success: true,
            filesDeleted: files.length
        });
    } catch (error) {
        console.error('[HLS] Cache clear failed:', error);
        res.status(500).json({
            error: 'Failed to clear cache'
        });
    }
});
export default router;

//# sourceMappingURL=hlsStream.js.map