"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
    if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TranscodingService = void 0;
const common_1 = require("@nestjs/common");
const node_path_1 = require("node:path");
const constants_1 = require("../constants");
const storage_core_1 = require("../cores/storage.core");
const decorators_1 = require("../decorators");
const enum_1 = require("../enum");
const base_service_1 = require("./base.service");
const database_1 = require("../utils/database");
const media_1 = require("../utils/media");
let TranscodingService = class TranscodingService extends base_service_1.BaseService {
    sessions = new Map();
    videoInterfaces = { dri: [], mali: false };
    cleanupInterval = null;
    async onBootstrap() {
        const [videoInterfaces] = await Promise.all([this.storageCore.getVideoInterfaces(), this.removeExpiredSessions()]);
        this.videoInterfaces = videoInterfaces;
    }
    onShutdown() {
        if (this.cleanupInterval) {
            clearInterval(this.cleanupInterval);
            this.cleanupInterval = null;
        }
        return Promise.all(this.sessions.values().map(({ id }) => this.onSessionEnd({ sessionId: id })));
    }
    onHlsSessionCleanup() {
        return this.removeExpiredSessions();
    }
    async onSessionRequest({ assetId, sessionId, ownerId }) {
        try {
            const expiresAt = new Date(Date.now() + constants_1.HLS_LEASE_DURATION_MS);
            await this.videoStreamRepository.createSession({ id: sessionId, assetId, expiresAt });
            this.sessions.set(sessionId, {
                assetId,
                expiresAt,
                id: sessionId,
                lastActivityTime: new Date(),
                lastClientRequestedSegment: null,
                lastCompletedSegment: null,
                ownerId,
                paused: false,
                process: null,
                starting: false,
                startSegment: null,
                variantIndex: null,
            });
            this.cleanupInterval ??= setInterval(() => void this.removeInactiveSessions(), constants_1.HLS_CLEANUP_INTERVAL_MS);
            this.websocketRepository.serverSend('HlsSessionResult', { sessionId });
        }
        catch (error) {
            if (!(0, database_1.isVideoStreamSessionPkConstraint)(error)) {
                this.logger.error(`Failed to create HLS session ${sessionId}: ${error}`);
                this.websocketRepository.serverSend('HlsSessionResult', { sessionId, error: 'Failed to create HLS session' });
            }
        }
    }
    async onSessionEnd({ sessionId }) {
        const session = this.sessions.get(sessionId);
        if (!session) {
            return;
        }
        this.sessions.delete(sessionId);
        if (this.cleanupInterval && this.sessions.size === 0) {
            clearInterval(this.cleanupInterval);
            this.cleanupInterval = null;
        }
        this.stopTranscode(session);
        await this.removeSessionDir(session);
        await this.videoStreamRepository.deleteSession(sessionId);
    }
    async onHeartbeat({ sessionId, segmentIndex }) {
        const session = this.sessions.get(sessionId);
        if (!session) {
            return;
        }
        session.lastActivityTime = new Date();
        if (segmentIndex !== undefined) {
            session.lastClientRequestedSegment = segmentIndex;
            this.applyBackpressure(session);
        }
        const remaining = session.expiresAt.getTime() - Date.now();
        if (remaining < constants_1.HLS_LEASE_DURATION_MS / 2) {
            session.expiresAt = new Date(Date.now() + constants_1.HLS_LEASE_DURATION_MS);
            await this.videoStreamRepository.extendSession(sessionId, session.expiresAt);
        }
    }
    async onSegmentRequest({ sessionId, variantIndex, segmentIndex }) {
        const session = this.sessions.get(sessionId);
        if (!session) {
            return;
        }
        session.variantIndex ??= variantIndex;
        session.startSegment ??= segmentIndex;
        const curSegment = session.lastCompletedSegment === null ? session.startSegment : session.lastCompletedSegment + 1;
        const isNeedsRestart = session.variantIndex !== variantIndex || segmentIndex < session.startSegment || segmentIndex > curSegment + 1;
        if (isNeedsRestart) {
            this.stopTranscode(session);
            session.variantIndex = variantIndex;
            session.startSegment = segmentIndex;
        }
        else if (session.process) {
            this.resumeTranscode(session);
            return;
        }
        else if (session.starting) {
            this.logger.debug(`Session ${sessionId} is already starting a transcode, skipping duplicate start request`);
            return;
        }
        session.starting = true;
        try {
            const process = await this.startTranscode(session, variantIndex, segmentIndex);
            if (process) {
                session.process = process;
            }
        }
        finally {
            session.starting = false;
        }
    }
    applyBackpressure(session) {
        if (session.lastCompletedSegment === null || session.lastClientRequestedSegment === null) {
            return;
        }
        const lead = session.lastCompletedSegment - session.lastClientRequestedSegment;
        this.logger.debug(`Session ${session.id} lead is ${lead} segments`);
        if (!session.paused && lead > constants_1.HLS_BACKPRESSURE_PAUSE_SEGMENTS) {
            this.pauseTranscode(session);
        }
        else if (session.paused && lead < constants_1.HLS_BACKPRESSURE_RESUME_SEGMENTS) {
            this.resumeTranscode(session);
        }
    }
    async startTranscode(session, variantIndex, startSegment) {
        const { ffmpeg } = await this.getConfig({ withCache: true });
        const asset = await this.videoStreamRepository.getForTranscoding(session.assetId);
        if (!asset) {
            this.logger.error(`Asset ${session.assetId} not found for HLS transcoding`);
            return;
        }
        if (session.variantIndex !== variantIndex || session.startSegment !== startSegment) {
            return;
        }
        const variant = constants_1.HLS_VARIANTS[variantIndex];
        if (!variant) {
            this.logger.error(`Variant ${variantIndex} out of range for asset ${session.assetId}`);
            await this.failSession(session, `Invalid variant index ${variantIndex}`);
            return;
        }
        const variantDir = storage_core_1.StorageCore.getHlsVariantFolder({
            ownerId: session.ownerId,
            sessionId: session.id,
            variantIndex,
        });
        this.storageRepository.mkdirSync(variantDir);
        const fps = (asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration;
        const gop = Math.ceil(constants_1.HLS_SEGMENT_DURATION * fps);
        const seekSeconds = startSegment > 0 ? (startSegment * gop - 0.5) / fps : 0;
        let config;
        try {
            config = media_1.BaseConfig.create({
                ...ffmpeg,
                targetVideoCodec: variant.codec,
                targetResolution: String(variant.resolution),
                maxBitrate: `${Math.round(variant.bitrate / 1000)}k`,
                gopSize: gop,
                crf: constants_1.HLS_CRF[variant.codec],
            }, this.videoInterfaces, { strictGop: true, lowLatency: true });
        }
        catch (error) {
            this.logger.error(`Failed to create transcode config for variant ${variantIndex} asset ${session.assetId}: ${error?.message ?? error}`);
            await this.failSession(session, `Failed to start transcode: ${error?.message ?? 'unknown error'}`);
            return;
        }
        const args = config.getHlsCommand({
            initFilename: 'init.mp4',
            inputPath: asset.originalPath,
            packetCount: asset.packets.packetCount,
            playlistFilename: (0, node_path_1.join)(variantDir, 'playlist.m3u8'),
            seekSeconds,
            segmentDuration: constants_1.HLS_SEGMENT_DURATION,
            segmentFilename: (0, node_path_1.join)(variantDir, 'seg_%d.m4s'),
            startSegment,
            target: enum_1.TranscodeTarget.All,
            timeBase: asset.videoStream.timeBase,
            totalDuration: asset.packets.totalDuration,
        }, asset.videoStream, asset.audioStream ?? undefined);
        this.logger.log(`Starting HLS transcode for asset ${session.assetId} variant ${variantIndex} with command: ffmpeg ${args.join(' ')}`);
        const process = this.processRepository.spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
        this.attachProcessHandlers(process, session, variantIndex);
        return process;
    }
    failSession(session, error) {
        this.websocketRepository.serverSend('HlsSessionResult', { sessionId: session.id, error });
        return this.onSessionEnd({ sessionId: session.id });
    }
    attachProcessHandlers(process, session, variantIndex) {
        let stderr = '';
        const variantDir = storage_core_1.StorageCore.getHlsVariantFolder({
            ownerId: session.ownerId,
            sessionId: session.id,
            variantIndex,
        });
        const watcher = this.storageRepository.watchDir(variantDir, (eventType, filename) => {
            if (eventType !== 'rename' || !filename || session.process !== process) {
                return;
            }
            const match = constants_1.HLS_SEGMENT_FILENAME_REGEX.exec(filename);
            if (!match) {
                return;
            }
            const segmentIndex = Number.parseInt(match[1]);
            const expected = session.lastCompletedSegment === null ? session.startSegment : session.lastCompletedSegment + 1;
            if (expected === null || segmentIndex !== expected) {
                return;
            }
            session.lastCompletedSegment = segmentIndex;
            this.websocketRepository.serverSend('HlsSegmentResult', {
                sessionId: session.id,
                variantIndex,
                segmentIndex,
            });
            this.applyBackpressure(session);
        });
        watcher.on('error', (error) => {
            this.logger.error(`watcher error for ${variantDir}: ${error}`);
        });
        process.stderr.on('data', (chunk) => {
            if (session.process !== process) {
                return;
            }
            stderr += chunk.toString();
        });
        process.on('exit', (code) => {
            watcher.close();
            if (session.process !== process || session.variantIndex !== variantIndex) {
                return;
            }
            session.paused = false;
            session.process = null;
            session.lastCompletedSegment = null;
            if (code) {
                this.logger.error(`FFmpeg exited with code ${code} for variant ${variantIndex} asset ${session.assetId}\n${stderr}`);
                void this.failSession(session, `Transcoding process exited unexpectedly with code ${code}`).catch((error) => this.logger.error(`Failed to end session ${session.id} after ffmpeg exit: ${error}`));
            }
        });
    }
    stopTranscode(session) {
        if (!session.process) {
            return;
        }
        session.process.kill('SIGKILL');
        session.process = null;
        session.lastCompletedSegment = null;
        session.paused = false;
        this.logger.debug(`Stopped transcoding for session ${session.id}`);
    }
    pauseTranscode(session) {
        if (session.paused || !session.process) {
            return;
        }
        session.process.kill('SIGSTOP');
        session.paused = true;
        this.logger.debug(`Paused transcoding for session ${session.id}`);
    }
    resumeTranscode(session) {
        if (!session.paused || !session.process) {
            return;
        }
        session.process.kill('SIGCONT');
        session.paused = false;
        this.logger.debug(`Resumed transcoding for session ${session.id}`);
    }
    async removeSessionDir(session) {
        const dir = storage_core_1.StorageCore.getHlsSessionFolder({ ownerId: session.ownerId, sessionId: session.id });
        try {
            await this.storageRepository.unlinkDir(dir, { recursive: true, force: true });
        }
        catch (error) {
            if (error?.code !== 'ENOENT') {
                throw error;
            }
            this.logger.warn(`Session dir ${dir} does not exist.`);
        }
    }
    removeInactiveSessions() {
        const cutoff = Date.now() - constants_1.HLS_INACTIVITY_TIMEOUT_MS;
        const inactiveSessions = this.sessions.values().filter((s) => s.lastActivityTime.getTime() < cutoff);
        return Promise.all(inactiveSessions.map(async (session) => {
            try {
                this.websocketRepository.serverSend('HlsSessionEnd', { sessionId: session.id });
                await this.onSessionEnd({ sessionId: session.id });
            }
            catch (error) {
                this.logger.error(`Failed to sweep inactive HLS session ${session.id}: ${error}`);
            }
        }));
    }
    removeExpiredSessions() {
        return this.databaseRepository.withLock(enum_1.DatabaseLock.HlsSessionCleanup, async () => {
            const expiredSessions = await this.videoStreamRepository.getExpiredSessions();
            await Promise.all(expiredSessions.map(async (session) => {
                await this.removeSessionDir(session);
                await this.videoStreamRepository.deleteSession(session.id);
            }));
        });
    }
};
exports.TranscodingService = TranscodingService;
__decorate([
    (0, decorators_1.OnEvent)({ name: 'AppBootstrap', workers: [enum_1.ImmichWorker.Microservices] }),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", []),
    __metadata("design:returntype", Promise)
], TranscodingService.prototype, "onBootstrap", null);
__decorate([
    (0, decorators_1.OnEvent)({ name: 'AppShutdown', workers: [enum_1.ImmichWorker.Microservices] }),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", []),
    __metadata("design:returntype", void 0)
], TranscodingService.prototype, "onShutdown", null);
__decorate([
    (0, decorators_1.OnJob)({ name: enum_1.JobName.HlsSessionCleanup, queue: enum_1.QueueName.BackgroundTask }),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", []),
    __metadata("design:returntype", void 0)
], TranscodingService.prototype, "onHlsSessionCleanup", null);
__decorate([
    (0, decorators_1.OnEvent)({ name: 'HlsSessionRequest', server: true, workers: [enum_1.ImmichWorker.Microservices] }),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", [Object]),
    __metadata("design:returntype", Promise)
], TranscodingService.prototype, "onSessionRequest", null);
__decorate([
    (0, decorators_1.OnEvent)({ name: 'HlsSessionEnd', server: true, workers: [enum_1.ImmichWorker.Microservices] }),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", [Object]),
    __metadata("design:returntype", Promise)
], TranscodingService.prototype, "onSessionEnd", null);
__decorate([
    (0, decorators_1.OnEvent)({ name: 'HlsHeartbeat', server: true, workers: [enum_1.ImmichWorker.Microservices] }),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", [Object]),
    __metadata("design:returntype", Promise)
], TranscodingService.prototype, "onHeartbeat", null);
__decorate([
    (0, decorators_1.OnEvent)({ name: 'HlsSegmentRequest', server: true, workers: [enum_1.ImmichWorker.Microservices] }),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", [Object]),
    __metadata("design:returntype", Promise)
], TranscodingService.prototype, "onSegmentRequest", null);
exports.TranscodingService = TranscodingService = __decorate([
    (0, common_1.Injectable)()
], TranscodingService);
//# sourceMappingURL=transcoding.service.js.map