| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461 |
- import { Inject, Injectable, Logger } from '@nestjs/common';
- import chokidar, { FSWatcher } from 'chokidar';
- import fs from 'fs';
- import path from 'path';
- import { DatasetsService } from './datasets.service';
- import { DbService } from './db.service';
- import { EventsGateway } from './events.gateway';
- import { TaskQueueService } from './task-queue.service';
- interface FileRecord {
- dataset: string;
- input: string;
- output: string;
- date: string;
- }
- @Injectable()
- export class WatcherService {
- private watcher: FSWatcher | null = null;
- private isWatching = false;
- private lastWatches: string[] = [];
- private lastOptions: any = {};
- private logger = new Logger('WatcherService');
- constructor(
- @Inject(DatasetsService) private readonly datasetsService: DatasetsService,
- @Inject(DbService) private readonly db: DbService,
- @Inject(EventsGateway) private readonly eventsGateway: EventsGateway,
- @Inject(TaskQueueService) private readonly taskQueue: TaskQueueService,
- ) {}
- start(watches?: string[], options: any = {}) {
- if (this.isWatching) {
- this.logger.warn('Watcher already running.');
- return { started: false, message: 'Watcher already running.' };
- }
- // If no watches provided, use all enabled dataset paths
- const enabledWatches =
- watches && watches.length > 0
- ? watches
- : this.datasetsService.getEnabledDatasetPaths();
- // 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.lastWatches = enabledWatches;
- this.lastOptions = conservativeOptions;
- this.watcher
- .on('add', (file: string) => {
- this.logger.log(`File added: ${file}`);
- this.handleFileAdded(file);
- })
- .on('change', (file: string) => {
- this.logger.log(`File changed: ${file}`);
- this.eventsGateway.emitFileUpdate({ type: 'change', file });
- })
- .on('unlink', (file: string) => {
- this.logger.log(`File removed: ${file}`);
- this.eventsGateway.emitFileUpdate({ type: 'unlink', file });
- })
- .on('error', (error: Error) => {
- this.logger.error(`Watcher error: ${error}`);
- this.eventsGateway.emitWatcherUpdate({
- type: 'error',
- error: error.message,
- });
- });
- this.logger.log('Watcher started.');
- this.eventsGateway.emitWatcherUpdate({
- type: 'started',
- watches: enabledWatches,
- });
- return { started: true };
- }
- private handleFileAdded(file: string) {
- // Determine dataset from file path
- const dataset = this.getDatasetFromPath(file);
- if (!dataset) {
- this.logger.warn(`Could not determine dataset for file: ${file}`);
- return;
- }
- // Check if this is a video file (basic extension check)
- if (!this.isVideoFile(file)) {
- this.logger.log(`Skipping non-video file: ${file}`);
- 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
- const datasetConfig = this.datasetsService.getDatasetConfig();
- const datasetSettings = datasetConfig[dataset];
- if (!datasetSettings || !datasetSettings.enabled) {
- this.logger.log(
- `Dataset ${dataset} is not enabled, skipping file: ${file}`,
- );
- return;
- }
- // 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);
- 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
- try {
- const task = this.taskQueue.createTask({
- dataset,
- input: file,
- output,
- 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}`);
- // Emit file update event
- this.eventsGateway.emitFileUpdate({
- type: 'add',
- file,
- dataset,
- taskId: task.id,
- });
- } catch (error) {
- this.logger.error(
- `Failed to create task for file ${file}: ${error.message}`,
- );
- }
- }
- private getDatasetFromPath(file: string): string | null {
- const datasetConfig = this.datasetsService.getDatasetConfig();
- // 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;
- }
- }
- }
- }
- return null;
- }
- private isVideoFile(file: string): boolean {
- const videoExtensions = [
- '.mp4',
- '.mkv',
- '.avi',
- '.mov',
- '.wmv',
- '.flv',
- '.webm',
- '.m4v',
- ];
- const ext = path.extname(file).toLowerCase();
- return videoExtensions.includes(ext);
- }
- 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) {
- await this.watcher.close();
- this.isWatching = false;
- this.logger.log('Watcher stopped.');
- this.eventsGateway.emitWatcherUpdate({ type: 'stopped' });
- return { stopped: true };
- }
- this.logger.warn('Watcher is not running.');
- return { stopped: false, message: 'Watcher is not running.' };
- }
- status() {
- return {
- isWatching: this.isWatching,
- watches: this.lastWatches,
- options: this.lastOptions,
- };
- }
- }
|