UNPKG

@gibme/tablo.tv

Version:

API interface for interacting with a Tablo TV device

289 lines 11 kB
"use strict"; // Copyright (c) 2025, Brandon Lehmann <brandonlehmann@gmail.com> // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.LiveTranscoder = void 0; const events_1 = require("events"); const child_process_1 = require("child_process"); const fs_1 = require("fs"); const path_1 = require("path"); const ffmpeg_static_1 = __importDefault(require("ffmpeg-static")); const which_1 = __importDefault(require("which")); const crypto_1 = require("crypto"); const timer_1 = __importDefault(require("@gibme/timer")); class LiveTranscoder extends events_1.EventEmitter { /** * Creates a new live transcoder instance. * * The `output_path` must be a valid path to a directory on the local filesystem. * * The `channel_id` must be a valid channel ID for the specified `device`. * * The `device` must be a valid `Device` instance. * @param id * @param device * @param channel_id * @param output_path * @param filename * @param auto_restart */ constructor(id, device, channel_id, output_path, filename = 'stream.m3u8', auto_restart = true) { super(); this.id = id; this.device = device; this.channel_id = channel_id; this.output_path = output_path; this.filename = filename; this.auto_restart = auto_restart; this.ffmpeg_path = ffmpeg_static_1.default !== null && ffmpeg_static_1.default !== void 0 ? ffmpeg_static_1.default : which_1.default.sync('ffmpeg'); this.emitter = new events_1.EventEmitter(); this._use_count = 0; this._active = false; this._full_path = ''; this.emitter.on('abort', () => __awaiter(this, void 0, void 0, function* () { var _a, _b; if (this.process) { (_a = this.process.stdin) === null || _a === void 0 ? void 0 : _a.write('q', error => { if (error) { this.emit('error', error); } }); try { if (!this.process.killed) { this.process.kill(); } } catch (_c) { } delete this.process; (_b = this.timer) === null || _b === void 0 ? void 0 : _b.destroy(); delete this.timer; if (this.session) { yield this.device.deleteSession(this.session); } this.active = false; this.emitter.emit('stopped'); } })); this.emitter.on('stopped', () => { try { const current_path = (0, path_1.resolve)(output_path, `./${this.id}`); if ((0, fs_1.existsSync)(current_path)) { (0, fs_1.rmSync)(current_path, { recursive: true, force: true }); } } catch (_a) { } this.emit('stopped'); }); this.full_path = (0, path_1.resolve)(output_path, `./${this.id}`); if ((0, fs_1.existsSync)(this.full_path)) { (0, fs_1.rmSync)(this.full_path, { recursive: true, force: true }); } (0, fs_1.mkdirSync)(this.full_path, { recursive: true }); this.full_path = (0, path_1.resolve)(this.full_path, `./${this.filename}`); } get use_count() { return this._use_count; } get active() { return this._active; } set active(active) { this._active = active; } get full_path() { return this._full_path; } set full_path(path) { this._full_path = path; } get session() { return this._session; } set session(session) { this._session = session; } get channel() { var _a; return (_a = this.session) === null || _a === void 0 ? void 0 : _a.channel; } get relative_path() { if (this.id) { return `${this.output_path}/${this.id}/${this.filename}` .replace('//', '/'); } else { return ''; } } /** * Retrieves an existing instance of a live transcoder or creates a new instance if one does not exist. * * The `device` must be a valid `Device` instance. * * The `channel_id` must be a valid channel ID for the specified `device`. * * The `output_path` must be a valid path to a directory on the local filesystem. * * The `filename` is the name of the output file. * * @param device * @param channel_id * @param output_path * @param filename * @param auto_restart */ static instance(device_1, channel_id_1, output_path_1) { return __awaiter(this, arguments, void 0, function* (device, channel_id, output_path, filename = 'stream.m3u8', auto_restart = true) { const info = yield device.info(); if (!info) { throw new Error('Failed to retrieve device information'); } const id = (0, crypto_1.createHash)('sha256') .update(JSON.stringify({ server_id: info.server_id, channel_id })) .digest('hex'); let instance = this.instances.get(id); if (!instance) { instance = new LiveTranscoder(id, device, channel_id, output_path, filename, auto_restart); this.instances.set(id, instance); } return instance; }); } on(event, listener) { return super.on(event, listener); } once(event, listener) { return super.once(event, listener); } off(event, listener) { return super.off(event, listener); } /** * Starts the live transcoder. * * The live transcoder will attempt to start a new session for the specified `channel_id` on the specified `device`. * * If a session is successfully started, the live transcoder will attempt to start a new transcoding process. */ start() { return __awaiter(this, void 0, void 0, function* () { if (this.active) { this._use_count++; this.emit('ready'); return true; } this.session = yield this.device.watchChannel(this.channel_id); if (!this.session) { this.emit('error', new Error('Failed to start session')); return false; } this.timer = new timer_1.default((this.session.keepalive - 30) * 1000); this.timer.on('tick', () => __awaiter(this, void 0, void 0, function* () { if (this.session) { yield this.device.keepaliveSession(this.session); } })); this._use_count++; const started = this.start_ffmpeg(); if (started) { const check = () => setTimeout(() => { if ((0, fs_1.existsSync)(this.full_path)) { this.active = true; this.emit('ready'); } else { check(); } }, 100); check(); } return started; }); } /** * Stops the live transcoder. */ stop() { this._use_count--; if (this.active && this.use_count <= 0) { this.emitter.emit('abort'); } } /** * Attempts to start the FFMpeg process * @private */ start_ffmpeg() { if (!this.session) { return false; } const args = ['-re', '-i', `${this.session.playlist_url}`, '-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency', '-crf', '23', '-g', '48', '-keyint_min', '48', '-sc_threshold', '0', '-ac', '2', '-c:a', 'aac', '-b:a', '128k', '-f', 'hls', '-hls_time', '4', '-hls_list_size', '6', '-hls_flags', 'delete_segments+program_date_time+append_list', this.full_path]; this.process = (0, child_process_1.spawn)(this.ffmpeg_path, args, { detached: false, shell: false, windowsHide: false, stdio: ['pipe', 'ignore', 'ignore'] }); this.process.on('error', error => { this.emit('error', error); if (!this.auto_restart) { this.emitter.emit('abort'); } else { this.start_ffmpeg(); } }); this.process.on('exit', code => { this.emit('exit', code); if (!this.auto_restart) { this.emitter.emit('abort'); } else { this.start_ffmpeg(); } }); return true; } } exports.LiveTranscoder = LiveTranscoder; LiveTranscoder.instances = new Map(); exports.default = LiveTranscoder; //# sourceMappingURL=live_transcoder.js.map