@eleven-am/transcoder
Version:
High-performance HLS transcoding library with hardware acceleration, intelligent client management, and distributed processing support for Node.js
325 lines • 12.9 kB
JavaScript
"use strict";
/*
* @eleven-am/transcoder
* Copyright (C) 2025 Roy OSSAI
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ClientTracker = void 0;
const fp_1 = require("@eleven-am/fp");
const types_1 = require("./types");
const utils_1 = require("./utils");
var ClientBehavior;
(function (ClientBehavior) {
ClientBehavior["FIRST_TIME"] = "FIRST_TIME";
ClientBehavior["SEEKING"] = "SEEKING";
ClientBehavior["SEQUENTIAL"] = "SEQUENTIAL";
})(ClientBehavior || (ClientBehavior = {}));
/**
* ClientTracker - Monitors client activity and manages resources
*
* This class tracks which clients are using which streams and ensures
* that unused resources are cleaned up to optimize system resource usage.
*/
class ClientTracker extends utils_1.ExtendedEventEmitter {
constructor(qualityService, inactivityCheckFrequency = 60_000, unusedStreamDebounceDelay = 300_000, inactivityThreshold = 1_800_000) {
super();
this.qualityService = qualityService;
this.inactivityCheckFrequency = inactivityCheckFrequency;
this.unusedStreamDebounceDelay = unusedStreamDebounceDelay;
this.inactivityThreshold = inactivityThreshold;
this.maxSegmentHistorySize = 10;
this.inactivityCheckInterval = null;
this.clientLastAccess = new Map();
this.streamLastAccess = new Map();
// Pure helper functions for priority calculation
this.getBehaviorBonus = (behavior) => {
const bonuses = {
[ClientBehavior.FIRST_TIME]: 30,
[ClientBehavior.SEEKING]: 20,
[ClientBehavior.SEQUENTIAL]: 5,
};
return bonuses[behavior] || 0;
};
this.getVideoQualityAdjustment = (quality) => {
const qualityInfo = this.qualityService.parseVideoQuality(quality);
return qualityInfo.value === 'original' ? -10 : this.getHeightBonus(qualityInfo.height);
};
this.getHeightBonus = (height) => {
if (height <= 480)
return 10;
if (height <= 720)
return 5;
return 0;
};
this.getQualityAdjustment = (type, quality) => type === types_1.StreamType.VIDEO ? this.getVideoQualityAdjustment(quality) : 0;
this.clampPriority = (priority) => Math.max(1, Math.min(100, priority));
this.calculateFinalPriority = (basePriority, behaviorBonus, qualityAdjustment) => this.clampPriority(basePriority + behaviorBonus + qualityAdjustment);
this.clients = new Map();
this.states = new Map();
this.clientSegmentHistory = new Map();
this.clientStreamMap = new Map();
this.streamClientMap = new Map();
this.pendingUnusedStreams = new Map();
this.initialize();
}
/**
* Clean up resources
*/
dispose() {
if (this.inactivityCheckInterval) {
clearInterval(this.inactivityCheckInterval);
this.inactivityCheckInterval = null;
}
for (const timer of this.pendingUnusedStreams.values()) {
clearTimeout(timer);
}
this.pendingUnusedStreams.clear();
}
/**
* Register client activity
* @param clientInfo Information about the client and its activity
*/
registerClientActivity(clientInfo) {
const now = new Date();
// Update local state
const existingClient = this.clients.get(clientInfo.clientId);
const fullClientInfo = {
clientId: clientInfo.clientId,
fileId: clientInfo.fileId,
filePath: clientInfo.filePath,
audioQuality: clientInfo.audioQuality ?? existingClient?.audioQuality,
videoQuality: clientInfo.videoQuality ?? existingClient?.videoQuality,
audioIndex: clientInfo.audioIndex ?? existingClient?.audioIndex,
videoIndex: clientInfo.videoIndex ?? existingClient?.videoIndex,
lastAccess: now,
};
const quality = clientInfo.videoQuality || clientInfo.audioQuality;
const type = clientInfo.videoQuality ? types_1.StreamType.VIDEO : types_1.StreamType.AUDIO;
const index = clientInfo.videoIndex || clientInfo.audioIndex;
this.clients.set(clientInfo.clientId, fullClientInfo);
const streamId = `${clientInfo.fileId}:${type}:${index}:${quality}`;
this.updateLastAccess(clientInfo.clientId, streamId);
const isNewClient = !this.clientStreamMap.has(clientInfo.clientId);
if (isNewClient) {
this.emit('client:registered', {
clientId: clientInfo.clientId,
fileId: clientInfo.fileId,
});
}
this.updateClientSession(clientInfo.clientId);
}
/**
* Get the priority for a stream request
* @param clientId The ID of the client requesting the stream
* @param type The type of stream (video/audio)
* @param quality The quality of the stream requested
* @param segmentIndex The index of the segment requested
* @returns A TaskEither containing the calculated priority (higher = more important)
*/
getPriority(clientId, type, quality, segmentIndex) {
const BASE_PRIORITY = 50;
const getClientFileId = () => this.clients.get(clientId)?.fileId;
const analyzeBehavior = () => this.analyzeClientBehavior(clientId, segmentIndex);
const buildPriorityData = (behavior) => ({
behaviorBonus: this.getBehaviorBonus(behavior),
qualityAdjustment: this.getQualityAdjustment(type, quality),
});
const calculatePriority = ({ behaviorBonus, qualityAdjustment }) => this.calculateFinalPriority(BASE_PRIORITY, behaviorBonus, qualityAdjustment);
return fp_1.TaskEither
.fromNullable(getClientFileId())
.map(analyzeBehavior)
.map(buildPriorityData)
.map(calculatePriority)
.orElse(() => fp_1.TaskEither.of(0));
}
/**
* Initialize the tracker
*/
initialize() {
this.inactivityCheckInterval = setInterval(() => {
this.checkForIdleStreams();
this.checkForInactiveClients();
}, this.inactivityCheckFrequency);
}
/**
* Check for idle streams and emit events
*/
checkForIdleStreams() {
const now = new Date();
for (const [streamId, lastAccess] of this.streamLastAccess.entries()) {
const idleTime = now.getTime() - lastAccess.getTime();
if (!this.streamClientMap.has(streamId) || this.streamClientMap.get(streamId).size === 0) {
if (this.pendingUnusedStreams.has(streamId)) {
continue;
}
this.emit('stream:idle', { streamId,
idleTime });
this.debounceStreamUnused(streamId);
}
}
}
/**
* Handle debounce logic for marking a stream as unused
*/
debounceStreamUnused(streamId) {
this.cancelStreamUnusedTimer(streamId);
try {
const timer = setTimeout(() => {
try {
if (!this.streamClientMap.has(streamId) || this.streamClientMap.get(streamId).size === 0) {
this.emit('stream:abandoned', { streamId });
this.streamLastAccess.delete(streamId);
this.pendingUnusedStreams.delete(streamId);
this.streamClientMap.delete(streamId);
}
}
catch {
this.pendingUnusedStreams.delete(streamId);
}
}, this.unusedStreamDebounceDelay);
this.pendingUnusedStreams.set(streamId, timer);
}
catch {
// no-op
}
}
/**
* Cancel any pending unused timer for a stream
*/
cancelStreamUnusedTimer(streamId) {
if (this.pendingUnusedStreams.has(streamId)) {
try {
clearTimeout(this.pendingUnusedStreams.get(streamId));
this.pendingUnusedStreams.delete(streamId);
}
catch {
this.pendingUnusedStreams.delete(streamId);
}
}
}
/**
* Update last access timestamps for client and stream
*/
updateLastAccess(clientId, streamId) {
const now = new Date();
this.clientLastAccess.set(clientId, now);
this.streamLastAccess.set(streamId, now);
if (!this.clientStreamMap.has(clientId)) {
this.clientStreamMap.set(clientId, new Set([streamId]));
}
else {
this.clientStreamMap.get(clientId).add(streamId);
}
if (!this.streamClientMap.has(streamId)) {
this.streamClientMap.set(streamId, new Set([clientId]));
}
else {
this.streamClientMap.get(streamId).add(clientId);
}
this.cancelStreamUnusedTimer(streamId);
}
/**
* Check for inactive clients and clean them up
*/
checkForInactiveClients() {
const now = new Date();
for (const [clientId, lastAccess] of this.clientLastAccess.entries()) {
const inactiveTime = now.getTime() - lastAccess.getTime();
if (inactiveTime > this.inactivityThreshold) {
this.removeClient(clientId);
}
}
}
/**
* Remove a client and clean up their resources
*/
removeClient(clientId) {
const client = this.clients.get(clientId);
if (!client) {
return;
}
const streams = this.clientStreamMap.get(clientId) || new Set();
for (const streamId of streams) {
const clients = this.streamClientMap.get(streamId);
if (clients) {
clients.delete(clientId);
if (clients.size === 0) {
this.debounceStreamUnused(streamId);
}
}
}
this.clients.delete(clientId);
this.clientStreamMap.delete(clientId);
this.clientLastAccess.delete(clientId);
this.clientSegmentHistory.delete(clientId);
this.emit('client:departed', {
clientId,
fileId: client.fileId,
});
}
/**
* Update a client's session information and emit event
* @param clientId The client ID to update
*/
updateClientSession(clientId) {
try {
const client = this.clients.get(clientId);
const state = this.states.get(clientId);
if (!client ||
(state?.audioIndex === client.audioIndex &&
state?.videoIndex === client.videoIndex &&
state?.audioQuality === client.audioQuality &&
state?.videoQuality === client.videoQuality)) {
return;
}
const newState = {
clientId,
fileId: client.fileId,
filePath: client.filePath,
audioQuality: client.audioQuality,
videoQuality: client.videoQuality,
audioIndex: client.audioIndex,
videoIndex: client.videoIndex,
};
this.states.set(clientId, newState);
this.emit('session:updated', newState);
}
catch {
// no-op
}
}
/**
* Analyze client behavior based on segment access pattern
*/
analyzeClientBehavior(clientId, segmentIndex) {
if (!this.clientSegmentHistory.has(clientId)) {
this.clientSegmentHistory.set(clientId, [segmentIndex]);
return ClientBehavior.FIRST_TIME;
}
const history = this.clientSegmentHistory.get(clientId);
const lastSegment = history[history.length - 1];
history.push(segmentIndex);
if (history.length > this.maxSegmentHistorySize) {
history.shift();
}
if (segmentIndex === lastSegment + 1) {
return ClientBehavior.SEQUENTIAL;
}
return ClientBehavior.SEEKING;
}
}
exports.ClientTracker = ClientTracker;
//# sourceMappingURL=clientTracker.js.map