import fs from "fs"; import path from "path"; import { createWriteStream } from "fs"; import { VIDEO_CONFIG } from "../config/videoConfig.js"; // Store active recordings const activeRecordings = new Map(); class VideoRecordingService { constructor() { this.outputDir = VIDEO_CONFIG.OUTPUT_DIR; this.autoStopTimeout = VIDEO_CONFIG.AUTO_STOP_TIMEOUT; this.processingDelay = VIDEO_CONFIG.PROCESSING_DELAY; } /** * Start recording a user's video in a voice channel * @param {Object} options - Recording options * @param {Object} options.client - Discord client * @param {Object} options.voiceChannel - Voice channel to record in * @param {Object} options.user - User to record * @param {Object} options.textChannel - Text channel for notifications * @returns {Promise} Recording result */ async startRecording({ client, voiceChannel, user, textChannel }) { try { // Check if already recording if (activeRecordings.has(voiceChannel.id)) { return { success: false, message: VIDEO_CONFIG.ERRORS.ALREADY_RECORDING, }; } // Ensure output directory exists this._ensureOutputDirectory(); // Create unique filename with timestamp const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const videoFilename = `recording-${timestamp}${VIDEO_CONFIG.VIDEO_EXTENSION}`; const videoPath = path.join(this.outputDir, videoFilename); console.log( `[Docker] Attempting to join voice channel: ${voiceChannel.name}` ); // Join the voice channel const connection = await client.voice.joinChannel( voiceChannel.id, VIDEO_CONFIG.VOICE_SETTINGS ); console.log(`[Docker] Successfully joined voice channel`); // Create stream connection for video recording const connectionStream = await connection.joinStreamConnection(user.id); // Create video stream with MKV container const videoStream = connectionStream.receiver.createVideoStream( user.id, createWriteStream(videoPath) ); // Set up video stream event handlers this._setupVideoStreamHandlers(videoStream, textChannel, voiceChannel.id); // Set up voice connection event handlers for auto-stop this._setupVoiceConnectionHandlers( connection, user.id, voiceChannel.id, textChannel ); // Store recording info const recordingInfo = { connection, connectionStream, videoStream, videoFilename, videoPath, startTime: Date.now(), userId: user.id, textChannelId: textChannel.id, voiceChannelId: voiceChannel.id, voiceChannelName: voiceChannel.name, targetUserId: user.id, }; activeRecordings.set(voiceChannel.id, recordingInfo); console.log(`[Docker] Started recording in ${voiceChannel.name}`); console.log(`[Docker] Output will be saved to: ${this.outputDir}`); // Set up auto-stop timeout this._setupAutoStop(voiceChannel.id, textChannel); return { success: true, message: "Recording started successfully", recordingInfo, }; } catch (error) { console.error("[Docker] Error starting recording:", error); return { success: false, message: "Failed to start recording", error: error.message, }; } } /** * Stop recording in a voice channel * @param {string} voiceChannelId - Voice channel ID * @param {Object} textChannel - Text channel for notifications * @param {string} stopMessage - Message to display when stopping * @returns {Object} Stop result */ async stopRecording( voiceChannelId, textChannel, stopMessage = "Recording stopped." ) { const recording = activeRecordings.get(voiceChannelId); if (!recording) { return { success: false, message: "No active recording found", }; } try { console.log( `[Docker] Stopping video recording for user: ${recording.userId}` ); // Clean up streams and connections this._cleanupRecording(recording); // Remove from active recordings activeRecordings.delete(voiceChannelId); const duration = Math.round((Date.now() - recording.startTime) / 1000); textChannel.send( `${stopMessage} ${VIDEO_CONFIG.MESSAGES.PROCESSING.replace( "{duration}", duration )}` ); // Process the video file after a delay setTimeout(() => { this._processVideoFile(recording, textChannel, duration); }, this.processingDelay); return { success: true, message: "Recording stopped successfully", duration, filename: recording.videoFilename, }; } catch (error) { console.error("[Docker] Error stopping recording:", error); return { success: false, message: "Error stopping recording, but file may have been saved", error: error.message, }; } } /** * Get status of all active recordings and saved files * @returns {Object} Status information */ getStatus() { const activeCount = activeRecordings.size; let fileCount = 0; let files = []; try { if (fs.existsSync(this.outputDir)) { const dirContents = fs.readdirSync(this.outputDir); files = dirContents.filter((file) => file.endsWith(VIDEO_CONFIG.VIDEO_EXTENSION) ); fileCount = files.length; } } catch (error) { console.error("Error reading output directory:", error); } return { activeRecordings: activeCount, outputDir: this.outputDir, videoFiles: fileCount, recentFiles: files.slice(-3), activeRecordingsList: Array.from(activeRecordings.values()).map((r) => ({ voiceChannelName: r.voiceChannelName, userId: r.userId, startTime: r.startTime, duration: Math.round((Date.now() - r.startTime) / 1000), })), }; } /** * Check if a voice channel has an active recording * @param {string} voiceChannelId - Voice channel ID * @returns {boolean} Whether recording is active */ isRecording(voiceChannelId) { return activeRecordings.has(voiceChannelId); } /** * Get recording info for a specific voice channel * @param {string} voiceChannelId - Voice channel ID * @returns {Object|null} Recording info or null if not found */ getRecordingInfo(voiceChannelId) { return activeRecordings.get(voiceChannelId) || null; } // Private methods _ensureOutputDirectory() { if (!fs.existsSync(this.outputDir)) { fs.mkdirSync(this.outputDir, { recursive: true }); } } _setupVideoStreamHandlers(videoStream, textChannel, voiceChannelId) { videoStream.on("ready", () => { console.log("[Docker] FFmpeg process ready for video recording!"); videoStream.stream.stderr.on("data", (data) => { console.log(`[Docker] FFmpeg: ${data}`); }); }); videoStream.on("error", (error) => { console.error("[Docker] Video recording error:", error); textChannel.send("Video recording encountered an error!"); this._handleRecordingError(voiceChannelId); }); } _setupVoiceConnectionHandlers( connection, targetUserId, voiceChannelId, textChannel ) { // Listen for streaming events to detect when screenshare is turned off connection.on("startStreaming", ({ video_ssrc, user_id, audio_ssrc }) => { console.log( `[Docker] Video streaming event - User: ${user_id}, Video SSRC: ${video_ssrc}, Target: ${targetUserId}` ); // Check if this is our target user and they turned off their screenshare if (user_id === targetUserId && (video_ssrc === 0 || !video_ssrc)) { console.log( `[Docker] Target user ${targetUserId} turned off screenshare, stopping video recording` ); this.stopRecording( voiceChannelId, textChannel, "📹 Video recording stopped - user turned off screenshare." ); } }); // Listen for user disconnect events connection.on("disconnect", (userId) => { if (userId === targetUserId) { console.log( `[Docker] Target user ${targetUserId} disconnected, stopping video recording` ); this.stopRecording( voiceChannelId, textChannel, "📹 Video recording stopped - user disconnected." ); } }); // Listen for connection close connection.on("close", () => { console.log(`[Docker] Voice connection closed, stopping video recording`); if (this.isRecording(voiceChannelId)) { this.stopRecording( voiceChannelId, textChannel, "📹 Video recording stopped - voice connection closed." ); } }); } _setupAutoStop(voiceChannelId, textChannel) { setTimeout(() => { if (activeRecordings.has(voiceChannelId)) { this.stopRecording( voiceChannelId, textChannel, "Recording stopped automatically after 30 seconds (demo limit)." ); } }, this.autoStopTimeout); } _cleanupRecording(recording) { // Remove all event listeners first if (recording.connection) { recording.connection.removeAllListeners("startStreaming"); recording.connection.removeAllListeners("disconnect"); recording.connection.removeAllListeners("close"); } if (recording.videoStream) { recording.videoStream.destroy(); } if (recording.connectionStream) { recording.connectionStream.destroy(); } if (recording.connection) { recording.connection.disconnect(); } } _handleRecordingError(voiceChannelId) { const recording = activeRecordings.get(voiceChannelId); if (recording) { this._cleanupRecording(recording); activeRecordings.delete(voiceChannelId); } } _processVideoFile(recording, textChannel, duration) { try { if (fs.existsSync(recording.videoPath)) { const stats = fs.statSync(recording.videoPath); if (stats.size > 0) { textChannel.send( VIDEO_CONFIG.MESSAGES.SAVED.replace( "{filename}", recording.videoFilename ) ); textChannel.send(VIDEO_CONFIG.MESSAGES.LOCATION); console.log( `[Docker] Video recording completed: ${recording.videoFilename}, Duration: ${duration}s` ); } else { textChannel.send(`❌ ${VIDEO_CONFIG.ERRORS.NO_VIDEO_DATA}`); fs.unlinkSync(recording.videoPath); // Delete empty file } } else { textChannel.send(`❌ ${VIDEO_CONFIG.ERRORS.FILE_NOT_CREATED}`); } } catch (error) { console.error("[Docker] Error processing video file:", error); textChannel.send( `⚠️ ${VIDEO_CONFIG.ERRORS.PROCESSING_FAILED} File: ${recording.videoFilename}` ); } } } // Export singleton instance export const videoRecordingService = new VideoRecordingService(); export default videoRecordingService;