import { Injectable, Logger } from '@nestjs/common'; import { spawn } from 'child_process'; import { existsSync, mkdirSync, readdirSync } from 'fs'; import path from 'path'; import { DbService } from './db.service'; import { EventsGateway } from './events.gateway'; @Injectable() export class HandbrakeService { private logger = new Logger('HandbrakeService'); constructor( private readonly eventsGateway: EventsGateway, private readonly db: DbService, ) {} /** * Check if output file exists (with case-insensitive directory matching) * Useful for avoiding creating tasks when output already exists */ outputFileExists(outputPath: string): boolean { const outputDir = path.dirname(outputPath); const outputFileName = path.basename(outputPath); // Check if exact path exists if (existsSync(outputPath)) { return true; } // Check for case-insensitive directory match const actualDir = this.findExistingDirCaseInsensitive(outputDir); if (actualDir) { const potentialPath = path.join(actualDir, outputFileName); if (existsSync(potentialPath)) { return true; } } return false; } /** * Find existing directory with case-insensitive matching * Returns the actual path if found, null otherwise */ private findExistingDirCaseInsensitive(dirPath: string): string | null { const parentDir = path.dirname(dirPath); const targetName = path.basename(dirPath); // If parent doesn't exist, can't find anything if (!existsSync(parentDir)) { return null; } try { const entries = readdirSync(parentDir, { withFileTypes: true }); const match = entries.find( (entry) => entry.isDirectory() && entry.name.toLowerCase() === targetName.toLowerCase(), ); return match ? path.join(parentDir, match.name) : null; } catch (err) { this.logger.warn( `Error checking for case-insensitive directory: ${dirPath}`, err, ); return null; } } /** * Ensure output directory exists, handling case-insensitive matches * Returns the actual directory path to use */ private ensureOutputDirectory(outputPath: string): string { const isAbsolute = path.isAbsolute(outputPath); const parts = outputPath.split(path.sep).filter((part) => part); // Filter out empty strings let currentPath = isAbsolute ? '/' : ''; // Build path incrementally, checking for case-insensitive matches for (let i = 0; i < parts.length; i++) { const part = parts[i]; const proposedPath = currentPath === '/' ? `/${part}` : path.join(currentPath, part); // Check if directory exists (exact case) if (existsSync(proposedPath)) { currentPath = proposedPath; continue; } // Check for case-insensitive match const existingPath = this.findExistingDirCaseInsensitive(proposedPath); if (existingPath) { this.logger.log( `Found existing directory with different case: ${existingPath} (wanted: ${proposedPath})`, ); currentPath = existingPath; } else { // Directory doesn't exist, create it try { mkdirSync(proposedPath, { recursive: false }); this.logger.log(`Created directory: ${proposedPath}`); currentPath = proposedPath; } catch (err) { throw new Error( `Failed to create directory ${proposedPath}: ${err.message}`, ); } } } return currentPath; } processWithHandbrake( input: string, output: string, preset: string, taskId?: number, ): Promise { return new Promise((resolve, reject) => { try { // Ensure output directory exists, handling case-insensitive matches const outputDir = path.dirname(output); let actualOutputDir: string; try { actualOutputDir = this.ensureOutputDirectory(outputDir); this.logger.log(`Output directory ready: ${actualOutputDir}`); } catch (err) { this.logger.error( `Failed to prepare output directory: ${outputDir}`, err, ); return reject(err); } // Update output path to use actual directory (in case of case mismatch) const actualOutput = path.join(actualOutputDir, path.basename(output)); const inputName = path.basename(input); const outputName = path.basename(actualOutput); let progressStarted = false; let lastPercent = 0; const hb = spawn('HandBrakeCLI', [ '-i', input, '-o', actualOutput, '--preset', preset, ]); hb.stdout.on('data', (data) => { const str = data.toString(); this.logger.debug(`HandBrake stdout: ${str}`); console.log('[STDOUT]', str); // Parse progress from stdout - try multiple patterns let progressMatch = str.match( /Encoding: task \d+ of \d+, (\d+(?:\.\d+)?)\s*%/, ); if (!progressMatch) { // Try alternative pattern progressMatch = str.match(/(\d+(?:\.\d+)?)\s*%/); } if (progressMatch) { const percent = Math.round(parseFloat(progressMatch[1])); console.log( `[MATCH] Found progress: ${percent}%, lastPercent: ${lastPercent}, taskId: ${taskId}`, ); if (percent !== lastPercent) { lastPercent = percent; progressStarted = true; console.log(`[UPDATE] Progress ${percent}% for task ${taskId}`); // Update task progress if we have a task ID if (taskId) { console.log( `[DB] Updating task ${taskId} progress to ${percent}%`, ); this.db.updateTask(taskId, { progress: percent }); console.log(`[DB] Update complete`); } else { console.warn('[WARN] No taskId provided!'); } // Emit progress update console.log( `[WS] Emitting progress event for task ${taskId}: ${percent}%`, ); this.eventsGateway.emitTaskUpdate({ type: 'progress', taskId, task: 'handbrake', input, output, preset, progress: percent, }); console.log(`[WS] Emit complete`); } } this.logger.log(str); }); hb.stderr.on('data', (data) => { const str = data.toString(); this.logger.debug(`HandBrake stderr: ${str}`); console.log('[STDERR]', str); // Parse progress from stderr as fallback - try multiple patterns let progressMatch = str.match( /Encoding: task \d+ of \d+, (\d+(?:\.\d+)?)\s*%/, ); if (!progressMatch) { // Try alternative pattern progressMatch = str.match(/(\d+(?:\.\d+)?)\s*%/); } if (progressMatch && !progressStarted) { const percent = Math.round(parseFloat(progressMatch[1])); console.log( `[STDERR MATCH] Found progress: ${percent}%, lastPercent: ${lastPercent}, taskId: ${taskId}`, ); if (percent !== lastPercent) { lastPercent = percent; console.log( `[STDERR UPDATE] Progress ${percent}% for task ${taskId}`, ); if (taskId) { console.log( `[STDERR DB] Updating task ${taskId} progress to ${percent}%`, ); this.db.updateTask(taskId, { progress: percent }); console.log(`[STDERR DB] Update complete`); } else { console.warn('[STDERR WARN] No taskId provided!'); } console.log( `[STDERR WS] Emitting progress event for task ${taskId}: ${percent}%`, ); this.eventsGateway.emitTaskUpdate({ type: 'progress', taskId, task: 'handbrake', input, output, preset, progress: percent, }); console.log(`[STDERR WS] Emit complete`); } } this.logger.error(str); }); hb.on('close', (code) => { if (code === 0) { this.logger.log( `Completed "${outputName}" with preset "${preset}"`, ); // Final progress update if (taskId) { this.db.updateTask(taskId, { progress: 100 }); } this.eventsGateway.emitTaskUpdate({ type: 'completed', taskId, task: 'handbrake', input, output, preset, success: true, }); resolve(true); } else { this.logger.error(`HandBrakeCLI exited with code ${code}`); this.eventsGateway.emitTaskUpdate({ type: 'failed', taskId, task: 'handbrake', input, output, preset, success: false, error: `Exit code ${code}`, }); reject(new Error(`HandBrakeCLI exited with code ${code}`)); } }); } catch (err) { this.logger.error( `Exception in processWithHandbrake: ${err && err.message ? err.message : err}`, ); reject(err); } }); } getPresetList(): Promise { return new Promise((resolve, reject) => { const hb = spawn('HandBrakeCLI', ['--preset-list']); let output = ''; hb.stdout.on('data', (data) => { output += data.toString(); }); hb.stderr.on('data', (data) => { output += data.toString(); }); hb.on('close', (code) => { if (code === 0) { // Parse the output to extract presets const lines = output.split('\n'); const presets: string[] = []; for (const line of lines) { // Preset names start with exactly 4 spaces (not 8 for descriptions) if ( line.startsWith(' ') && !line.startsWith(' ') && line.trim().length > 0 ) { presets.push(line.trim()); } } resolve(presets); } else { this.logger.error('Error getting preset list'); resolve([]); } }); }); } }