"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.HlsService = void 0;
const common_1 = require("@nestjs/common");
const node_fs_1 = require("node:fs");
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 event_1 = require("../utils/event");
const file_1 = require("../utils/file");
const media_1 = require("../utils/media");
let HlsService = class HlsService extends base_service_1.BaseService {
    pendingSegments = new event_1.PendingEvents({ timeoutMs: 15_000 });
    pendingSessions = new event_1.PendingEvents({ timeoutMs: 5000 });
    sessions = new Map();
    onSessionResult(event) {
        this.pendingSessions.complete(event.sessionId, event);
        if (event.error) {
            this.sessions.delete(event.sessionId);
            this.pendingSegments.rejectByPrefix(`${event.sessionId}:`, event.error);
        }
    }
    onSessionEnd({ sessionId }) {
        this.sessions.delete(sessionId);
        this.pendingSegments.rejectByPrefix(`${sessionId}:`, 'Session ended');
    }
    onSegmentResult(event) {
        this.pendingSegments.complete(this.getSegmentKey(event), event);
    }
    async getMainPlaylist(auth, assetId) {
        await this.requireAccess({ auth, permission: enum_1.Permission.AssetView, ids: [assetId] });
        const { ffmpeg } = await this.getConfig({ withCache: true });
        if (!ffmpeg.realtime.enabled) {
            throw new common_1.BadRequestException('Real-time transcoding is not enabled');
        }
        const asset = await this.videoStreamRepository.getForMainPlaylist(assetId);
        if (!asset) {
            throw new common_1.NotFoundException('Asset metadata is not yet ready for streaming');
        }
        const sessionId = this.cryptoRepository.randomUUID();
        this.websocketRepository.serverSend('HlsSessionRequest', { sessionId, assetId, ownerId: auth.user.id });
        await this.pendingSessions.wait(sessionId);
        this.trackSession(sessionId);
        return this.generateMainPlaylist(sessionId, ffmpeg, asset);
    }
    async getMediaPlaylist(auth, assetId, sessionId, variantIndex, position) {
        await this.requireAccess({ auth, permission: enum_1.Permission.AssetView, ids: [assetId] });
        const asset = await this.videoStreamRepository.getForMediaPlaylist(assetId, sessionId);
        if (!asset) {
            throw new common_1.NotFoundException('Asset not found or metadata not yet ready for streaming');
        }
        const segmentation = this.getSegmentation(asset);
        const hintedSegment = position === undefined ? undefined : this.positionToSegmentIndex(segmentation, position);
        this.prewarmVariant(assetId, sessionId, variantIndex, hintedSegment);
        return this.generateMediaPlaylist(asset, segmentation);
    }
    async getSegment(auth, assetId, sessionId, variantIndex, filename, initSegment) {
        await this.requireAccess({ auth, permission: enum_1.Permission.AssetView, ids: [assetId] });
        const session = await this.videoStreamRepository.getSession(sessionId);
        if (!session) {
            throw new common_1.NotFoundException('Session not found');
        }
        const variantDir = storage_core_1.StorageCore.getHlsVariantFolder({ ownerId: auth.user.id, sessionId, variantIndex });
        const path = (0, node_path_1.join)(variantDir, filename);
        const response = new file_1.ImmichFileResponse({
            path,
            contentType: 'video/mp4',
            cacheControl: enum_1.CacheControl.PrivateWithCache,
        });
        const apiSession = this.trackSession(sessionId, variantIndex);
        const segmentIndex = this.getSegmentIndex(apiSession, filename, initSegment);
        this.websocketRepository.serverSend('HlsHeartbeat', { sessionId, variantIndex, segmentIndex });
        if (await this.storageRepository.checkFileExists(path, node_fs_1.constants.R_OK)) {
            return response;
        }
        this.websocketRepository.serverSend('HlsSegmentRequest', { sessionId, assetId, variantIndex, segmentIndex });
        await this.pendingSegments.wait(this.getSegmentKey({ sessionId, variantIndex, segmentIndex }));
        return response;
    }
    async endSession(auth, assetId, sessionId) {
        await this.requireAccess({ auth, permission: enum_1.Permission.AssetView, ids: [assetId] });
        this.websocketRepository.serverSend('HlsSessionEnd', { sessionId });
    }
    generateMainPlaylist(sessionId, ffmpeg, asset) {
        const fps = (asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration;
        const roundedFps = fps.toFixed(3);
        const sourceResolution = Math.min(asset.videoStream.height, asset.videoStream.width);
        const targetResolution = Math.max(sourceResolution, constants_1.HLS_VARIANTS[0].resolution);
        const lines = ['#EXTM3U', `#EXT-X-VERSION:${constants_1.HLS_VERSION}`, '#EXT-X-INDEPENDENT-SEGMENTS'];
        const { videoCodecs, resolutions } = ffmpeg.realtime;
        for (let i = 0; i < constants_1.HLS_VARIANTS.length; i++) {
            const { resolution, bitrate, codec } = constants_1.HLS_VARIANTS[i];
            if (resolution > targetResolution || !videoCodecs.includes(codec) || !resolutions.includes(resolution)) {
                continue;
            }
            const { width, height } = (0, media_1.getOutputSize)(asset.videoStream, resolution);
            const codecString = (0, media_1.getCodecString)(codec, width, height, fps);
            lines.push(`#EXT-X-STREAM-INF:BANDWIDTH=${Math.round(bitrate * 1.35)},RESOLUTION=${width}x${height},CODECS="${codecString},mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=${roundedFps}`, `${sessionId}/${i}/playlist.m3u8`);
        }
        lines.push('');
        if (lines.length === 4) {
            throw new common_1.NotFoundException('No supported variants for this video');
        }
        return lines.join('\n');
    }
    getSegmentation({ videoStream, packets }) {
        const fps = (packets.packetCount * videoStream.timeBase) / packets.totalDuration;
        const framesPerSegment = Math.ceil(constants_1.HLS_SEGMENT_DURATION * fps);
        const segmentCount = Math.ceil(packets.outputFrames / framesPerSegment);
        return { fps, framesPerSegment, segmentCount, segmentDuration: framesPerSegment / fps };
    }
    positionToSegmentIndex({ segmentDuration, segmentCount }, position) {
        return Math.min(Math.max(Math.floor(position / segmentDuration), 0), segmentCount - 1);
    }
    generateMediaPlaylist({ packets }, segmentation) {
        const { fps, framesPerSegment, segmentCount, segmentDuration: fullSegmentDuration } = segmentation;
        const lastSegmentFrames = packets.outputFrames - framesPerSegment * (segmentCount - 1);
        const lastSegmentDuration = lastSegmentFrames / fps;
        const lines = [
            '#EXTM3U',
            `#EXT-X-VERSION:${constants_1.HLS_VERSION}`,
            '#EXT-X-INDEPENDENT-SEGMENTS',
            `#EXT-X-TARGETDURATION:${constants_1.HLS_SEGMENT_DURATION}`,
            '#EXT-X-MEDIA-SEQUENCE:0',
            '#EXT-X-PLAYLIST-TYPE:VOD',
            '#EXT-X-MAP:URI="init.mp4"',
        ];
        for (let i = 0; i < segmentCount - 1; i++) {
            lines.push(`#EXTINF:${fullSegmentDuration.toFixed(6)},`, `seg_${i}.m4s`);
        }
        lines.push(`#EXTINF:${lastSegmentDuration.toFixed(6)},`, `seg_${segmentCount - 1}.m4s`, '#EXT-X-ENDLIST', '');
        return lines.join('\n');
    }
    prewarmVariant(assetId, sessionId, variantIndex, hintedSegment) {
        const session = this.sessions.get(sessionId);
        if (session?.lastVariantIndex === variantIndex) {
            return;
        }
        const nextSegment = session && session.lastRequestedSegment !== null ? session.lastRequestedSegment + 1 : undefined;
        const segmentIndex = hintedSegment ?? nextSegment;
        if (segmentIndex !== undefined) {
            this.websocketRepository.serverSend('HlsSegmentRequest', { sessionId, assetId, variantIndex, segmentIndex });
        }
    }
    getSegmentKey({ sessionId, variantIndex, segmentIndex }) {
        return `${sessionId}:${variantIndex}:${segmentIndex}`;
    }
    getSegmentIndex(session, filename, initSegment) {
        if (filename.endsWith('.mp4')) {
            return initSegment ?? (session.lastRequestedSegment ?? -1) + 1;
        }
        const segmentIndex = Number.parseInt(constants_1.HLS_SEGMENT_FILENAME_REGEX.exec(filename)[1]);
        session.lastRequestedSegment = segmentIndex;
        return segmentIndex;
    }
    trackSession(id, variantIndex = null) {
        const session = this.sessions.get(id);
        if (!session) {
            const newSession = { lastRequestedSegment: null, lastVariantIndex: variantIndex };
            this.sessions.set(id, newSession);
            return newSession;
        }
        if (session.lastVariantIndex !== null && session.lastVariantIndex !== variantIndex) {
            this.pendingSegments.rejectByPrefix(`${id}:${session.lastVariantIndex}:`, 'Variant changed');
        }
        session.lastVariantIndex = variantIndex;
        return session;
    }
};
exports.HlsService = HlsService;
__decorate([
    (0, decorators_1.OnEvent)({ name: 'HlsSessionResult', server: true, workers: [enum_1.ImmichWorker.Api] }),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", [Object]),
    __metadata("design:returntype", void 0)
], HlsService.prototype, "onSessionResult", null);
__decorate([
    (0, decorators_1.OnEvent)({ name: 'HlsSessionEnd', server: true, workers: [enum_1.ImmichWorker.Api] }),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", [Object]),
    __metadata("design:returntype", void 0)
], HlsService.prototype, "onSessionEnd", null);
__decorate([
    (0, decorators_1.OnEvent)({ name: 'HlsSegmentResult', server: true, workers: [enum_1.ImmichWorker.Api] }),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", [Object]),
    __metadata("design:returntype", void 0)
], HlsService.prototype, "onSegmentResult", null);
exports.HlsService = HlsService = __decorate([
    (0, common_1.Injectable)()
], HlsService);
//# sourceMappingURL=hls.service.js.map