|
@@ -1,10 +1,19 @@
|
|
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
|
|
import chokidar, { FSWatcher } from 'chokidar';
|
|
import chokidar, { FSWatcher } from 'chokidar';
|
|
|
|
|
+import fs from 'fs';
|
|
|
import path from 'path';
|
|
import path from 'path';
|
|
|
import { DatasetsService } from './datasets.service';
|
|
import { DatasetsService } from './datasets.service';
|
|
|
|
|
+import { DbService } from './db.service';
|
|
|
import { EventsGateway } from './events.gateway';
|
|
import { EventsGateway } from './events.gateway';
|
|
|
import { TaskQueueService } from './task-queue.service';
|
|
import { TaskQueueService } from './task-queue.service';
|
|
|
|
|
|
|
|
|
|
+interface FileRecord {
|
|
|
|
|
+ dataset: string;
|
|
|
|
|
+ input: string;
|
|
|
|
|
+ output: string;
|
|
|
|
|
+ date: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
@Injectable()
|
|
@Injectable()
|
|
|
export class WatcherService {
|
|
export class WatcherService {
|
|
|
private watcher: FSWatcher | null = null;
|
|
private watcher: FSWatcher | null = null;
|
|
@@ -15,6 +24,7 @@ export class WatcherService {
|
|
|
|
|
|
|
|
constructor(
|
|
constructor(
|
|
|
@Inject(DatasetsService) private readonly datasetsService: DatasetsService,
|
|
@Inject(DatasetsService) private readonly datasetsService: DatasetsService,
|
|
|
|
|
+ @Inject(DbService) private readonly db: DbService,
|
|
|
@Inject(EventsGateway) private readonly eventsGateway: EventsGateway,
|
|
@Inject(EventsGateway) private readonly eventsGateway: EventsGateway,
|
|
|
@Inject(TaskQueueService) private readonly taskQueue: TaskQueueService,
|
|
@Inject(TaskQueueService) private readonly taskQueue: TaskQueueService,
|
|
|
) {}
|
|
) {}
|
|
@@ -29,10 +39,19 @@ export class WatcherService {
|
|
|
watches && watches.length > 0
|
|
watches && watches.length > 0
|
|
|
? watches
|
|
? watches
|
|
|
: this.datasetsService.getEnabledDatasetPaths();
|
|
: this.datasetsService.getEnabledDatasetPaths();
|
|
|
- this.watcher = chokidar.watch(enabledWatches, options);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // Override options to be more conservative for file descriptor limits
|
|
|
|
|
+ const conservativeOptions = {
|
|
|
|
|
+ ...options,
|
|
|
|
|
+ interval: Math.max(options.interval || 10000, 30000), // Minimum 30 seconds
|
|
|
|
|
+ depth: options.depth !== undefined ? options.depth : 1,
|
|
|
|
|
+ ignorePermissionErrors: true,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ this.watcher = chokidar.watch(enabledWatches, conservativeOptions);
|
|
|
this.isWatching = true;
|
|
this.isWatching = true;
|
|
|
this.lastWatches = enabledWatches;
|
|
this.lastWatches = enabledWatches;
|
|
|
- this.lastOptions = options;
|
|
|
|
|
|
|
+ this.lastOptions = conservativeOptions;
|
|
|
this.watcher
|
|
this.watcher
|
|
|
.on('add', (file: string) => {
|
|
.on('add', (file: string) => {
|
|
|
this.logger.log(`File added: ${file}`);
|
|
this.logger.log(`File added: ${file}`);
|
|
@@ -75,6 +94,12 @@ export class WatcherService {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Validate that the file has proper video headers
|
|
|
|
|
+ if (!this.isValidVideoFile(file)) {
|
|
|
|
|
+ this.logger.warn(`File appears to be corrupted or incomplete: ${file}`);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// Get dataset configuration
|
|
// Get dataset configuration
|
|
|
const datasetConfig = this.datasetsService.getDatasetConfig();
|
|
const datasetConfig = this.datasetsService.getDatasetConfig();
|
|
|
const datasetSettings = datasetConfig[dataset];
|
|
const datasetSettings = datasetConfig[dataset];
|
|
@@ -86,14 +111,211 @@ export class WatcherService {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Determine preset
|
|
|
|
|
- let preset = datasetSettings.preset || 'Fast 1080p30';
|
|
|
|
|
|
|
+ // Determine preset and output configuration - find the specific path configuration
|
|
|
|
|
+ let preset = 'Fast 1080p30'; // Default fallback
|
|
|
|
|
+ let destination: string | undefined;
|
|
|
|
|
+ let ext = '.mkv'; // Default extension
|
|
|
|
|
+ let clean: any;
|
|
|
|
|
+ let folder = false; // Default: don't create subfolders
|
|
|
|
|
+
|
|
|
|
|
+ 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.preset) {
|
|
|
|
|
+ preset = pathConfig.preset;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (pathConfig.destination) {
|
|
|
|
|
+ destination = pathConfig.destination;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (pathConfig.ext) {
|
|
|
|
|
+ // Ensure extension starts with a dot
|
|
|
|
|
+ ext = pathConfig.ext.startsWith('.')
|
|
|
|
|
+ ? pathConfig.ext
|
|
|
|
|
+ : '.' + pathConfig.ext;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (pathConfig.clean) {
|
|
|
|
|
+ clean = pathConfig.clean;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (typeof pathConfig.folder === 'boolean') {
|
|
|
|
|
+ folder = pathConfig.folder;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // If no path-specific config found, try the old format (for backward compatibility)
|
|
|
|
|
+ if (preset === 'Fast 1080p30' && datasetObj.preset) {
|
|
|
|
|
+ preset = datasetObj.preset;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!destination && datasetObj.destination) {
|
|
|
|
|
+ destination = datasetObj.destination;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (ext === '.mkv' && datasetObj.ext) {
|
|
|
|
|
+ ext = datasetObj.ext.startsWith('.')
|
|
|
|
|
+ ? datasetObj.ext
|
|
|
|
|
+ : '.' + datasetObj.ext;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!clean && datasetObj.clean) {
|
|
|
|
|
+ clean = datasetObj.clean;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!folder && typeof datasetObj.folder === 'boolean') {
|
|
|
|
|
+ folder = datasetObj.folder;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Create output path based on configuration
|
|
|
|
|
+ let output: string;
|
|
|
|
|
+ if (destination) {
|
|
|
|
|
+ // If destination is specified, use it as the base path
|
|
|
|
|
+ const fileName = path.basename(file, path.extname(file));
|
|
|
|
|
+ let cleanFileName = fileName;
|
|
|
|
|
+
|
|
|
|
|
+ // Apply cleaning rules if specified
|
|
|
|
|
+ if (clean && typeof clean === 'object') {
|
|
|
|
|
+ for (const [pattern, replacement] of Object.entries(clean)) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const regex = new RegExp(pattern, 'g');
|
|
|
|
|
+ cleanFileName = cleanFileName.replace(regex, replacement as string);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ this.logger.warn(
|
|
|
|
|
+ `Invalid regex pattern in clean config: ${pattern}`,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // If folder is enabled, create a subdirectory based on the cleaned filename
|
|
|
|
|
+ if (folder) {
|
|
|
|
|
+ // Try to extract series/site name from filename pattern
|
|
|
|
|
+ // Look for common date/episode patterns and take everything before the first separator
|
|
|
|
|
+ const patterns = [
|
|
|
|
|
+ /\d{2}\.\d{2}\.\d{2}/, // 24.12.17
|
|
|
|
|
+ /[A-Za-z]\d{3,4}/, // E651, S123, etc.
|
|
|
|
|
+ /\d{4}/, // 2024, 1234, etc.
|
|
|
|
|
+ /\.\d+/, // .123, .2024, etc.
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ let folderName = cleanFileName.charAt(0).toUpperCase(); // fallback
|
|
|
|
|
+ let foundMatch = false;
|
|
|
|
|
+
|
|
|
|
|
+ for (const pattern of patterns) {
|
|
|
|
|
+ const match = cleanFileName.match(pattern);
|
|
|
|
|
+ if (match && match.index !== undefined && match.index > 0) {
|
|
|
|
|
+ // Take everything before the pattern as the potential folder name
|
|
|
|
|
+ let potentialFolderName = cleanFileName
|
|
|
|
|
+ .substring(0, match.index)
|
|
|
|
|
+ .trim();
|
|
|
|
|
+ // Remove trailing dots if any
|
|
|
|
|
+ potentialFolderName = potentialFolderName.replace(/\.$/, '');
|
|
|
|
|
+
|
|
|
|
|
+ // If the potential folder name contains dots, take only the first part (site name)
|
|
|
|
|
+ // This handles patterns like "Site.Series.Date..." where we want just "Site"
|
|
|
|
|
+ if (potentialFolderName.includes('.')) {
|
|
|
|
|
+ folderName = potentialFolderName.split('.')[0];
|
|
|
|
|
+ } else {
|
|
|
|
|
+ folderName = potentialFolderName;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ foundMatch = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // If no pattern matched but filename contains dots, try to extract site name
|
|
|
|
|
+ if (!foundMatch && cleanFileName.includes('.')) {
|
|
|
|
|
+ const parts = cleanFileName.split('.');
|
|
|
|
|
+ if (parts.length > 1 && parts[0].length > 0) {
|
|
|
|
|
+ folderName = parts[0];
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if (cleanFileName.toLowerCase().startsWith('the ')) {
|
|
|
|
|
+ // For titles starting with "The", use the next word
|
|
|
|
|
+ const words = cleanFileName.split(' ');
|
|
|
|
|
+ if (words.length > 1) {
|
|
|
|
|
+ folderName = words[1].charAt(0).toUpperCase();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ output = path.join(destination, folderName, cleanFileName + ext);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ output = path.join(destination, cleanFileName + ext);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Ensure destination directory exists
|
|
|
|
|
+ const outputDir = path.dirname(output);
|
|
|
|
|
+ if (!fs.existsSync(outputDir)) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ fs.mkdirSync(outputDir, { recursive: true });
|
|
|
|
|
+ this.logger.log(`Created output directory: ${outputDir}`);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ this.logger.error(
|
|
|
|
|
+ `Failed to create output directory ${outputDir}: ${error.message}`,
|
|
|
|
|
+ );
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // Default behavior: same directory with new extension
|
|
|
|
|
+ output = path.join(
|
|
|
|
|
+ path.dirname(file),
|
|
|
|
|
+ path.basename(file, path.extname(file)) + ext,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Always create/update file record for discovered files
|
|
|
|
|
+ const existingFileRecord = this.db.findFile(dataset, file) as
|
|
|
|
|
+ | FileRecord
|
|
|
|
|
+ | undefined;
|
|
|
|
|
+ if (!existingFileRecord) {
|
|
|
|
|
+ // Create file record for newly discovered file
|
|
|
|
|
+ this.db.setFile(dataset, file, {
|
|
|
|
|
+ date: new Date().toISOString(),
|
|
|
|
|
+ output: output,
|
|
|
|
|
+ });
|
|
|
|
|
+ this.logger.log(`Discovered new file: ${file}`);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // Update existing file record with current output path (in case config changed)
|
|
|
|
|
+ this.db.setFile(dataset, file, {
|
|
|
|
|
+ output: output,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Automatic task creation: only when output doesn't exist
|
|
|
|
|
+ const outputExists = fs.existsSync(output);
|
|
|
|
|
|
|
|
- // Create output path (same directory, .mkv extension)
|
|
|
|
|
- const output = path.join(
|
|
|
|
|
- path.dirname(file),
|
|
|
|
|
- path.basename(file, path.extname(file)) + '.mkv',
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ if (outputExists) {
|
|
|
|
|
+ this.logger.log(
|
|
|
|
|
+ `Output file already exists, skipping automatic task creation: ${output}`,
|
|
|
|
|
+ );
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Check if task already exists for this input file
|
|
|
|
|
+ const existingTask = this.taskQueue.getTaskByInput(file);
|
|
|
|
|
+ if (existingTask) {
|
|
|
|
|
+ // If task exists and is currently processing, reset to pending for retry
|
|
|
|
|
+ if (existingTask.status === 'processing') {
|
|
|
|
|
+ this.logger.log(
|
|
|
|
|
+ `Resetting stuck processing task ${existingTask.id} to pending for file: ${file}`,
|
|
|
|
|
+ );
|
|
|
|
|
+ this.taskQueue.updateTaskStatus(existingTask.id, 'pending');
|
|
|
|
|
+ this.eventsGateway.emitTaskUpdate({
|
|
|
|
|
+ type: 'reset',
|
|
|
|
|
+ taskId: existingTask.id,
|
|
|
|
|
+ file,
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.logger.log(
|
|
|
|
|
+ `Task already exists for file: ${file} (status: ${existingTask.status})`,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
// Create task for processing
|
|
// Create task for processing
|
|
|
try {
|
|
try {
|
|
@@ -104,6 +326,12 @@ export class WatcherService {
|
|
|
preset,
|
|
preset,
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ // Update file record to indicate processing has started
|
|
|
|
|
+ this.db.setFile(dataset, file, {
|
|
|
|
|
+ status: 'pending',
|
|
|
|
|
+ date: new Date().toISOString(),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
this.logger.log(`Created task ${task.id} for file: ${file}`);
|
|
this.logger.log(`Created task ${task.id} for file: ${file}`);
|
|
|
|
|
|
|
|
// Emit file update event
|
|
// Emit file update event
|
|
@@ -121,12 +349,19 @@ export class WatcherService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private getDatasetFromPath(file: string): string | null {
|
|
private getDatasetFromPath(file: string): string | null {
|
|
|
- const enabledPaths = this.datasetsService.getEnabledDatasetPaths();
|
|
|
|
|
|
|
+ const datasetConfig = this.datasetsService.getDatasetConfig();
|
|
|
|
|
|
|
|
- for (const datasetPath of enabledPaths) {
|
|
|
|
|
- if (file.startsWith(datasetPath)) {
|
|
|
|
|
- // Extract dataset name from path (last directory name)
|
|
|
|
|
- return path.basename(datasetPath);
|
|
|
|
|
|
|
+ // Iterate through each dataset and its paths
|
|
|
|
|
+ for (const datasetName of Object.keys(datasetConfig)) {
|
|
|
|
|
+ const datasetObj = datasetConfig[datasetName];
|
|
|
|
|
+ if (typeof datasetObj === 'object' && datasetObj !== null) {
|
|
|
|
|
+ // Check each path in the dataset configuration
|
|
|
|
|
+ for (const pathKey of Object.keys(datasetObj)) {
|
|
|
|
|
+ if (pathKey !== 'enabled' && file.startsWith(pathKey)) {
|
|
|
|
|
+ // Return the actual dataset name (e.g., "tvshows", "pr0n")
|
|
|
|
|
+ return datasetName;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -148,9 +383,65 @@ export class WatcherService {
|
|
|
return videoExtensions.includes(ext);
|
|
return videoExtensions.includes(ext);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- stop() {
|
|
|
|
|
|
|
+ private isValidVideoFile(file: string): boolean {
|
|
|
|
|
+ try {
|
|
|
|
|
+ // Check if file exists and is readable
|
|
|
|
|
+ if (!fs.existsSync(file)) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const stats = fs.statSync(file);
|
|
|
|
|
+ if (stats.size === 0) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Read first few bytes to check for video file signatures
|
|
|
|
|
+ const buffer = Buffer.alloc(12);
|
|
|
|
|
+ const fd = fs.openSync(file, 'r');
|
|
|
|
|
+ try {
|
|
|
|
|
+ fs.readSync(fd, buffer, 0, 12, 0);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ fs.closeSync(fd);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Check for common video file signatures
|
|
|
|
|
+ const signature = buffer.toString('hex');
|
|
|
|
|
+
|
|
|
|
|
+ // MP4 signature (ftyp box)
|
|
|
|
|
+ if (signature.includes('66747970')) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // MKV/WebM signature (EBML)
|
|
|
|
|
+ if (signature.startsWith('1a45dfa3')) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // AVI signature (RIFF)
|
|
|
|
|
+ if (
|
|
|
|
|
+ signature.startsWith('52494646') &&
|
|
|
|
|
+ buffer.toString('ascii', 8, 12) === 'AVI '
|
|
|
|
|
+ ) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // MOV signature (ftyp)
|
|
|
|
|
+ if (signature.includes('66747971') || signature.includes('66747970')) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // For other formats, just check if file is large enough to be a video (> 1MB)
|
|
|
|
|
+ // This is a basic heuristic since not all video formats have easily detectable headers
|
|
|
|
|
+ return stats.size > 1024 * 1024;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ this.logger.warn(`Error validating video file ${file}: ${error.message}`);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async stop() {
|
|
|
if (this.watcher && this.isWatching) {
|
|
if (this.watcher && this.isWatching) {
|
|
|
- this.watcher.close();
|
|
|
|
|
|
|
+ await this.watcher.close();
|
|
|
this.isWatching = false;
|
|
this.isWatching = false;
|
|
|
this.logger.log('Watcher stopped.');
|
|
this.logger.log('Watcher stopped.');
|
|
|
this.eventsGateway.emitWatcherUpdate({ type: 'stopped' });
|
|
this.eventsGateway.emitWatcherUpdate({ type: 'stopped' });
|