import { Body, Controller, Delete, Get, Param, Post, Put, Query, } from '@nestjs/common'; import * as path from 'path'; import { AppService } from './app.service'; import { EventsGateway } from './events.gateway'; interface FileRecord { dataset: string; input: string; output: string; date: string; } @Controller() export class AppController { constructor( private readonly appService: AppService, private readonly eventsGateway: EventsGateway, ) {} // List available datasets @Get('files') listDatasets() { return this.appService.listDatasets(); } // List all datasets (including disabled ones) @Get('files/all-datasets') listAllDatasets() { return this.appService.listAllDatasets(); } // Get total successful files across all datasets @Get('files/stats/successful') getTotalSuccessfulFiles() { return this.appService.getTotalSuccessfulFiles(); } // Get total processed files across all datasets @Get('files/stats/processed') getTotalProcessedFiles() { return this.appService.getTotalProcessedFiles(); } @Get() getRoot() { return { status: 'ok', message: 'Watch Finished API Service', datetime: new Date().toISOString(), uptime: process.uptime(), }; } @Get('ready') getReady() { return { status: 'ready', datetime: new Date().toISOString() }; } @Get('health') getHealth() { return { status: 'healthy', datetime: new Date().toISOString() }; } // --- Unified files CRUD endpoints below --- // Create a file record @Post('files/:dataset/:file') createFile( @Param('dataset') dataset: string, @Param('file') file: string, @Body() payload: any, ) { const result = this.appService.setFile(dataset, file, payload); this.eventsGateway.emitFileUpdate({ type: 'created', dataset, file, data: payload, }); return result; } // Update a file record @Post('files/:dataset/:file/update') updateFile( @Param('dataset') dataset: string, @Param('file') file: string, @Body() payload: any, ) { const result = this.appService.setFile(dataset, file, payload); this.eventsGateway.emitFileUpdate({ type: 'updated', dataset, file, data: payload, }); return result; } // Read a file record @Get('files/:dataset/:file') readFile(@Param('dataset') dataset: string, @Param('file') file: string) { return this.appService.findFile(dataset, file); } // Destroy a file record (hard delete) @Delete('files/:dataset/:file') destroyFile(@Param('dataset') dataset: string, @Param('file') file: string) { const result = this.appService.removeFile(dataset, file, false); this.eventsGateway.emitFileUpdate({ type: 'deleted', dataset, file, }); return result; } // Clear all file records from database @Delete('files') clearAllFiles() { const result = this.appService.clearAllFiles(); // Emit a general file update to notify clients this.eventsGateway.emitFileUpdate({ type: 'cleared', dataset: '*', file: '*', }); return { message: 'All file records cleared' }; } // Requeue a file for processing (creates a task) @Post('files/:dataset/:file/requeue') requeueFile( @Param('dataset') dataset: string, @Param('file') file: string, @Body('preset') preset?: string, ) { // Get dataset configuration to find the preset and output configuration const datasetConfig = this.appService.getDatasetConfig(); let processingPreset = preset; let output: string; // Try to find existing file record first const fileRecord = this.appService.findFile(dataset, file) as FileRecord; // Determine preset from dataset config (same logic as watcher) if (!processingPreset) { processingPreset = 'Fast 1080p30'; // Default fallback } if (datasetConfig[dataset]) { const datasetObj = datasetConfig[dataset]; // Find the path configuration that matches this file for (const pathKey of Object.keys(datasetObj)) { if (pathKey !== 'enabled' && file.startsWith(pathKey)) { const pathConfig = datasetObj[pathKey]; if (pathConfig && pathConfig.preset) { processingPreset = pathConfig.preset; break; } } } // Fallback to dataset-level preset if no path-specific preset found if (processingPreset === 'Fast 1080p30' && datasetObj.preset) { processingPreset = datasetObj.preset; } } // Determine destination and ext from dataset config let destination: string | undefined; let ext = '.mkv'; // Default extension if (datasetConfig[dataset]) { const datasetObj = datasetConfig[dataset]; // Find the path configuration that matches this file for (const pathKey of Object.keys(datasetObj)) { if (pathKey !== 'enabled' && file.startsWith(pathKey)) { const pathConfig = datasetObj[pathKey]; if (pathConfig) { if (pathConfig.destination) { destination = pathConfig.destination; } if (pathConfig.ext) { ext = pathConfig.ext.startsWith('.') ? pathConfig.ext : '.' + pathConfig.ext; } } break; } } // Fallback to dataset-level config if (!destination && datasetObj.destination) { destination = datasetObj.destination; } if (ext === '.mkv' && datasetObj.ext) { ext = datasetObj.ext.startsWith('.') ? datasetObj.ext : '.' + datasetObj.ext; } } if (fileRecord) { // Use existing file record output = fileRecord.output; } else { // No existing file record - determine output path from dataset config if (destination) { const fileName = path.basename(file, path.extname(file)); output = path.join(destination, fileName + ext); } else { // Default: same directory with new extension output = path.join( path.dirname(file), path.basename(file, path.extname(file)) + ext, ); } } // Fallback to default preset if no preset found if (!processingPreset) { processingPreset = 'Fast 1080p30'; } // Create a task for processing const task = this.appService.createTask({ dataset, input: file, output: output, preset: processingPreset, priority: 1, // Higher priority for manual requeues status: 'pending', // Mark as pending for processing }); // Check if file already exists const existingFile = this.appService.findFile(dataset, file); // Update file record (create if doesn't exist, or update output if requeueing) this.appService.setFile(dataset, file, { date: new Date().toISOString(), output: output, }); // Emit file update this.eventsGateway.emitFileUpdate({ type: 'requeued', dataset, file, taskId: task.id, }); // Emit task update to indicate task was created this.eventsGateway.emitTaskUpdate({ type: 'created', taskId: task.id, task: 'handbrake', input: file, output: output, preset: processingPreset, }); return { taskId: task.id, message: 'Task created for processing' }; } @Get('files/:dataset') getAllFiles(@Param('dataset') dataset: string) { return this.appService.getAllFiles(dataset); } @Get('files/:dataset/deleted-older-than/:isoDate') getDeletedOlderThan( @Param('dataset') dataset: string, @Param('isoDate') isoDate: string, ) { return this.appService.getDeletedOlderThan(dataset, isoDate); } // --- Additional endpoints below --- @Post('handbrake/process') processWithHandbrake( @Body('input') input: string, @Body('output') output: string, @Body('preset') preset: string, ) { this.eventsGateway.emitTaskUpdate({ type: 'started', task: 'handbrake', input, output, preset, }); return this.appService.processWithHandbrake(input, output, preset); } @Get('handbrake/presets') getHandbrakePresets() { return this.appService.getHandbrakePresets(); } @Post('maintenance/cleanup') cleanup(@Body('file') file: string, @Body('dirs') dirs: string[]) { const result = this.appService.cleanup(file, dirs); this.eventsGateway.emitMaintenanceUpdate({ type: 'cleanup', file, dirs, }); return result; } @Post('maintenance/purge') purge( @Body('dirs') dirs: string[], @Body('dayMs') dayMs?: number, @Body('cleanerMs') cleanerMs?: number, ) { const result = this.appService.purge(dirs, dayMs, cleanerMs); this.eventsGateway.emitMaintenanceUpdate({ type: 'purge', dirs, dayMs, cleanerMs, }); return result; } @Post('maintenance/prune') prune(@Body('dirs') dirs: string[]) { const result = this.appService.prune(dirs); this.eventsGateway.emitTaskUpdate({ type: 'prune', dirs, }); return result; } // Task maintenance endpoints @Post('maintenance/tasks/cleanup') cleanupTasks( @Body('status') status?: string, @Body('olderThanDays') olderThanDays?: number, ) { let result; if (status) { result = this.appService.cleanupTasksByStatus(status, olderThanDays); } else if (olderThanDays) { result = this.appService.cleanupOldTasks(olderThanDays); } else { // Default: cleanup completed and failed tasks older than 7 days result = this.appService.cleanupTasksByStatus('completed', 7); this.appService.cleanupTasksByStatus('failed', 7); this.appService.cleanupTasksByStatus('skipped', 7); } this.eventsGateway.emitTaskUpdate({ type: 'task_cleanup', status, olderThanDays, }); return result; } @Post('maintenance/tasks/archive') archiveTasks(@Body('daysOld') daysOld: number = 30): { changes?: number } { const result = this.appService.archiveOldTasks(daysOld); this.eventsGateway.emitTaskUpdate({ type: 'task_archive', daysOld, }); return result; } @Post('maintenance/tasks/purge') purgeAllTasks() { const result = this.appService.purgeAllTasks(); this.eventsGateway.emitTaskUpdate({ type: 'task_purge', }); return result; } @Get('maintenance/tasks/stats') getTaskStats() { return this.appService.getTaskStats(); } @Post('maintenance/tasks/scheduled-cleanup') runScheduledTaskCleanup() { const result = this.appService.scheduledTaskCleanup(); this.eventsGateway.emitTaskUpdate({ type: 'scheduled_task_cleanup', }); return result; } // Duplicate file review endpoints @Post('maintenance/duplicates/scan') async scanDuplicates(@Body('resetExisting') resetExisting?: boolean) { return await this.appService.scanDuplicateFiles(resetExisting); } @Get('maintenance/duplicates') listDuplicates( @Query('status') status?: string, @Query('dataset') dataset?: string, ) { return this.appService.listDuplicateGroups(status, dataset); } @Post('maintenance/duplicates/:id/mark') markDuplicate( @Param('id') id: string, @Body('status') status: 'pending' | 'reviewed' | 'purged', @Body('note') note?: string, ) { return this.appService.markDuplicateGroup(Number(id), status, note); } @Post('maintenance/duplicates/:id/purge') purgeDuplicate( @Param('id') id: string, @Body('files') files: string[], @Body('note') note?: string, ) { return this.appService.purgeDuplicateFiles(Number(id), files || [], note); } @Get('config/settings') getSettings( @Query('key') key?: string, @Query('default') defaultValue?: any, ) { return this.appService.getSettings(key, defaultValue); } @Get('config/settings/:key') getSetting(@Param('key') key: string) { return this.appService.getSettings(key); } @Post('config/settings') setSettings(@Body() settings: Record) { // console.log('Received setSettings request:', settings); try { const result = this.appService.setSettings(settings); // console.log('setSettings result:', result); this.eventsGateway.emitSettingsUpdate({ type: 'settings', action: 'update', settings, }); return result; } catch (error) { console.error('Error in setSettings controller:', error); throw error; } } @Delete('config/settings/:key') deleteSetting(@Param('key') key: string) { const result = this.appService.deleteSetting(key); this.eventsGateway.emitSettingsUpdate({ type: 'settings', action: 'delete', key, }); return result; } @Get('config/file/:name') getConfigFile(@Param('name') name: string) { return this.appService.getConfigFile(name); } @Get('config/list') listConfigs() { return this.appService.listConfigs(); } @Post('watcher/start') startWatcher( @Body('watches') watches: string[], @Body('options') options?: any, ) { return this.appService.startWatcher(watches, options); } @Post('watcher/stop') async stopWatcher() { return this.appService.stopWatcher(); } @Get('watcher/status') watcherStatus() { return this.appService.watcherStatus(); } @Post('files/expire') deleteExpiredFiles(@Body('days') days?: number) { return { deleted: this.appService.deleteExpiredFiles(days) }; } @Post('files/migrate') migrateJsonToSqlite(@Body() opts: { datasets?: string[]; dataDir?: string }) { this.appService.migrateJsonToSqlite(opts); return { migrated: true }; } // Task management endpoints @Get('tasks') getAllTasks() { return this.appService.getAllTasks(); } @Get('tasks/queue/status') getQueueStatus() { return this.appService.getQueueStatus(); } @Get('tasks/queue/settings') getQueueSettings() { return this.appService.getQueueSettings(); } @Post('tasks/queue/settings') updateQueueSettings(@Body() settings: any) { return this.appService.updateQueueSettings(settings); } @Get('tasks/processing-status') getTaskProcessingStatus() { return this.appService.taskProcessingStatus(); } @Get('tasks/:id') getTaskById(@Param('id') id: string) { return this.appService.getTaskById(parseInt(id)); } @Delete('tasks/:id') deleteTask(@Param('id') id: string) { return this.appService.deleteTask(parseInt(id)); } @Post('tasks/start-processing') startTaskProcessing() { return this.appService.startTaskProcessing(); } @Post('tasks/stop-processing') stopTaskProcessing(@Body('graceful') graceful?: boolean) { const shouldGraceful = graceful !== false; return this.appService.stopTaskProcessing(shouldGraceful); } // Task management endpoints @Get('tasks') getTasks() { return this.appService.getAllTasks(); } @Get('tasks/:id') getTask(@Param('id') id: string) { return this.appService.getTaskById(parseInt(id)); } @Put('tasks/:id') updateTask(@Param('id') id: string, @Body() updates: any) { return this.appService.updateTask(parseInt(id), updates); } @Post('tasks') createTask(@Body() taskData: any) { return this.appService.createTask(taskData); } }