handbrake.service.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. import { Injectable, Logger } from '@nestjs/common';
  2. import { spawn } from 'child_process';
  3. import { existsSync, mkdirSync, readdirSync } from 'fs';
  4. import path from 'path';
  5. import { DbService } from './db.service';
  6. import { EventsGateway } from './events.gateway';
  7. @Injectable()
  8. export class HandbrakeService {
  9. private logger = new Logger('HandbrakeService');
  10. constructor(
  11. private readonly eventsGateway: EventsGateway,
  12. private readonly db: DbService,
  13. ) {}
  14. /**
  15. * Check if output file exists (with case-insensitive directory matching)
  16. * Useful for avoiding creating tasks when output already exists
  17. */
  18. outputFileExists(outputPath: string): boolean {
  19. const outputDir = path.dirname(outputPath);
  20. const outputFileName = path.basename(outputPath);
  21. // Check if exact path exists
  22. if (existsSync(outputPath)) {
  23. return true;
  24. }
  25. // Check for case-insensitive directory match
  26. const actualDir = this.findExistingDirCaseInsensitive(outputDir);
  27. if (actualDir) {
  28. const potentialPath = path.join(actualDir, outputFileName);
  29. if (existsSync(potentialPath)) {
  30. return true;
  31. }
  32. }
  33. return false;
  34. }
  35. /**
  36. * Find existing directory with case-insensitive matching
  37. * Returns the actual path if found, null otherwise
  38. */
  39. private findExistingDirCaseInsensitive(dirPath: string): string | null {
  40. const parentDir = path.dirname(dirPath);
  41. const targetName = path.basename(dirPath);
  42. // If parent doesn't exist, can't find anything
  43. if (!existsSync(parentDir)) {
  44. return null;
  45. }
  46. try {
  47. const entries = readdirSync(parentDir, { withFileTypes: true });
  48. const match = entries.find(
  49. (entry) =>
  50. entry.isDirectory() &&
  51. entry.name.toLowerCase() === targetName.toLowerCase(),
  52. );
  53. return match ? path.join(parentDir, match.name) : null;
  54. } catch (err) {
  55. this.logger.warn(
  56. `Error checking for case-insensitive directory: ${dirPath}`,
  57. err,
  58. );
  59. return null;
  60. }
  61. }
  62. /**
  63. * Ensure output directory exists, handling case-insensitive matches
  64. * Returns the actual directory path to use
  65. */
  66. private ensureOutputDirectory(outputPath: string): string {
  67. const isAbsolute = path.isAbsolute(outputPath);
  68. const parts = outputPath.split(path.sep).filter((part) => part); // Filter out empty strings
  69. let currentPath = isAbsolute ? '/' : '';
  70. // Build path incrementally, checking for case-insensitive matches
  71. for (let i = 0; i < parts.length; i++) {
  72. const part = parts[i];
  73. const proposedPath =
  74. currentPath === '/' ? `/${part}` : path.join(currentPath, part);
  75. // Check if directory exists (exact case)
  76. if (existsSync(proposedPath)) {
  77. currentPath = proposedPath;
  78. continue;
  79. }
  80. // Check for case-insensitive match
  81. const existingPath = this.findExistingDirCaseInsensitive(proposedPath);
  82. if (existingPath) {
  83. this.logger.log(
  84. `Found existing directory with different case: ${existingPath} (wanted: ${proposedPath})`,
  85. );
  86. currentPath = existingPath;
  87. } else {
  88. // Directory doesn't exist, create it
  89. try {
  90. mkdirSync(proposedPath, { recursive: false });
  91. this.logger.log(`Created directory: ${proposedPath}`);
  92. currentPath = proposedPath;
  93. } catch (err) {
  94. throw new Error(
  95. `Failed to create directory ${proposedPath}: ${err.message}`,
  96. );
  97. }
  98. }
  99. }
  100. return currentPath;
  101. }
  102. processWithHandbrake(
  103. input: string,
  104. output: string,
  105. preset: string,
  106. taskId?: number,
  107. ): Promise<boolean> {
  108. return new Promise((resolve, reject) => {
  109. try {
  110. // Ensure output directory exists, handling case-insensitive matches
  111. const outputDir = path.dirname(output);
  112. let actualOutputDir: string;
  113. try {
  114. actualOutputDir = this.ensureOutputDirectory(outputDir);
  115. this.logger.log(`Output directory ready: ${actualOutputDir}`);
  116. } catch (err) {
  117. this.logger.error(
  118. `Failed to prepare output directory: ${outputDir}`,
  119. err,
  120. );
  121. return reject(err);
  122. }
  123. // Update output path to use actual directory (in case of case mismatch)
  124. const actualOutput = path.join(actualOutputDir, path.basename(output));
  125. const inputName = path.basename(input);
  126. const outputName = path.basename(actualOutput);
  127. let progressStarted = false;
  128. let lastPercent = 0;
  129. const hb = spawn('HandBrakeCLI', [
  130. '-i',
  131. input,
  132. '-o',
  133. actualOutput,
  134. '--preset',
  135. preset,
  136. ]);
  137. hb.stdout.on('data', (data) => {
  138. const str = data.toString();
  139. this.logger.debug(`HandBrake stdout: ${str}`);
  140. console.log('[STDOUT]', str);
  141. // Parse progress from stdout - try multiple patterns
  142. let progressMatch = str.match(
  143. /Encoding: task \d+ of \d+, (\d+(?:\.\d+)?)\s*%/,
  144. );
  145. if (!progressMatch) {
  146. // Try alternative pattern
  147. progressMatch = str.match(/(\d+(?:\.\d+)?)\s*%/);
  148. }
  149. if (progressMatch) {
  150. const percent = Math.round(parseFloat(progressMatch[1]));
  151. console.log(
  152. `[MATCH] Found progress: ${percent}%, lastPercent: ${lastPercent}, taskId: ${taskId}`,
  153. );
  154. if (percent !== lastPercent) {
  155. lastPercent = percent;
  156. progressStarted = true;
  157. console.log(`[UPDATE] Progress ${percent}% for task ${taskId}`);
  158. // Update task progress if we have a task ID
  159. if (taskId) {
  160. console.log(
  161. `[DB] Updating task ${taskId} progress to ${percent}%`,
  162. );
  163. this.db.updateTask(taskId, { progress: percent });
  164. console.log(`[DB] Update complete`);
  165. } else {
  166. console.warn('[WARN] No taskId provided!');
  167. }
  168. // Emit progress update
  169. console.log(
  170. `[WS] Emitting progress event for task ${taskId}: ${percent}%`,
  171. );
  172. this.eventsGateway.emitTaskUpdate({
  173. type: 'progress',
  174. taskId,
  175. task: 'handbrake',
  176. input,
  177. output,
  178. preset,
  179. progress: percent,
  180. });
  181. console.log(`[WS] Emit complete`);
  182. }
  183. }
  184. this.logger.log(str);
  185. });
  186. hb.stderr.on('data', (data) => {
  187. const str = data.toString();
  188. this.logger.debug(`HandBrake stderr: ${str}`);
  189. console.log('[STDERR]', str);
  190. // Parse progress from stderr as fallback - try multiple patterns
  191. let progressMatch = str.match(
  192. /Encoding: task \d+ of \d+, (\d+(?:\.\d+)?)\s*%/,
  193. );
  194. if (!progressMatch) {
  195. // Try alternative pattern
  196. progressMatch = str.match(/(\d+(?:\.\d+)?)\s*%/);
  197. }
  198. if (progressMatch && !progressStarted) {
  199. const percent = Math.round(parseFloat(progressMatch[1]));
  200. console.log(
  201. `[STDERR MATCH] Found progress: ${percent}%, lastPercent: ${lastPercent}, taskId: ${taskId}`,
  202. );
  203. if (percent !== lastPercent) {
  204. lastPercent = percent;
  205. console.log(
  206. `[STDERR UPDATE] Progress ${percent}% for task ${taskId}`,
  207. );
  208. if (taskId) {
  209. console.log(
  210. `[STDERR DB] Updating task ${taskId} progress to ${percent}%`,
  211. );
  212. this.db.updateTask(taskId, { progress: percent });
  213. console.log(`[STDERR DB] Update complete`);
  214. } else {
  215. console.warn('[STDERR WARN] No taskId provided!');
  216. }
  217. console.log(
  218. `[STDERR WS] Emitting progress event for task ${taskId}: ${percent}%`,
  219. );
  220. this.eventsGateway.emitTaskUpdate({
  221. type: 'progress',
  222. taskId,
  223. task: 'handbrake',
  224. input,
  225. output,
  226. preset,
  227. progress: percent,
  228. });
  229. console.log(`[STDERR WS] Emit complete`);
  230. }
  231. }
  232. this.logger.error(str);
  233. });
  234. hb.on('close', (code) => {
  235. if (code === 0) {
  236. this.logger.log(
  237. `Completed "${outputName}" with preset "${preset}"`,
  238. );
  239. // Final progress update
  240. if (taskId) {
  241. this.db.updateTask(taskId, { progress: 100 });
  242. }
  243. this.eventsGateway.emitTaskUpdate({
  244. type: 'completed',
  245. taskId,
  246. task: 'handbrake',
  247. input,
  248. output,
  249. preset,
  250. success: true,
  251. });
  252. resolve(true);
  253. } else {
  254. this.logger.error(`HandBrakeCLI exited with code ${code}`);
  255. this.eventsGateway.emitTaskUpdate({
  256. type: 'failed',
  257. taskId,
  258. task: 'handbrake',
  259. input,
  260. output,
  261. preset,
  262. success: false,
  263. error: `Exit code ${code}`,
  264. });
  265. reject(new Error(`HandBrakeCLI exited with code ${code}`));
  266. }
  267. });
  268. } catch (err) {
  269. this.logger.error(
  270. `Exception in processWithHandbrake: ${err && err.message ? err.message : err}`,
  271. );
  272. reject(err);
  273. }
  274. });
  275. }
  276. getPresetList(): Promise<string[]> {
  277. return new Promise((resolve, reject) => {
  278. const hb = spawn('HandBrakeCLI', ['--preset-list']);
  279. let output = '';
  280. hb.stdout.on('data', (data) => {
  281. output += data.toString();
  282. });
  283. hb.stderr.on('data', (data) => {
  284. output += data.toString();
  285. });
  286. hb.on('close', (code) => {
  287. if (code === 0) {
  288. // Parse the output to extract presets
  289. const lines = output.split('\n');
  290. const presets: string[] = [];
  291. for (const line of lines) {
  292. // Preset names start with exactly 4 spaces (not 8 for descriptions)
  293. if (
  294. line.startsWith(' ') &&
  295. !line.startsWith(' ') &&
  296. line.trim().length > 0
  297. ) {
  298. presets.push(line.trim());
  299. }
  300. }
  301. resolve(presets);
  302. } else {
  303. this.logger.error('Error getting preset list');
  304. resolve([]);
  305. }
  306. });
  307. });
  308. }
  309. }