UNPKG

@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
"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