app.controller.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. import {
  2. Body,
  3. Controller,
  4. Delete,
  5. Get,
  6. Param,
  7. Post,
  8. Put,
  9. Query,
  10. } from '@nestjs/common';
  11. import * as path from 'path';
  12. import { AppService } from './app.service';
  13. import { EventsGateway } from './events.gateway';
  14. interface FileRecord {
  15. dataset: string;
  16. input: string;
  17. output: string;
  18. date: string;
  19. }
  20. @Controller()
  21. export class AppController {
  22. constructor(
  23. private readonly appService: AppService,
  24. private readonly eventsGateway: EventsGateway,
  25. ) {}
  26. // List available datasets
  27. @Get('files')
  28. listDatasets() {
  29. return this.appService.listDatasets();
  30. }
  31. // List all datasets (including disabled ones)
  32. @Get('files/all-datasets')
  33. listAllDatasets() {
  34. return this.appService.listAllDatasets();
  35. }
  36. // Get total successful files across all datasets
  37. @Get('files/stats/successful')
  38. getTotalSuccessfulFiles() {
  39. return this.appService.getTotalSuccessfulFiles();
  40. }
  41. // Get total processed files across all datasets
  42. @Get('files/stats/processed')
  43. getTotalProcessedFiles() {
  44. return this.appService.getTotalProcessedFiles();
  45. }
  46. @Get()
  47. getRoot() {
  48. return {
  49. status: 'ok',
  50. message: 'Watch Finished API Service',
  51. datetime: new Date().toISOString(),
  52. uptime: process.uptime(),
  53. };
  54. }
  55. @Get('ready')
  56. getReady() {
  57. return { status: 'ready', datetime: new Date().toISOString() };
  58. }
  59. @Get('health')
  60. getHealth() {
  61. return { status: 'healthy', datetime: new Date().toISOString() };
  62. }
  63. // --- Unified files CRUD endpoints below ---
  64. // Create a file record
  65. @Post('files/:dataset/:file')
  66. createFile(
  67. @Param('dataset') dataset: string,
  68. @Param('file') file: string,
  69. @Body() payload: any,
  70. ) {
  71. const result = this.appService.setFile(dataset, file, payload);
  72. this.eventsGateway.emitFileUpdate({
  73. type: 'created',
  74. dataset,
  75. file,
  76. data: payload,
  77. });
  78. return result;
  79. }
  80. // Update a file record
  81. @Post('files/:dataset/:file/update')
  82. updateFile(
  83. @Param('dataset') dataset: string,
  84. @Param('file') file: string,
  85. @Body() payload: any,
  86. ) {
  87. const result = this.appService.setFile(dataset, file, payload);
  88. this.eventsGateway.emitFileUpdate({
  89. type: 'updated',
  90. dataset,
  91. file,
  92. data: payload,
  93. });
  94. return result;
  95. }
  96. // Read a file record
  97. @Get('files/:dataset/:file')
  98. readFile(@Param('dataset') dataset: string, @Param('file') file: string) {
  99. return this.appService.findFile(dataset, file);
  100. }
  101. // Destroy a file record (hard delete)
  102. @Delete('files/:dataset/:file')
  103. destroyFile(@Param('dataset') dataset: string, @Param('file') file: string) {
  104. const result = this.appService.removeFile(dataset, file, false);
  105. this.eventsGateway.emitFileUpdate({
  106. type: 'deleted',
  107. dataset,
  108. file,
  109. });
  110. return result;
  111. }
  112. // Clear all file records from database
  113. @Delete('files')
  114. clearAllFiles() {
  115. const result = this.appService.clearAllFiles();
  116. // Emit a general file update to notify clients
  117. this.eventsGateway.emitFileUpdate({
  118. type: 'cleared',
  119. dataset: '*',
  120. file: '*',
  121. });
  122. return { message: 'All file records cleared' };
  123. }
  124. // Requeue a file for processing (creates a task)
  125. @Post('files/:dataset/:file/requeue')
  126. requeueFile(
  127. @Param('dataset') dataset: string,
  128. @Param('file') file: string,
  129. @Body('preset') preset?: string,
  130. ) {
  131. // Get dataset configuration to find the preset and output configuration
  132. const datasetConfig = this.appService.getDatasetConfig();
  133. let processingPreset = preset;
  134. let output: string;
  135. // Try to find existing file record first
  136. const fileRecord = this.appService.findFile(dataset, file) as FileRecord;
  137. // Determine preset from dataset config (same logic as watcher)
  138. if (!processingPreset) {
  139. processingPreset = 'Fast 1080p30'; // Default fallback
  140. }
  141. if (datasetConfig[dataset]) {
  142. const datasetObj = datasetConfig[dataset];
  143. // Find the path configuration that matches this file
  144. for (const pathKey of Object.keys(datasetObj)) {
  145. if (pathKey !== 'enabled' && file.startsWith(pathKey)) {
  146. const pathConfig = datasetObj[pathKey];
  147. if (pathConfig && pathConfig.preset) {
  148. processingPreset = pathConfig.preset;
  149. break;
  150. }
  151. }
  152. }
  153. // Fallback to dataset-level preset if no path-specific preset found
  154. if (processingPreset === 'Fast 1080p30' && datasetObj.preset) {
  155. processingPreset = datasetObj.preset;
  156. }
  157. }
  158. // Determine destination and ext from dataset config
  159. let destination: string | undefined;
  160. let ext = '.mkv'; // Default extension
  161. if (datasetConfig[dataset]) {
  162. const datasetObj = datasetConfig[dataset];
  163. // Find the path configuration that matches this file
  164. for (const pathKey of Object.keys(datasetObj)) {
  165. if (pathKey !== 'enabled' && file.startsWith(pathKey)) {
  166. const pathConfig = datasetObj[pathKey];
  167. if (pathConfig) {
  168. if (pathConfig.destination) {
  169. destination = pathConfig.destination;
  170. }
  171. if (pathConfig.ext) {
  172. ext = pathConfig.ext.startsWith('.')
  173. ? pathConfig.ext
  174. : '.' + pathConfig.ext;
  175. }
  176. }
  177. break;
  178. }
  179. }
  180. // Fallback to dataset-level config
  181. if (!destination && datasetObj.destination) {
  182. destination = datasetObj.destination;
  183. }
  184. if (ext === '.mkv' && datasetObj.ext) {
  185. ext = datasetObj.ext.startsWith('.')
  186. ? datasetObj.ext
  187. : '.' + datasetObj.ext;
  188. }
  189. }
  190. if (fileRecord) {
  191. // Use existing file record
  192. output = fileRecord.output;
  193. } else {
  194. // No existing file record - determine output path from dataset config
  195. if (destination) {
  196. const fileName = path.basename(file, path.extname(file));
  197. output = path.join(destination, fileName + ext);
  198. } else {
  199. // Default: same directory with new extension
  200. output = path.join(
  201. path.dirname(file),
  202. path.basename(file, path.extname(file)) + ext,
  203. );
  204. }
  205. }
  206. // Fallback to default preset if no preset found
  207. if (!processingPreset) {
  208. processingPreset = 'Fast 1080p30';
  209. }
  210. // Create a task for processing
  211. const task = this.appService.createTask({
  212. dataset,
  213. input: file,
  214. output: output,
  215. preset: processingPreset,
  216. priority: 1, // Higher priority for manual requeues
  217. status: 'pending', // Mark as pending for processing
  218. });
  219. // Check if file already exists
  220. const existingFile = this.appService.findFile(dataset, file);
  221. // Update file record (create if doesn't exist, or update output if requeueing)
  222. this.appService.setFile(dataset, file, {
  223. date: new Date().toISOString(),
  224. output: output,
  225. });
  226. // Emit file update
  227. this.eventsGateway.emitFileUpdate({
  228. type: 'requeued',
  229. dataset,
  230. file,
  231. taskId: task.id,
  232. });
  233. // Emit task update to indicate task was created
  234. this.eventsGateway.emitTaskUpdate({
  235. type: 'created',
  236. taskId: task.id,
  237. task: 'handbrake',
  238. input: file,
  239. output: output,
  240. preset: processingPreset,
  241. });
  242. return { taskId: task.id, message: 'Task created for processing' };
  243. }
  244. @Get('files/:dataset')
  245. getAllFiles(@Param('dataset') dataset: string) {
  246. return this.appService.getAllFiles(dataset);
  247. }
  248. @Get('files/:dataset/deleted-older-than/:isoDate')
  249. getDeletedOlderThan(
  250. @Param('dataset') dataset: string,
  251. @Param('isoDate') isoDate: string,
  252. ) {
  253. return this.appService.getDeletedOlderThan(dataset, isoDate);
  254. }
  255. // --- Additional endpoints below ---
  256. @Post('handbrake/process')
  257. processWithHandbrake(
  258. @Body('input') input: string,
  259. @Body('output') output: string,
  260. @Body('preset') preset: string,
  261. ) {
  262. this.eventsGateway.emitTaskUpdate({
  263. type: 'started',
  264. task: 'handbrake',
  265. input,
  266. output,
  267. preset,
  268. });
  269. return this.appService.processWithHandbrake(input, output, preset);
  270. }
  271. @Get('handbrake/presets')
  272. getHandbrakePresets() {
  273. return this.appService.getHandbrakePresets();
  274. }
  275. @Post('maintenance/cleanup')
  276. cleanup(@Body('file') file: string, @Body('dirs') dirs: string[]) {
  277. const result = this.appService.cleanup(file, dirs);
  278. this.eventsGateway.emitMaintenanceUpdate({
  279. type: 'cleanup',
  280. file,
  281. dirs,
  282. });
  283. return result;
  284. }
  285. @Post('maintenance/purge')
  286. purge(
  287. @Body('dirs') dirs: string[],
  288. @Body('dayMs') dayMs?: number,
  289. @Body('cleanerMs') cleanerMs?: number,
  290. ) {
  291. const result = this.appService.purge(dirs, dayMs, cleanerMs);
  292. this.eventsGateway.emitMaintenanceUpdate({
  293. type: 'purge',
  294. dirs,
  295. dayMs,
  296. cleanerMs,
  297. });
  298. return result;
  299. }
  300. @Post('maintenance/prune')
  301. prune(@Body('dirs') dirs: string[]) {
  302. const result = this.appService.prune(dirs);
  303. this.eventsGateway.emitTaskUpdate({
  304. type: 'prune',
  305. dirs,
  306. });
  307. return result;
  308. }
  309. // Task maintenance endpoints
  310. @Post('maintenance/tasks/cleanup')
  311. cleanupTasks(
  312. @Body('status') status?: string,
  313. @Body('olderThanDays') olderThanDays?: number,
  314. ) {
  315. let result;
  316. if (status) {
  317. result = this.appService.cleanupTasksByStatus(status, olderThanDays);
  318. } else if (olderThanDays) {
  319. result = this.appService.cleanupOldTasks(olderThanDays);
  320. } else {
  321. // Default: cleanup completed and failed tasks older than 7 days
  322. result = this.appService.cleanupTasksByStatus('completed', 7);
  323. this.appService.cleanupTasksByStatus('failed', 7);
  324. this.appService.cleanupTasksByStatus('skipped', 7);
  325. }
  326. this.eventsGateway.emitTaskUpdate({
  327. type: 'task_cleanup',
  328. status,
  329. olderThanDays,
  330. });
  331. return result;
  332. }
  333. @Post('maintenance/tasks/archive')
  334. archiveTasks(@Body('daysOld') daysOld: number = 30): { changes?: number } {
  335. const result = this.appService.archiveOldTasks(daysOld);
  336. this.eventsGateway.emitTaskUpdate({
  337. type: 'task_archive',
  338. daysOld,
  339. });
  340. return result;
  341. }
  342. @Post('maintenance/tasks/purge')
  343. purgeAllTasks() {
  344. const result = this.appService.purgeAllTasks();
  345. this.eventsGateway.emitTaskUpdate({
  346. type: 'task_purge',
  347. });
  348. return result;
  349. }
  350. @Get('maintenance/tasks/stats')
  351. getTaskStats() {
  352. return this.appService.getTaskStats();
  353. }
  354. @Post('maintenance/tasks/scheduled-cleanup')
  355. runScheduledTaskCleanup() {
  356. const result = this.appService.scheduledTaskCleanup();
  357. this.eventsGateway.emitTaskUpdate({
  358. type: 'scheduled_task_cleanup',
  359. });
  360. return result;
  361. }
  362. // Duplicate file review endpoints
  363. @Post('maintenance/duplicates/scan')
  364. async scanDuplicates(@Body('resetExisting') resetExisting?: boolean) {
  365. return await this.appService.scanDuplicateFiles(resetExisting);
  366. }
  367. @Get('maintenance/duplicates')
  368. listDuplicates(
  369. @Query('status') status?: string,
  370. @Query('dataset') dataset?: string,
  371. ) {
  372. return this.appService.listDuplicateGroups(status, dataset);
  373. }
  374. @Post('maintenance/duplicates/:id/mark')
  375. markDuplicate(
  376. @Param('id') id: string,
  377. @Body('status') status: 'pending' | 'reviewed' | 'purged',
  378. @Body('note') note?: string,
  379. ) {
  380. return this.appService.markDuplicateGroup(Number(id), status, note);
  381. }
  382. @Post('maintenance/duplicates/:id/purge')
  383. purgeDuplicate(
  384. @Param('id') id: string,
  385. @Body('files') files: string[],
  386. @Body('note') note?: string,
  387. ) {
  388. return this.appService.purgeDuplicateFiles(Number(id), files || [], note);
  389. }
  390. @Get('config/settings')
  391. getSettings(
  392. @Query('key') key?: string,
  393. @Query('default') defaultValue?: any,
  394. ) {
  395. return this.appService.getSettings(key, defaultValue);
  396. }
  397. @Get('config/settings/:key')
  398. getSetting(@Param('key') key: string) {
  399. return this.appService.getSettings(key);
  400. }
  401. @Post('config/settings')
  402. setSettings(@Body() settings: Record<string, any>) {
  403. // console.log('Received setSettings request:', settings);
  404. try {
  405. const result = this.appService.setSettings(settings);
  406. // console.log('setSettings result:', result);
  407. this.eventsGateway.emitSettingsUpdate({
  408. type: 'settings',
  409. action: 'update',
  410. settings,
  411. });
  412. return result;
  413. } catch (error) {
  414. console.error('Error in setSettings controller:', error);
  415. throw error;
  416. }
  417. }
  418. @Delete('config/settings/:key')
  419. deleteSetting(@Param('key') key: string) {
  420. const result = this.appService.deleteSetting(key);
  421. this.eventsGateway.emitSettingsUpdate({
  422. type: 'settings',
  423. action: 'delete',
  424. key,
  425. });
  426. return result;
  427. }
  428. @Get('config/file/:name')
  429. getConfigFile(@Param('name') name: string) {
  430. return this.appService.getConfigFile(name);
  431. }
  432. @Get('config/list')
  433. listConfigs() {
  434. return this.appService.listConfigs();
  435. }
  436. @Post('watcher/start')
  437. startWatcher(
  438. @Body('watches') watches: string[],
  439. @Body('options') options?: any,
  440. ) {
  441. return this.appService.startWatcher(watches, options);
  442. }
  443. @Post('watcher/stop')
  444. async stopWatcher() {
  445. return this.appService.stopWatcher();
  446. }
  447. @Get('watcher/status')
  448. watcherStatus() {
  449. return this.appService.watcherStatus();
  450. }
  451. @Post('files/expire')
  452. deleteExpiredFiles(@Body('days') days?: number) {
  453. return { deleted: this.appService.deleteExpiredFiles(days) };
  454. }
  455. @Post('files/migrate')
  456. migrateJsonToSqlite(@Body() opts: { datasets?: string[]; dataDir?: string }) {
  457. this.appService.migrateJsonToSqlite(opts);
  458. return { migrated: true };
  459. }
  460. // Task management endpoints
  461. @Get('tasks')
  462. getAllTasks() {
  463. return this.appService.getAllTasks();
  464. }
  465. @Get('tasks/queue/status')
  466. getQueueStatus() {
  467. return this.appService.getQueueStatus();
  468. }
  469. @Get('tasks/queue/settings')
  470. getQueueSettings() {
  471. return this.appService.getQueueSettings();
  472. }
  473. @Post('tasks/queue/settings')
  474. updateQueueSettings(@Body() settings: any) {
  475. return this.appService.updateQueueSettings(settings);
  476. }
  477. @Get('tasks/processing-status')
  478. getTaskProcessingStatus() {
  479. return this.appService.taskProcessingStatus();
  480. }
  481. @Get('tasks/:id')
  482. getTaskById(@Param('id') id: string) {
  483. return this.appService.getTaskById(parseInt(id));
  484. }
  485. @Delete('tasks/:id')
  486. deleteTask(@Param('id') id: string) {
  487. return this.appService.deleteTask(parseInt(id));
  488. }
  489. @Post('tasks/start-processing')
  490. startTaskProcessing() {
  491. return this.appService.startTaskProcessing();
  492. }
  493. @Post('tasks/stop-processing')
  494. stopTaskProcessing(@Body('graceful') graceful?: boolean) {
  495. const shouldGraceful = graceful !== false;
  496. return this.appService.stopTaskProcessing(shouldGraceful);
  497. }
  498. // Task management endpoints
  499. @Get('tasks')
  500. getTasks() {
  501. return this.appService.getAllTasks();
  502. }
  503. @Get('tasks/:id')
  504. getTask(@Param('id') id: string) {
  505. return this.appService.getTaskById(parseInt(id));
  506. }
  507. @Put('tasks/:id')
  508. updateTask(@Param('id') id: string, @Body() updates: any) {
  509. return this.appService.updateTask(parseInt(id), updates);
  510. }
  511. @Post('tasks')
  512. createTask(@Body() taskData: any) {
  513. return this.appService.createTask(taskData);
  514. }
  515. }