import { Injectable, Logger } from '@nestjs/common'; import { spawn } from 'child_process'; import crypto from 'crypto'; import { createReadStream, existsSync, mkdirSync, readdirSync } from 'fs'; import { stat } from 'fs/promises'; 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) { currentPath = existingPath; } else { // Directory doesn't exist, create it try { mkdirSync(proposedPath, { recursive: false }); currentPath = proposedPath; } catch (err) { throw new Error( `Failed to create directory ${proposedPath}: ${err.message}`, ); } } } return currentPath; } /** * Hash a file asynchronously using streaming to avoid loading entire file into memory */ private async hashFileAsync(filePath: string): Promise { return new Promise((resolve) => { const hash = crypto.createHash('sha1'); const stream = createReadStream(filePath, { highWaterMark: 64 * 1024 }); stream.on('data', (chunk) => hash.update(chunk)); stream.on('end', () => resolve(hash.digest('hex'))); stream.on('error', (error) => { this.logger.warn(`Failed to hash ${filePath}: ${error}`); resolve(null); }); }); } processWithHandbrake( input: string, output: string, preset: string, taskId?: number, dataset?: string, ): 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); } catch (err) { this.logger.error( `Failed to prepare output directory: ${outputDir}`, err, ); return reject( new Error(`Failed to prepare output directory: ${outputDir}`), ); } // 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(); // 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])); if (percent !== lastPercent) { lastPercent = percent; progressStarted = true; // Update task progress if we have a task ID if (taskId) { this.db.updateTask(taskId, { progress: percent }); } // Emit progress update this.eventsGateway.emitTaskUpdate({ type: 'progress', taskId, task: 'handbrake', input, output, preset, progress: percent, }); } } }); hb.stderr.on('data', (data) => { const str = data.toString(); // 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', async (code) => { if (code === 0) { this.logger.log( `Completed "${outputName}" with preset "${preset}"`, ); // Hash the output file if dataset is provided if (dataset && existsSync(actualOutput)) { try { const fileStats = await stat(actualOutput); const hash = await this.hashFileAsync(actualOutput); if (hash) { this.logger.log( `Hashing output file: ${actualOutput}, hash: ${hash}, size: ${fileStats.size}`, ); this.db.updateFileHash(dataset, input, hash, fileStats.size); this.logger.log(`Updated database with hash for ${input}`); } } catch (hashError) { this.logger.warn( `Failed to hash output file ${actualOutput}: ${hashError}`, ); } } // 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( new Error( `Exception in processWithHandbrake: ${err && err.message ? err.message : 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([]); } }); }); } }