teto_ai/src/services/videoRecording.js
Mikolaj Wojciech Gorski 81a2318bd2 feat: add auto-stop functionality for webcam and video recordings
- Add automatic recording stop when user turns off camera/screenshare
- Listen for startStreaming events to detect video_ssrc=0 (camera/screen off)
- Add disconnect and connection close event handling
- Implement proper cleanup of event listeners to prevent memory leaks
- Add targetUserId tracking for accurate event filtering
- Update both videoRecording.js and webcamRecording.js services
- Update discord.js-selfbot-v13 submodule with latest webcam recording fixes
2025-07-26 16:13:19 +02:00

375 lines
11 KiB
JavaScript

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<Object>} 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;