"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.MuxingSession = void 0;
const tslib_1 = require("tslib");
const bluebird_1 = require("bluebird");
const chokidar_1 = require("chokidar");
const fs_extra_1 = require("fs-extra");
const p_queue_1 = tslib_1.__importDefault(require("p-queue"));
const path_1 = require("path");
const stream_1 = require("stream");
const ffmpeg_1 = require("../../../helpers/ffmpeg");
const logger_1 = require("../../../helpers/logger");
const config_1 = require("../../../initializers/config");
const constants_1 = require("../../../initializers/constants");
const object_storage_1 = require("../../object-storage");
const video_file_1 = require("../../../models/video/video-file");
const paths_1 = require("../../paths");
const default_transcoding_profiles_1 = require("../../transcoding/default-transcoding-profiles");
const user_1 = require("../../user");
const live_quota_store_1 = require("../live-quota-store");
const live_segment_sha_store_1 = require("../live-segment-sha-store");
const live_utils_1 = require("../live-utils");
const memoizee = require("memoizee");
class MuxingSession extends stream_1.EventEmitter {
    constructor(options) {
        super();
        this.segmentsToProcessPerPlaylist = {};
        this.masterPlaylistCreated = false;
        this.liveReady = false;
        this.aborted = false;
        this.isAbleToUploadVideoWithCache = memoizee((userId) => {
            return (0, user_1.isAbleToUploadVideo)(userId, 1000);
        }, { maxAge: constants_1.MEMOIZE_TTL.LIVE_ABLE_TO_UPLOAD });
        this.hasClientSocketInBadHealthWithCache = memoizee((sessionId) => {
            return this.hasClientSocketInBadHealth(sessionId);
        }, { maxAge: constants_1.MEMOIZE_TTL.LIVE_CHECK_SOCKET_HEALTH });
        this.context = options.context;
        this.user = options.user;
        this.sessionId = options.sessionId;
        this.videoLive = options.videoLive;
        this.streamingPlaylist = options.streamingPlaylist;
        this.inputUrl = options.inputUrl;
        this.fps = options.fps;
        this.bitrate = options.bitrate;
        this.ratio = options.ratio;
        this.hasAudio = options.hasAudio;
        this.allResolutions = options.allResolutions;
        this.videoId = this.videoLive.Video.id;
        this.videoUUID = this.videoLive.Video.uuid;
        this.saveReplay = this.videoLive.saveReplay;
        this.outDirectory = (0, paths_1.getLiveDirectory)(this.videoLive.Video);
        this.replayDirectory = (0, path_1.join)((0, paths_1.getLiveReplayBaseDirectory)(this.videoLive.Video), new Date().toISOString());
        this.liveSegmentShaStore = new live_segment_sha_store_1.LiveSegmentShaStore({
            videoUUID: this.videoLive.Video.uuid,
            sha256Path: (0, path_1.join)(this.outDirectory, this.streamingPlaylist.segmentsSha256Filename),
            streamingPlaylist: this.streamingPlaylist,
            sendToObjectStorage: config_1.CONFIG.OBJECT_STORAGE.ENABLED
        });
        this.lTags = (0, logger_1.loggerTagsFactory)('live', this.sessionId, this.videoUUID);
    }
    runMuxing() {
        return tslib_1.__awaiter(this, void 0, void 0, function* () {
            this.createFiles();
            yield this.prepareDirectories();
            this.ffmpegCommand = config_1.CONFIG.LIVE.TRANSCODING.ENABLED
                ? yield (0, ffmpeg_1.getLiveTranscodingCommand)({
                    inputUrl: this.inputUrl,
                    outPath: this.outDirectory,
                    masterPlaylistName: this.streamingPlaylist.playlistFilename,
                    latencyMode: this.videoLive.latencyMode,
                    resolutions: this.allResolutions,
                    fps: this.fps,
                    bitrate: this.bitrate,
                    ratio: this.ratio,
                    hasAudio: this.hasAudio,
                    availableEncoders: default_transcoding_profiles_1.VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
                    profile: config_1.CONFIG.LIVE.TRANSCODING.PROFILE
                })
                : (0, ffmpeg_1.getLiveMuxingCommand)({
                    inputUrl: this.inputUrl,
                    outPath: this.outDirectory,
                    masterPlaylistName: this.streamingPlaylist.playlistFilename,
                    latencyMode: this.videoLive.latencyMode
                });
            logger_1.logger.info('Running live muxing/transcoding for %s.', this.videoUUID, this.lTags());
            this.watchMasterFile();
            this.watchTSFiles();
            this.watchM3U8File();
            let ffmpegShellCommand;
            this.ffmpegCommand.on('start', cmdline => {
                ffmpegShellCommand = cmdline;
                logger_1.logger.debug('Running ffmpeg command for live', Object.assign({ ffmpegShellCommand }, this.lTags()));
            });
            this.ffmpegCommand.on('error', (err, stdout, stderr) => {
                this.onFFmpegError({ err, stdout, stderr, ffmpegShellCommand });
            });
            this.ffmpegCommand.on('end', () => {
                this.emit('ffmpeg-end', ({ videoId: this.videoId }));
                this.onFFmpegEnded();
            });
            this.ffmpegCommand.run();
        });
    }
    abort() {
        if (!this.ffmpegCommand)
            return;
        this.aborted = true;
        this.ffmpegCommand.kill('SIGINT');
    }
    destroy() {
        this.removeAllListeners();
        this.isAbleToUploadVideoWithCache.clear();
        this.hasClientSocketInBadHealthWithCache.clear();
    }
    onFFmpegError(options) {
        var _a;
        const { err, stdout, stderr, ffmpegShellCommand } = options;
        this.onFFmpegEnded();
        if ((_a = err === null || err === void 0 ? void 0 : err.message) === null || _a === void 0 ? void 0 : _a.includes('Exiting normally'))
            return;
        logger_1.logger.error('Live transcoding error.', Object.assign({ err, stdout, stderr, ffmpegShellCommand }, this.lTags()));
        this.emit('ffmpeg-error', ({ videoId: this.videoId }));
    }
    onFFmpegEnded() {
        logger_1.logger.info('RTMP transmuxing for video %s ended. Scheduling cleanup', this.inputUrl, this.lTags());
        setTimeout(() => {
            Promise.all([this.tsWatcher.close(), this.masterWatcher.close(), this.m3u8Watcher.close()])
                .then(() => {
                for (const key of Object.keys(this.segmentsToProcessPerPlaylist)) {
                    this.processSegments(this.segmentsToProcessPerPlaylist[key]);
                }
            })
                .catch(err => {
                logger_1.logger.error('Cannot close watchers of %s or process remaining hash segments.', this.outDirectory, Object.assign({ err }, this.lTags()));
            });
            this.emit('after-cleanup', { videoId: this.videoId });
        }, 1000);
    }
    watchMasterFile() {
        this.masterWatcher = (0, chokidar_1.watch)(this.outDirectory + '/' + this.streamingPlaylist.playlistFilename);
        this.masterWatcher.on('add', () => tslib_1.__awaiter(this, void 0, void 0, function* () {
            if (this.streamingPlaylist.storage === 1) {
                try {
                    const url = yield (0, object_storage_1.storeHLSFileFromFilename)(this.streamingPlaylist, this.streamingPlaylist.playlistFilename);
                    this.streamingPlaylist.playlistUrl = url;
                    this.streamingPlaylist.assignP2PMediaLoaderInfoHashes(this.videoLive.Video, this.allResolutions);
                    yield this.streamingPlaylist.save();
                }
                catch (err) {
                    logger_1.logger.error('Cannot upload live master file to object storage.', Object.assign({ err }, this.lTags()));
                }
            }
            this.masterPlaylistCreated = true;
            this.masterWatcher.close()
                .catch(err => logger_1.logger.error('Cannot close master watcher of %s.', this.outDirectory, Object.assign({ err }, this.lTags())));
        }));
    }
    watchM3U8File() {
        this.m3u8Watcher = (0, chokidar_1.watch)(this.outDirectory + '/*.m3u8');
        const sendQueues = new Map();
        const onChangeOrAdd = (m3u8Path) => tslib_1.__awaiter(this, void 0, void 0, function* () {
            if (this.streamingPlaylist.storage !== 1)
                return;
            try {
                if (!sendQueues.has(m3u8Path)) {
                    sendQueues.set(m3u8Path, new p_queue_1.default({ concurrency: 1 }));
                }
                const queue = sendQueues.get(m3u8Path);
                yield queue.add(() => (0, object_storage_1.storeHLSFileFromPath)(this.streamingPlaylist, m3u8Path));
            }
            catch (err) {
                logger_1.logger.error('Cannot store in object storage m3u8 file %s', m3u8Path, Object.assign({ err }, this.lTags()));
            }
        });
        this.m3u8Watcher.on('change', onChangeOrAdd);
    }
    watchTSFiles() {
        const startStreamDateTime = new Date().getTime();
        this.tsWatcher = (0, chokidar_1.watch)(this.outDirectory + '/*.ts');
        const playlistIdMatcher = /^([\d+])-/;
        const addHandler = (segmentPath) => tslib_1.__awaiter(this, void 0, void 0, function* () {
            logger_1.logger.debug('Live add handler of %s.', segmentPath, this.lTags());
            const playlistId = (0, path_1.basename)(segmentPath).match(playlistIdMatcher)[0];
            const segmentsToProcess = this.segmentsToProcessPerPlaylist[playlistId] || [];
            this.processSegments(segmentsToProcess);
            this.segmentsToProcessPerPlaylist[playlistId] = [segmentPath];
            if (this.hasClientSocketInBadHealthWithCache(this.sessionId)) {
                this.emit('bad-socket-health', { videoId: this.videoId });
                return;
            }
            if (this.isDurationConstraintValid(startStreamDateTime) !== true) {
                this.emit('duration-exceeded', { videoId: this.videoId });
                return;
            }
            if ((yield this.isQuotaExceeded(segmentPath)) === true) {
                this.emit('quota-exceeded', { videoId: this.videoId });
            }
        });
        const deleteHandler = (segmentPath) => tslib_1.__awaiter(this, void 0, void 0, function* () {
            try {
                yield this.liveSegmentShaStore.removeSegmentSha(segmentPath);
            }
            catch (err) {
                logger_1.logger.warn('Cannot remove segment sha %s from sha store', segmentPath, Object.assign({ err }, this.lTags()));
            }
            if (this.streamingPlaylist.storage === 1) {
                try {
                    yield (0, object_storage_1.removeHLSFileObjectStorageByPath)(this.streamingPlaylist, segmentPath);
                }
                catch (err) {
                    logger_1.logger.error('Cannot remove segment %s from object storage', segmentPath, Object.assign({ err }, this.lTags()));
                }
            }
        });
        this.tsWatcher.on('add', p => addHandler(p));
        this.tsWatcher.on('unlink', p => deleteHandler(p));
    }
    isQuotaExceeded(segmentPath) {
        return tslib_1.__awaiter(this, void 0, void 0, function* () {
            if (this.saveReplay !== true)
                return false;
            if (this.aborted)
                return false;
            try {
                const segmentStat = yield (0, fs_extra_1.stat)(segmentPath);
                live_quota_store_1.LiveQuotaStore.Instance.addQuotaTo(this.user.id, this.videoLive.id, segmentStat.size);
                const canUpload = yield this.isAbleToUploadVideoWithCache(this.user.id);
                return canUpload !== true;
            }
            catch (err) {
                logger_1.logger.error('Cannot stat %s or check quota of %d.', segmentPath, this.user.id, Object.assign({ err }, this.lTags()));
            }
        });
    }
    createFiles() {
        for (let i = 0; i < this.allResolutions.length; i++) {
            const resolution = this.allResolutions[i];
            const file = new video_file_1.VideoFileModel({
                resolution,
                size: -1,
                extname: '.ts',
                infoHash: null,
                fps: this.fps,
                storage: this.streamingPlaylist.storage,
                videoStreamingPlaylistId: this.streamingPlaylist.id
            });
            video_file_1.VideoFileModel.customUpsert(file, 'streaming-playlist', null)
                .catch(err => logger_1.logger.error('Cannot create file for live streaming.', Object.assign({ err }, this.lTags())));
        }
    }
    prepareDirectories() {
        return tslib_1.__awaiter(this, void 0, void 0, function* () {
            yield (0, fs_extra_1.ensureDir)(this.outDirectory);
            if (this.videoLive.saveReplay === true) {
                yield (0, fs_extra_1.ensureDir)(this.replayDirectory);
            }
        });
    }
    isDurationConstraintValid(streamingStartTime) {
        const maxDuration = config_1.CONFIG.LIVE.MAX_DURATION;
        if (maxDuration < 0)
            return true;
        const now = new Date().getTime();
        const max = streamingStartTime + maxDuration;
        return now <= max;
    }
    processSegments(segmentPaths) {
        (0, bluebird_1.mapSeries)(segmentPaths, previousSegment => this.processSegment(previousSegment))
            .catch(err => {
            if (this.aborted)
                return;
            logger_1.logger.error('Cannot process segments', Object.assign({ err }, this.lTags()));
        });
    }
    processSegment(segmentPath) {
        return tslib_1.__awaiter(this, void 0, void 0, function* () {
            yield this.liveSegmentShaStore.addSegmentSha(segmentPath);
            if (this.saveReplay) {
                yield this.addSegmentToReplay(segmentPath);
            }
            if (this.streamingPlaylist.storage === 1) {
                try {
                    yield (0, object_storage_1.storeHLSFileFromPath)(this.streamingPlaylist, segmentPath);
                }
                catch (err) {
                    logger_1.logger.error('Cannot store TS segment %s in object storage', segmentPath, Object.assign({ err }, this.lTags()));
                }
            }
            if (this.masterPlaylistCreated && !this.liveReady) {
                this.liveReady = true;
                this.emit('live-ready', { videoId: this.videoId });
            }
        });
    }
    hasClientSocketInBadHealth(sessionId) {
        const rtmpSession = this.context.sessions.get(sessionId);
        if (!rtmpSession) {
            logger_1.logger.warn('Cannot get session %s to check players socket health.', sessionId, this.lTags());
            return;
        }
        for (const playerSessionId of rtmpSession.players) {
            const playerSession = this.context.sessions.get(playerSessionId);
            if (!playerSession) {
                logger_1.logger.error('Cannot get player session %s to check socket health.', playerSession, this.lTags());
                continue;
            }
            if (playerSession.socket.writableLength > constants_1.VIDEO_LIVE.MAX_SOCKET_WAITING_DATA) {
                return true;
            }
        }
        return false;
    }
    addSegmentToReplay(segmentPath) {
        return tslib_1.__awaiter(this, void 0, void 0, function* () {
            const segmentName = (0, path_1.basename)(segmentPath);
            const dest = (0, path_1.join)(this.replayDirectory, (0, live_utils_1.buildConcatenatedName)(segmentName));
            try {
                const data = yield (0, fs_extra_1.readFile)(segmentPath);
                yield (0, fs_extra_1.appendFile)(dest, data);
            }
            catch (err) {
                logger_1.logger.error('Cannot copy segment %s to replay directory.', segmentPath, Object.assign({ err }, this.lTags()));
            }
        });
    }
}
exports.MuxingSession = MuxingSession;
//# sourceMappingURL=muxing-session.js.map