handbrake.service.ts 11 KB

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