@gibme/tablo.tv
Version:
API interface for interacting with a Tablo TV device
289 lines • 11 kB
JavaScript
"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