| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344 |
- 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<boolean> {
- 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<string[]> {
- 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([]);
- }
- });
- });
- }
- }
|