initial commit
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* MetaDataToFolderStruct - Generates virtual filesystem structure from metadata
|
||||
*
|
||||
* Migrated from meta-mesh to meta-fuse.
|
||||
* Takes a collection of file metadata and generates organized virtual paths.
|
||||
*/
|
||||
|
||||
import { renamingRule, sanitizePath } from './RenamingRule.js';
|
||||
import { Logger } from 'tslog';
|
||||
|
||||
const logger = new Logger({ name: 'MetaDataToFolderStruct' });
|
||||
|
||||
type RenamingRuleFunction = (metadata: any, filepath: string) => string | null;
|
||||
|
||||
export class MetaDataToFolderStruct {
|
||||
public renamingRule: RenamingRuleFunction;
|
||||
|
||||
constructor(customRenamingRule?: RenamingRuleFunction) {
|
||||
this.renamingRule = customRenamingRule ?? renamingRule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute virtual filesystem structure for FUSE/WebDAV
|
||||
* Generates organized paths based on metadata without creating physical files
|
||||
*
|
||||
* @param data Map of source paths to metadata
|
||||
* @returns Map of source paths to virtual paths
|
||||
*/
|
||||
private computeVirtualStructure(data: Map<string, any>): Map<string, string> {
|
||||
const virtualMap = new Map<string, string>();
|
||||
|
||||
for (const [filepath, metadata] of data) {
|
||||
// Compute the virtual path for the file
|
||||
let virtualPath: string | null = null;
|
||||
|
||||
try {
|
||||
virtualPath = this.renamingRule(metadata, filepath);
|
||||
} catch (e: any) {
|
||||
logger.warn(`Failed to compute virtual path for ${filepath}: ${e.message || e}`);
|
||||
}
|
||||
|
||||
if (!virtualPath) {
|
||||
// Fallback: use filename directly if renaming rule fails
|
||||
const filename = filepath.split('/').pop() || filepath.split('\\').pop() || filepath;
|
||||
virtualPath = `Unsorted/${filename}`;
|
||||
}
|
||||
|
||||
virtualPath = sanitizePath(virtualPath);
|
||||
virtualMap.set(filepath, virtualPath);
|
||||
}
|
||||
|
||||
logger.debug(`Computed virtual structure for ${virtualMap.size} files`);
|
||||
return virtualMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate virtual filesystem structure for FUSE/WebDAV
|
||||
* Returns only the computed virtual paths without checking physical filesystem
|
||||
*
|
||||
* @param data Map of source paths to metadata
|
||||
* @returns Map of source paths to virtual paths
|
||||
*/
|
||||
public generateVirtualStructure(data: Map<string, any>): Map<string, string> {
|
||||
const start = performance.now();
|
||||
const computed = this.computeVirtualStructure(data);
|
||||
logger.debug(`Compute virtual structure took ${Math.ceil(performance.now() - start)}ms`);
|
||||
return computed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate virtual path for a single file
|
||||
*
|
||||
* @param sourcePath The source file path
|
||||
* @param metadata The file metadata
|
||||
* @returns Virtual path or fallback path
|
||||
*/
|
||||
public generateSingleVirtualPath(sourcePath: string, metadata: any): string {
|
||||
let virtualPath: string | null = null;
|
||||
|
||||
try {
|
||||
virtualPath = this.renamingRule(metadata, sourcePath);
|
||||
} catch (e: any) {
|
||||
logger.warn(`Failed to compute virtual path for ${sourcePath}: ${e.message || e}`);
|
||||
}
|
||||
|
||||
if (!virtualPath) {
|
||||
// Fallback: use filename directly if renaming rule fails
|
||||
const filename = sourcePath.split('/').pop() || sourcePath.split('\\').pop() || sourcePath;
|
||||
virtualPath = `Unsorted/${filename}`;
|
||||
}
|
||||
|
||||
return sanitizePath(virtualPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Renaming Rule - Defines folder organization logic
|
||||
*
|
||||
* Migrated from meta-mesh to meta-fuse for virtual path computation.
|
||||
* Organizes files into structured folders based on metadata:
|
||||
* - TV Shows/{title}/S01/{filename} for series
|
||||
* - Movies/{title} (year)/{filename} for movies
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimal metadata interface for folder organization
|
||||
* Contains only the fields needed for virtual path computation
|
||||
*/
|
||||
export interface MediaMeta {
|
||||
// Title information
|
||||
titles?: { eng?: string; [key: string]: string | undefined };
|
||||
originalTitle?: string;
|
||||
title?: string;
|
||||
|
||||
// Series metadata
|
||||
season?: number;
|
||||
episode?: number;
|
||||
extra?: boolean;
|
||||
|
||||
// Movie metadata
|
||||
movieYear?: number;
|
||||
year?: number;
|
||||
|
||||
// File type info
|
||||
fileType?: string; // 'video', 'subtitle', 'torrent'
|
||||
extension?: string;
|
||||
|
||||
// Version/variant
|
||||
version?: string;
|
||||
subtitleLanguage?: string;
|
||||
|
||||
// Allow additional fields
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the virtual path for a file based on its metadata
|
||||
* Returns the path relative to the VFS root
|
||||
*
|
||||
* @param metadata File metadata with title, season, episode, year, etc.
|
||||
* @param filepath Original file path (used for warnings)
|
||||
* @returns Virtual path (e.g., "TV Shows/Show Name/S01/Episode.mkv") or null if unsupported
|
||||
*/
|
||||
export function renamingRule(metadata: Partial<MediaMeta>, filepath?: string): string | null {
|
||||
const supported = ['video', 'subtitle', 'torrent'];
|
||||
|
||||
// Check if filetype is one of the supported ones
|
||||
if (!metadata.fileType || !supported.includes(metadata.fileType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract the file extension
|
||||
const extension = metadata.extension;
|
||||
if (!extension) {
|
||||
console.warn(`[RenamingRule] No extension found for file: ${filepath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get title - try various fields
|
||||
const title = metadata.titles?.eng || metadata.originalTitle;
|
||||
if (!title) {
|
||||
if (metadata.fileType !== 'torrent') {
|
||||
// Don't warn for torrent files because they don't always have a title
|
||||
console.warn(`[RenamingRule] No title found for file: ${filepath}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let newPath = '';
|
||||
|
||||
// Build filename components
|
||||
const season = metadata.season ? ('S' + String(metadata.season).padStart(2, '0')) : '';
|
||||
const seasonSpace = metadata.season ? ` ${season}` : '';
|
||||
const episode = metadata.episode ? ('E' + String(metadata.episode).padStart(2, '0')) : '';
|
||||
const version = metadata.version ? ` ${metadata.version}` : '';
|
||||
const year = metadata.movieYear ? ` (${metadata.movieYear})` : '';
|
||||
const subtitle = metadata.fileType === 'subtitle' && metadata.subtitleLanguage
|
||||
? `.${metadata.subtitleLanguage}`
|
||||
: '';
|
||||
|
||||
const fileName = `${title}${seasonSpace}${episode}${year}${subtitle}${version}.${extension}`;
|
||||
|
||||
// Determine structure: TV Shows for series, Movies for standalone content
|
||||
// Note: season can be 0 (special episodes), so check for null/undefined explicitly
|
||||
const hasSeason = metadata.season !== null && metadata.season !== undefined;
|
||||
const hasEpisode = metadata.episode !== null && metadata.episode !== undefined;
|
||||
|
||||
if (metadata.extra) {
|
||||
newPath = `TV Shows/${title}/extra/${fileName}`;
|
||||
} else if (hasSeason && hasEpisode) {
|
||||
newPath = `TV Shows/${title}/${season}/${fileName}`;
|
||||
} else {
|
||||
newPath = `Movies/${title}${year}/${fileName}`;
|
||||
}
|
||||
|
||||
return newPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a path to remove illegal characters
|
||||
* Works for both Windows and Unix-like systems
|
||||
*/
|
||||
export function sanitizePath(newPath: string): string {
|
||||
// Isolate any potential Windows drive letter at the start
|
||||
const driveMatch = newPath.match(/^[a-zA-Z]:/);
|
||||
const drive = driveMatch ? driveMatch[0] : '';
|
||||
|
||||
// Define illegal characters for Windows and potentially problematic ones for Unix
|
||||
const illegalChars = /[<>:"|?*]/g;
|
||||
|
||||
// Replace illegal characters in the path, excluding the drive part
|
||||
const pathWithoutDrive = newPath.slice(drive.length);
|
||||
const sanitizedPath = pathWithoutDrive.replace(illegalChars, '');
|
||||
|
||||
// Combine the drive (if any) and the sanitized path
|
||||
const cleanPath = drive + sanitizedPath;
|
||||
|
||||
// Normalize to forward slashes (works cross-platform)
|
||||
return cleanPath.replace(/\\/g, '/');
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
/**
|
||||
* VirtualFileSystem - In-memory representation of the virtual filesystem
|
||||
*
|
||||
* Reads metadata from Redis and computes organized virtual paths
|
||||
* using MetaDataToFolderStruct for folder structure organization.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { Logger } from 'tslog';
|
||||
import { RedisClient, FileMetadata } from '../kv/RedisClient.js';
|
||||
import { MetaDataToFolderStruct } from './MetaDataToFolderStruct.js';
|
||||
import { sanitizePath } from './RenamingRule.js';
|
||||
import { TemplateEngine } from './template/TemplateEngine.js';
|
||||
import { ConditionEvaluator } from './template/ConditionEvaluator.js';
|
||||
import { ConfigStorage } from '../config/ConfigStorage.js';
|
||||
import type { RenamingConfig, RenamingRule, PreviewItem, PreviewResponse } from './types/RenamingRuleTypes.js';
|
||||
|
||||
const logger = new Logger({ name: 'VirtualFileSystem' });
|
||||
|
||||
export interface FileAttributes {
|
||||
size: number;
|
||||
mode: number;
|
||||
mtime: number;
|
||||
atime: number;
|
||||
ctime: number;
|
||||
nlink: number;
|
||||
uid: number;
|
||||
gid: number;
|
||||
}
|
||||
|
||||
export interface ReadResult {
|
||||
sourcePath: string | null;
|
||||
content: Buffer | null;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface VFSNode {
|
||||
type: 'file' | 'directory';
|
||||
name: string;
|
||||
parent: string | null;
|
||||
sourcePath?: string;
|
||||
size?: number;
|
||||
mtime?: Date;
|
||||
ctime?: Date;
|
||||
metadata?: FileMetadata;
|
||||
children?: Set<string>;
|
||||
}
|
||||
|
||||
export interface VFSConfig {
|
||||
fileMode?: number;
|
||||
directoryMode?: number;
|
||||
uid?: number;
|
||||
gid?: number;
|
||||
refreshInterval?: number;
|
||||
configDir?: string; // Directory for config files (renaming rules, etc.)
|
||||
}
|
||||
|
||||
export class VirtualFileSystem {
|
||||
private nodes: Map<string, VFSNode> = new Map();
|
||||
private sourcePathToVirtualPath: Map<string, string> = new Map();
|
||||
private redisClient: RedisClient;
|
||||
private configStorage: ConfigStorage;
|
||||
private metaDataToFolderStruct: MetaDataToFolderStruct;
|
||||
private templateEngine: TemplateEngine;
|
||||
private conditionEvaluator: ConditionEvaluator;
|
||||
private rulesConfig: RenamingConfig | null = null;
|
||||
private config: Required<VFSConfig>;
|
||||
private refreshTimer: NodeJS.Timeout | null = null;
|
||||
private lastRefresh: Date | null = null;
|
||||
|
||||
private cachedStats = {
|
||||
fileCount: 0,
|
||||
directoryCount: 0,
|
||||
totalSize: 0,
|
||||
};
|
||||
|
||||
constructor(redisClient: RedisClient, config: VFSConfig = {}) {
|
||||
this.redisClient = redisClient;
|
||||
this.metaDataToFolderStruct = new MetaDataToFolderStruct();
|
||||
this.templateEngine = new TemplateEngine();
|
||||
this.conditionEvaluator = new ConditionEvaluator();
|
||||
|
||||
// Default config directory
|
||||
const configDir = config.configDir ?? '/meta-fuse/config';
|
||||
this.configStorage = new ConfigStorage({ configDir });
|
||||
|
||||
this.config = {
|
||||
fileMode: config.fileMode ?? 0o644,
|
||||
directoryMode: config.directoryMode ?? 0o755,
|
||||
uid: config.uid ?? 1000,
|
||||
gid: config.gid ?? 1000,
|
||||
refreshInterval: config.refreshInterval ?? 30000, // 30 seconds
|
||||
configDir,
|
||||
};
|
||||
|
||||
// Initialize root directory
|
||||
this.initRoot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize root directory
|
||||
*/
|
||||
private initRoot(): void {
|
||||
this.nodes.clear();
|
||||
this.sourcePathToVirtualPath.clear();
|
||||
this.nodes.set('/', {
|
||||
type: 'directory',
|
||||
name: '',
|
||||
parent: null,
|
||||
children: new Set(),
|
||||
});
|
||||
this.cachedStats = { fileCount: 0, directoryCount: 1, totalSize: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the VFS (connect to Redis and load data)
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
logger.info('Starting VirtualFileSystem');
|
||||
|
||||
// Initial load
|
||||
await this.refresh();
|
||||
|
||||
// Start periodic refresh
|
||||
this.refreshTimer = setInterval(async () => {
|
||||
try {
|
||||
await this.refresh();
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh VFS:', error);
|
||||
}
|
||||
}, this.config.refreshInterval);
|
||||
|
||||
logger.info('VirtualFileSystem started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the VFS
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
logger.info('VirtualFileSystem stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the VFS from Redis
|
||||
* Uses template-based rules if configured, otherwise falls back to MetaDataToFolderStruct
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
logger.debug('Refreshing VFS from Redis');
|
||||
|
||||
try {
|
||||
// Load rules configuration
|
||||
this.loadRulesConfig();
|
||||
|
||||
// Get all files from Redis (keyed by sourcePath)
|
||||
const files = await this.redisClient.getAllFiles();
|
||||
|
||||
if (files.size === 0) {
|
||||
logger.info('No files found in Redis');
|
||||
this.initRoot();
|
||||
return;
|
||||
}
|
||||
|
||||
// Rebuild VFS with computed paths
|
||||
this.initRoot();
|
||||
|
||||
for (const [sourcePath, metadata] of files) {
|
||||
// Compute virtual path using rules or fallback
|
||||
const virtualPath = this.computeVirtualPath(metadata, sourcePath);
|
||||
if (virtualPath) {
|
||||
this.addFile(virtualPath, metadata);
|
||||
this.sourcePathToVirtualPath.set(sourcePath, virtualPath);
|
||||
}
|
||||
}
|
||||
|
||||
this.lastRefresh = new Date();
|
||||
logger.info(`VFS refreshed: ${this.cachedStats.fileCount} files, ${this.cachedStats.directoryCount} directories`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh VFS:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load rules configuration from file
|
||||
* ConfigStorage creates the default config file if it doesn't exist
|
||||
*/
|
||||
private loadRulesConfig(): void {
|
||||
this.rulesConfig = this.configStorage.getRulesConfig();
|
||||
const isDefault = this.rulesConfig.isDefault ? ' (default)' : '';
|
||||
logger.debug(`Loaded ${this.rulesConfig.rules.length} renaming rules${isDefault}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute virtual path for a file using template rules
|
||||
*/
|
||||
private computeVirtualPath(metadata: FileMetadata, sourcePath: string): string {
|
||||
if (!this.rulesConfig) {
|
||||
// Fallback to old behavior
|
||||
return this.metaDataToFolderStruct.generateSingleVirtualPath(sourcePath, metadata);
|
||||
}
|
||||
|
||||
// Sort rules by priority (descending)
|
||||
const sortedRules = [...this.rulesConfig.rules]
|
||||
.filter(r => r.enabled)
|
||||
.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
// Find first matching rule
|
||||
for (const rule of sortedRules) {
|
||||
if (this.conditionEvaluator.evaluateGroup(rule.conditions, metadata as Record<string, unknown>)) {
|
||||
const result = this.templateEngine.interpolate(rule.template, metadata as Record<string, unknown>);
|
||||
if (result) {
|
||||
return sanitizePath(result);
|
||||
}
|
||||
if (rule.fallbackToUnsorted) {
|
||||
const filename = metadata.fileName || sourcePath.split('/').pop() || 'unknown';
|
||||
return `Unsorted/${filename}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply default rule if no rules matched
|
||||
if (this.rulesConfig.defaultRule) {
|
||||
const result = this.templateEngine.interpolate(
|
||||
this.rulesConfig.defaultRule.template,
|
||||
metadata as Record<string, unknown>
|
||||
);
|
||||
if (result) {
|
||||
return sanitizePath(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Ultimate fallback
|
||||
const filename = metadata.fileName || sourcePath.split('/').pop() || 'unknown';
|
||||
return `Unsorted/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incremental file update from pub/sub
|
||||
* Adds or updates a single file without full rebuild
|
||||
*/
|
||||
async onFileUpdate(hashId: string, action: 'add' | 'update' | 'remove'): Promise<void> {
|
||||
if (action === 'remove') {
|
||||
// Find and remove file by hashId
|
||||
for (const [vPath, node] of this.nodes) {
|
||||
if (node.type === 'file' && node.metadata?.hashId === hashId) {
|
||||
this.removeFile(vPath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Add or update: fetch metadata and compute virtual path
|
||||
const metadata = await this.redisClient.getFileByHashId(hashId);
|
||||
if (!metadata) {
|
||||
logger.warn(`File not found in Redis: ${hashId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute virtual path for this single file
|
||||
const virtualPath = this.metaDataToFolderStruct.generateSingleVirtualPath(
|
||||
metadata.sourcePath,
|
||||
metadata
|
||||
);
|
||||
|
||||
// If updating, remove old entry first
|
||||
if (action === 'update') {
|
||||
const oldVirtualPath = this.sourcePathToVirtualPath.get(metadata.sourcePath);
|
||||
if (oldVirtualPath && oldVirtualPath !== virtualPath) {
|
||||
this.removeFile(oldVirtualPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Add/update file
|
||||
this.addFile(virtualPath, metadata);
|
||||
this.sourcePathToVirtualPath.set(metadata.sourcePath, virtualPath);
|
||||
|
||||
logger.debug(`File ${action}ed: ${virtualPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a file to the VFS
|
||||
*/
|
||||
private addFile(virtualPath: string, metadata: FileMetadata): void {
|
||||
virtualPath = this.normalizePath(virtualPath);
|
||||
|
||||
// Skip if file already exists at this path
|
||||
if (this.nodes.has(virtualPath) && this.nodes.get(virtualPath)!.type === 'file') {
|
||||
// Update existing file
|
||||
const node = this.nodes.get(virtualPath)!;
|
||||
const oldSize = node.size ?? 0;
|
||||
this.cachedStats.totalSize -= oldSize;
|
||||
this.cachedStats.fileCount--;
|
||||
}
|
||||
|
||||
// Create parent directories
|
||||
const dirname = path.dirname(virtualPath);
|
||||
this.ensureDirectory(dirname);
|
||||
|
||||
// Get file stats
|
||||
let size = metadata.size;
|
||||
let mtime = new Date(metadata.mtime || Date.now());
|
||||
let ctime = new Date(metadata.ctime || metadata.mtime || Date.now());
|
||||
|
||||
// Fallback: stat the source file if metadata is incomplete
|
||||
if (!size && metadata.sourcePath) {
|
||||
try {
|
||||
const stats = fs.statSync(metadata.sourcePath);
|
||||
size = stats.size;
|
||||
mtime = stats.mtime;
|
||||
ctime = stats.ctime;
|
||||
} catch {
|
||||
logger.warn(`Cannot stat source file: ${metadata.sourcePath}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Add file node
|
||||
const filename = path.basename(virtualPath);
|
||||
this.nodes.set(virtualPath, {
|
||||
type: 'file',
|
||||
name: filename,
|
||||
parent: dirname,
|
||||
sourcePath: metadata.sourcePath,
|
||||
size,
|
||||
mtime,
|
||||
ctime,
|
||||
metadata,
|
||||
});
|
||||
|
||||
// Add to parent's children
|
||||
const parentNode = this.nodes.get(dirname);
|
||||
if (parentNode && parentNode.type === 'directory') {
|
||||
parentNode.children!.add(filename);
|
||||
}
|
||||
|
||||
// Update stats
|
||||
this.cachedStats.fileCount++;
|
||||
this.cachedStats.totalSize += size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file from the VFS
|
||||
*/
|
||||
private removeFile(virtualPath: string): void {
|
||||
virtualPath = this.normalizePath(virtualPath);
|
||||
const node = this.nodes.get(virtualPath);
|
||||
|
||||
if (!node || node.type !== 'file') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove from parent's children
|
||||
if (node.parent) {
|
||||
const parentNode = this.nodes.get(node.parent);
|
||||
if (parentNode && parentNode.type === 'directory') {
|
||||
parentNode.children!.delete(node.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Update stats
|
||||
this.cachedStats.fileCount--;
|
||||
this.cachedStats.totalSize -= node.size ?? 0;
|
||||
|
||||
// Remove node
|
||||
this.nodes.delete(virtualPath);
|
||||
|
||||
// Remove from source path mapping
|
||||
if (node.sourcePath) {
|
||||
this.sourcePathToVirtualPath.delete(node.sourcePath);
|
||||
}
|
||||
|
||||
// Clean up empty parent directories
|
||||
this.cleanEmptyDirectories(node.parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up empty directories
|
||||
*/
|
||||
private cleanEmptyDirectories(dirPath: string | null): void {
|
||||
if (!dirPath || dirPath === '/') {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = this.nodes.get(dirPath);
|
||||
if (!node || node.type !== 'directory') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only remove if empty
|
||||
if (node.children!.size === 0) {
|
||||
// Remove from parent
|
||||
if (node.parent) {
|
||||
const parentNode = this.nodes.get(node.parent);
|
||||
if (parentNode && parentNode.type === 'directory') {
|
||||
parentNode.children!.delete(node.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove this directory
|
||||
this.nodes.delete(dirPath);
|
||||
this.cachedStats.directoryCount--;
|
||||
|
||||
// Recursively clean parent
|
||||
this.cleanEmptyDirectories(node.parent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a directory exists in the VFS
|
||||
*/
|
||||
private ensureDirectory(dirPath: string): void {
|
||||
dirPath = this.normalizePath(dirPath);
|
||||
|
||||
if (dirPath === '/' || this.nodes.has(dirPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recursively create parent
|
||||
const parentPath = path.dirname(dirPath);
|
||||
this.ensureDirectory(parentPath);
|
||||
|
||||
// Create this directory
|
||||
const dirName = path.basename(dirPath);
|
||||
this.nodes.set(dirPath, {
|
||||
type: 'directory',
|
||||
name: dirName,
|
||||
parent: parentPath,
|
||||
children: new Set(),
|
||||
});
|
||||
|
||||
// Add to parent's children
|
||||
const parentNode = this.nodes.get(parentPath);
|
||||
if (parentNode && parentNode.type === 'directory') {
|
||||
parentNode.children!.add(dirName);
|
||||
}
|
||||
|
||||
this.cachedStats.directoryCount++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path
|
||||
*/
|
||||
private normalizePath(p: string): string {
|
||||
p = p.split(path.sep).join('/');
|
||||
if (!p.startsWith('/')) {
|
||||
p = '/' + p;
|
||||
}
|
||||
if (p !== '/' && p.endsWith('/')) {
|
||||
p = p.slice(0, -1);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// FUSE API Methods
|
||||
|
||||
/**
|
||||
* List directory contents
|
||||
*/
|
||||
readdir(dirPath: string): string[] | null {
|
||||
dirPath = this.normalizePath(dirPath);
|
||||
const node = this.nodes.get(dirPath);
|
||||
|
||||
if (!node || node.type !== 'directory') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Array.from(node.children!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file attributes
|
||||
*/
|
||||
getattr(filepath: string): FileAttributes | null {
|
||||
filepath = this.normalizePath(filepath);
|
||||
const node = this.nodes.get(filepath);
|
||||
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.type === 'directory') {
|
||||
return {
|
||||
size: 4096,
|
||||
mode: 0o040000 | this.config.directoryMode,
|
||||
mtime: Date.now() / 1000,
|
||||
atime: Date.now() / 1000,
|
||||
ctime: Date.now() / 1000,
|
||||
nlink: 2,
|
||||
uid: this.config.uid,
|
||||
gid: this.config.gid,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
size: node.size!,
|
||||
mode: 0o100000 | this.config.fileMode,
|
||||
mtime: node.mtime!.getTime() / 1000,
|
||||
atime: node.mtime!.getTime() / 1000,
|
||||
ctime: node.ctime!.getTime() / 1000,
|
||||
nlink: 1,
|
||||
uid: this.config.uid,
|
||||
gid: this.config.gid,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path exists
|
||||
*/
|
||||
exists(filepath: string): boolean {
|
||||
filepath = this.normalizePath(filepath);
|
||||
return this.nodes.has(filepath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file (returns source path for actual reading)
|
||||
*/
|
||||
read(filepath: string): ReadResult | null {
|
||||
filepath = this.normalizePath(filepath);
|
||||
const node = this.nodes.get(filepath);
|
||||
|
||||
if (!node || node.type !== 'file') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sourcePath: node.sourcePath!,
|
||||
content: null,
|
||||
size: node.size!,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file metadata
|
||||
*/
|
||||
getMetadata(filepath: string): FileMetadata | null {
|
||||
filepath = this.normalizePath(filepath);
|
||||
const node = this.nodes.get(filepath);
|
||||
|
||||
if (!node || node.type !== 'file') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return node.metadata ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all file paths
|
||||
*/
|
||||
getAllFiles(): string[] {
|
||||
const files: string[] = [];
|
||||
for (const [filepath, node] of this.nodes.entries()) {
|
||||
if (node.type === 'file') {
|
||||
files.push(filepath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all directory paths
|
||||
*/
|
||||
getAllDirectories(): string[] {
|
||||
const dirs: string[] = [];
|
||||
for (const [dirpath, node] of this.nodes.entries()) {
|
||||
if (node.type === 'directory') {
|
||||
dirs.push(dirpath);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VFS statistics
|
||||
*/
|
||||
getStats(): {
|
||||
fileCount: number;
|
||||
directoryCount: number;
|
||||
totalSize: number;
|
||||
lastRefresh: string | null;
|
||||
redisConnected: boolean;
|
||||
} {
|
||||
return {
|
||||
...this.cachedStats,
|
||||
lastRefresh: this.lastRefresh?.toISOString() ?? null,
|
||||
redisConnected: this.redisClient.connected,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get virtual path for a source path
|
||||
*/
|
||||
getVirtualPath(sourcePath: string): string | null {
|
||||
return this.sourcePathToVirtualPath.get(sourcePath) ?? null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Rules Configuration API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get current rules configuration
|
||||
*/
|
||||
getRulesConfig(): RenamingConfig {
|
||||
if (!this.rulesConfig) {
|
||||
this.loadRulesConfig();
|
||||
}
|
||||
return this.rulesConfig!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save rules configuration to file
|
||||
*/
|
||||
saveRulesConfig(config: RenamingConfig): void {
|
||||
this.configStorage.saveRulesConfig(config);
|
||||
this.rulesConfig = config;
|
||||
logger.info(`Saved rules configuration with ${config.rules.length} rules`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview how files would be renamed with given rules
|
||||
*/
|
||||
async previewRules(testRules?: RenamingRule[], limit: number = 100): Promise<PreviewResponse> {
|
||||
const files = await this.redisClient.getAllFiles();
|
||||
const previews: PreviewItem[] = [];
|
||||
|
||||
// Use test rules or current config
|
||||
const rulesToTest = testRules || this.rulesConfig?.rules || [];
|
||||
const defaultRule = this.rulesConfig?.defaultRule;
|
||||
|
||||
let count = 0;
|
||||
for (const [sourcePath, metadata] of files) {
|
||||
if (count >= limit) break;
|
||||
|
||||
const currentVirtualPath = this.sourcePathToVirtualPath.get(sourcePath) || 'Unknown';
|
||||
|
||||
try {
|
||||
// Compute new path with test rules
|
||||
const newVirtualPath = this.computeVirtualPathWithRules(
|
||||
metadata,
|
||||
sourcePath,
|
||||
rulesToTest,
|
||||
defaultRule
|
||||
);
|
||||
const matchedRule = this.findMatchingRule(metadata, rulesToTest);
|
||||
|
||||
previews.push({
|
||||
sourcePath,
|
||||
currentVirtualPath,
|
||||
newVirtualPath,
|
||||
matchedRule: matchedRule?.id || null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
previews.push({
|
||||
sourcePath,
|
||||
currentVirtualPath,
|
||||
newVirtualPath: null,
|
||||
matchedRule: null,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
return {
|
||||
previews,
|
||||
total: files.size,
|
||||
limited: files.size > limit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute virtual path with specific rules (for preview)
|
||||
*/
|
||||
private computeVirtualPathWithRules(
|
||||
metadata: FileMetadata,
|
||||
sourcePath: string,
|
||||
rules: RenamingRule[],
|
||||
defaultRule?: RenamingRule
|
||||
): string {
|
||||
// Sort rules by priority (descending)
|
||||
const sortedRules = [...rules]
|
||||
.filter(r => r.enabled)
|
||||
.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
// Find first matching rule
|
||||
for (const rule of sortedRules) {
|
||||
if (this.conditionEvaluator.evaluateGroup(rule.conditions, metadata as Record<string, unknown>)) {
|
||||
const result = this.templateEngine.interpolate(rule.template, metadata as Record<string, unknown>);
|
||||
if (result) {
|
||||
return sanitizePath(result);
|
||||
}
|
||||
if (rule.fallbackToUnsorted) {
|
||||
const filename = metadata.fileName || sourcePath.split('/').pop() || 'unknown';
|
||||
return `Unsorted/${filename}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply default rule if no rules matched
|
||||
if (defaultRule) {
|
||||
const result = this.templateEngine.interpolate(
|
||||
defaultRule.template,
|
||||
metadata as Record<string, unknown>
|
||||
);
|
||||
if (result) {
|
||||
return sanitizePath(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Ultimate fallback
|
||||
const filename = metadata.fileName || sourcePath.split('/').pop() || 'unknown';
|
||||
return `Unsorted/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find which rule matches a file
|
||||
*/
|
||||
private findMatchingRule(metadata: FileMetadata, rules: RenamingRule[]): RenamingRule | null {
|
||||
const sortedRules = [...rules]
|
||||
.filter(r => r.enabled)
|
||||
.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
for (const rule of sortedRules) {
|
||||
if (this.conditionEvaluator.evaluateGroup(rule.conditions, metadata as Record<string, unknown>)) {
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Default Renaming Rules
|
||||
*
|
||||
* These rules match the current hardcoded behavior in RenamingRule.ts.
|
||||
* They serve as the initial configuration when no custom rules are set.
|
||||
*
|
||||
* Old behavior:
|
||||
* - Only processes video, subtitle, torrent files
|
||||
* - Requires extension
|
||||
* - Title from titles.eng || originalTitle
|
||||
* - Files without title/extension are hidden (not in Unsorted)
|
||||
*/
|
||||
|
||||
import type { RenamingConfig, RenamingRule } from '../types/RenamingRuleTypes.js';
|
||||
|
||||
/**
|
||||
* Supported file types condition (video, subtitle, torrent)
|
||||
* Used by all rules to filter out unsupported file types
|
||||
*/
|
||||
const supportedFileTypeCondition = {
|
||||
operator: 'OR' as const,
|
||||
conditions: [
|
||||
{ type: 'EQUALS' as const, field: 'fileType', value: 'video' },
|
||||
{ type: 'EQUALS' as const, field: 'fileType', value: 'subtitle' },
|
||||
{ type: 'EQUALS' as const, field: 'fileType', value: 'torrent' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* TV Series - Extras rule
|
||||
* Matches files marked as extra/special content
|
||||
*/
|
||||
const tvExtrasRule: RenamingRule = {
|
||||
id: 'tv-extras',
|
||||
name: 'TV Series - Extras',
|
||||
description: 'Special episodes, behind the scenes, extras',
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
conditions: {
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
supportedFileTypeCondition,
|
||||
{ type: 'EXISTS', field: 'extension' },
|
||||
{ type: 'EQUALS', field: 'extra', value: true },
|
||||
],
|
||||
},
|
||||
// Old format: TV Shows/{title}/extra/{title}{seasonSpace}{episode}{subtitle}{version}.{extension}
|
||||
template: 'TV Shows/{title|originalTitle}/extra/{title|originalTitle}{season?( S{season:pad2})}{episode?(E{episode:pad2})}{subtitleLanguage?(.{subtitleLanguage})}{version?( {version})}.{extension}',
|
||||
fallbackToUnsorted: true, // If template fails (no title), put in Unsorted
|
||||
};
|
||||
|
||||
/**
|
||||
* TV Series rule
|
||||
* Matches files with both season and episode numbers
|
||||
*/
|
||||
const tvSeriesRule: RenamingRule = {
|
||||
id: 'tv-series',
|
||||
name: 'TV Series',
|
||||
description: 'Regular TV show episodes with season and episode numbers',
|
||||
enabled: true,
|
||||
priority: 90,
|
||||
conditions: {
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
supportedFileTypeCondition,
|
||||
{ type: 'EXISTS', field: 'extension' },
|
||||
{ type: 'EXISTS', field: 'season' },
|
||||
{ type: 'EXISTS', field: 'episode' },
|
||||
],
|
||||
},
|
||||
// Old format: TV Shows/{title}/S{season}/{title} S{season}E{episode}{subtitle}{version}.{extension}
|
||||
template: 'TV Shows/{title|originalTitle}/S{season:pad2}/{title|originalTitle} S{season:pad2}E{episode:pad2}{subtitleLanguage?(.{subtitleLanguage})}{version?( {version})}.{extension}',
|
||||
fallbackToUnsorted: true, // If template fails (no title), put in Unsorted
|
||||
};
|
||||
|
||||
/**
|
||||
* Movies rule
|
||||
* Matches files with a title but no season number (assumed to be movies)
|
||||
*/
|
||||
const moviesRule: RenamingRule = {
|
||||
id: 'movies',
|
||||
name: 'Movies',
|
||||
description: 'Standalone movies without season/episode numbers',
|
||||
enabled: true,
|
||||
priority: 80,
|
||||
conditions: {
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
supportedFileTypeCondition,
|
||||
{ type: 'EXISTS', field: 'extension' },
|
||||
{
|
||||
operator: 'OR',
|
||||
conditions: [
|
||||
{ type: 'EXISTS', field: 'title' },
|
||||
{ type: 'EXISTS', field: 'originalTitle' },
|
||||
],
|
||||
},
|
||||
{ type: 'NOT_EXISTS', field: 'season' },
|
||||
],
|
||||
},
|
||||
// Old format: Movies/{title}{year}/{title}{year}{subtitle}{version}.{extension}
|
||||
template: 'Movies/{title|originalTitle}{movieYear?( ({movieYear}))}/{title|originalTitle}{movieYear?( ({movieYear}))}{subtitleLanguage?(.{subtitleLanguage})}{version?( {version})}.{extension}',
|
||||
fallbackToUnsorted: true, // If template fails, put in Unsorted
|
||||
};
|
||||
|
||||
/**
|
||||
* Images rule
|
||||
* Matches image files (posters, backdrops, etc.)
|
||||
* Note: Poster images are classified as 'document' type
|
||||
*/
|
||||
const imagesRule: RenamingRule = {
|
||||
id: 'images',
|
||||
name: 'Images',
|
||||
description: 'Image files like posters and backdrops',
|
||||
enabled: true,
|
||||
priority: 70,
|
||||
conditions: {
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
{
|
||||
operator: 'OR',
|
||||
conditions: [
|
||||
{ type: 'EQUALS', field: 'fileType', value: 'image' },
|
||||
{ type: 'EQUALS', field: 'fileType', value: 'document' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
template: 'Images/{fileName}',
|
||||
fallbackToUnsorted: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default fallback rule
|
||||
* Used when no other rules match - places files in Unsorted folder
|
||||
* Note: In old behavior, unsupported files were hidden. This rule provides visibility.
|
||||
*/
|
||||
const fallbackRule: RenamingRule = {
|
||||
id: 'fallback-unsorted',
|
||||
name: 'Unsorted',
|
||||
description: 'Files that do not match any other rule',
|
||||
enabled: true,
|
||||
priority: 0,
|
||||
conditions: {
|
||||
operator: 'AND',
|
||||
conditions: [], // Always matches
|
||||
},
|
||||
template: 'Unsorted/{fileName}',
|
||||
fallbackToUnsorted: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the default renaming configuration
|
||||
*
|
||||
* @returns The default RenamingConfig matching current hardcoded behavior
|
||||
*/
|
||||
export function getDefaultRulesConfig(): RenamingConfig {
|
||||
return {
|
||||
version: 1,
|
||||
rules: [
|
||||
tvExtrasRule,
|
||||
tvSeriesRule,
|
||||
moviesRule,
|
||||
imagesRule,
|
||||
],
|
||||
defaultRule: fallbackRule,
|
||||
lastModified: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Plex-style naming preset
|
||||
* Uses Plex's preferred naming conventions
|
||||
*/
|
||||
export function getPlexPreset(): RenamingConfig {
|
||||
return {
|
||||
version: 1,
|
||||
rules: [
|
||||
{
|
||||
...tvSeriesRule,
|
||||
id: 'plex-tv',
|
||||
name: 'TV Series (Plex)',
|
||||
template: 'TV Shows/{title|originalTitle}/Season {season:pad2}/{title|originalTitle} - S{season:pad2}E{episode:pad2}{subtitleLanguage?(.{subtitleLanguage})}{version?( - {version})}.{extension}',
|
||||
},
|
||||
{
|
||||
...moviesRule,
|
||||
id: 'plex-movies',
|
||||
name: 'Movies (Plex)',
|
||||
template: 'Movies/{title|originalTitle} ({movieYear|Unknown})/{title|originalTitle} ({movieYear|Unknown}){subtitleLanguage?(.{subtitleLanguage})}{version?( - {version})}.{extension}',
|
||||
},
|
||||
],
|
||||
defaultRule: fallbackRule,
|
||||
lastModified: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Jellyfin-style naming preset
|
||||
* Uses Jellyfin's preferred naming conventions
|
||||
*/
|
||||
export function getJellyfinPreset(): RenamingConfig {
|
||||
return {
|
||||
version: 1,
|
||||
rules: [
|
||||
{
|
||||
...tvSeriesRule,
|
||||
id: 'jellyfin-tv',
|
||||
name: 'TV Series (Jellyfin)',
|
||||
template: 'Shows/{title|originalTitle}{year?( ({year}))}/Season {season:pad2}/S{season:pad2}E{episode:pad2}{subtitleLanguage?(.{subtitleLanguage})}.{extension}',
|
||||
},
|
||||
{
|
||||
...moviesRule,
|
||||
id: 'jellyfin-movies',
|
||||
name: 'Movies (Jellyfin)',
|
||||
template: 'Movies/{title|originalTitle} ({movieYear})/{title|originalTitle} ({movieYear}){subtitleLanguage?(.{subtitleLanguage})}.{extension}',
|
||||
},
|
||||
],
|
||||
defaultRule: fallbackRule,
|
||||
lastModified: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Available presets
|
||||
*/
|
||||
export const PRESETS = {
|
||||
default: getDefaultRulesConfig,
|
||||
plex: getPlexPreset,
|
||||
jellyfin: getJellyfinPreset,
|
||||
} as const;
|
||||
|
||||
export type PresetName = keyof typeof PRESETS;
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Condition Evaluator
|
||||
*
|
||||
* Evaluates conditions and condition groups against metadata
|
||||
* to determine which renaming rule should be applied.
|
||||
*/
|
||||
|
||||
import { Logger } from 'tslog';
|
||||
import type { Condition, ConditionGroup } from '../types/RenamingRuleTypes.js';
|
||||
import { isConditionGroup } from '../types/RenamingRuleTypes.js';
|
||||
import { getNestedValue } from './TemplateEngine.js';
|
||||
|
||||
const logger = new Logger({ name: 'ConditionEvaluator' });
|
||||
|
||||
/**
|
||||
* Condition Evaluator class
|
||||
*/
|
||||
export class ConditionEvaluator {
|
||||
/**
|
||||
* Evaluate a single condition against metadata
|
||||
*
|
||||
* @param condition The condition to evaluate
|
||||
* @param metadata The metadata object to check against
|
||||
* @returns true if the condition is satisfied
|
||||
*/
|
||||
evaluateCondition(condition: Condition, metadata: Record<string, unknown>): boolean {
|
||||
const value = getNestedValue(metadata, condition.field);
|
||||
|
||||
switch (condition.type) {
|
||||
case 'EXISTS':
|
||||
return value !== null && value !== undefined;
|
||||
|
||||
case 'NOT_EXISTS':
|
||||
return value === null || value === undefined;
|
||||
|
||||
case 'EQUALS':
|
||||
// Handle type coercion for common cases
|
||||
if (typeof condition.value === 'boolean') {
|
||||
return value === condition.value;
|
||||
}
|
||||
if (typeof condition.value === 'number') {
|
||||
return Number(value) === condition.value;
|
||||
}
|
||||
return String(value) === String(condition.value);
|
||||
|
||||
case 'NOT_EQUALS':
|
||||
if (typeof condition.value === 'boolean') {
|
||||
return value !== condition.value;
|
||||
}
|
||||
if (typeof condition.value === 'number') {
|
||||
return Number(value) !== condition.value;
|
||||
}
|
||||
return String(value) !== String(condition.value);
|
||||
|
||||
case 'CONTAINS':
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
return String(value).includes(String(condition.value));
|
||||
|
||||
case 'MATCHES':
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const regex = new RegExp(String(condition.value));
|
||||
return regex.test(String(value));
|
||||
} catch (error) {
|
||||
logger.warn(`Invalid regex in condition: ${condition.value}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn(`Unknown condition type: ${(condition as Condition).type}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a condition group (AND/OR of multiple conditions)
|
||||
*
|
||||
* @param group The condition group to evaluate
|
||||
* @param metadata The metadata object to check against
|
||||
* @returns true if the group condition is satisfied
|
||||
*/
|
||||
evaluateGroup(group: ConditionGroup, metadata: Record<string, unknown>): boolean {
|
||||
// Empty conditions means always match
|
||||
if (group.conditions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const results = group.conditions.map(condition => {
|
||||
if (isConditionGroup(condition)) {
|
||||
return this.evaluateGroup(condition, metadata);
|
||||
}
|
||||
return this.evaluateCondition(condition, metadata);
|
||||
});
|
||||
|
||||
if (group.operator === 'AND') {
|
||||
return results.every(r => r);
|
||||
} else {
|
||||
return results.some(r => r);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate conditions - convenience method that handles both single conditions and groups
|
||||
*
|
||||
* @param conditions A single condition or condition group
|
||||
* @param metadata The metadata object to check against
|
||||
* @returns true if the conditions are satisfied
|
||||
*/
|
||||
evaluate(conditions: Condition | ConditionGroup, metadata: Record<string, unknown>): boolean {
|
||||
if (isConditionGroup(conditions)) {
|
||||
return this.evaluateGroup(conditions, metadata);
|
||||
}
|
||||
return this.evaluateCondition(conditions, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a condition or condition group
|
||||
*
|
||||
* @param conditions The conditions to validate
|
||||
* @returns Validation result with any errors
|
||||
*/
|
||||
validate(conditions: Condition | ConditionGroup): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (isConditionGroup(conditions)) {
|
||||
// Validate group
|
||||
if (!['AND', 'OR'].includes(conditions.operator)) {
|
||||
errors.push(`Invalid operator: ${conditions.operator}`);
|
||||
}
|
||||
|
||||
for (const condition of conditions.conditions) {
|
||||
const result = this.validate(condition);
|
||||
errors.push(...result.errors);
|
||||
}
|
||||
} else {
|
||||
// Validate single condition
|
||||
const validTypes = ['EXISTS', 'NOT_EXISTS', 'EQUALS', 'NOT_EQUALS', 'CONTAINS', 'MATCHES'];
|
||||
if (!validTypes.includes(conditions.type)) {
|
||||
errors.push(`Invalid condition type: ${conditions.type}`);
|
||||
}
|
||||
|
||||
if (!conditions.field || typeof conditions.field !== 'string') {
|
||||
errors.push('Condition must have a valid field');
|
||||
}
|
||||
|
||||
// Check if value is required
|
||||
const requiresValue = ['EQUALS', 'NOT_EQUALS', 'CONTAINS', 'MATCHES'];
|
||||
if (requiresValue.includes(conditions.type) && conditions.value === undefined) {
|
||||
errors.push(`Condition type ${conditions.type} requires a value`);
|
||||
}
|
||||
|
||||
// Validate regex for MATCHES
|
||||
if (conditions.type === 'MATCHES' && conditions.value !== undefined) {
|
||||
try {
|
||||
new RegExp(String(conditions.value));
|
||||
} catch (error) {
|
||||
errors.push(`Invalid regex: ${conditions.value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Template Engine
|
||||
*
|
||||
* Parses and interpolates template strings for renaming rules.
|
||||
*
|
||||
* Supported syntax:
|
||||
* - {var} - Basic variable interpolation
|
||||
* - {var:pad2} - Zero-pad to 2 digits
|
||||
* - {var:pad3} - Zero-pad to 3 digits
|
||||
* - {var?} - Optional: omit if missing
|
||||
* - {var?(text)} - Conditional section: include text only if var exists
|
||||
* - {var|default} - Use default value if var is missing
|
||||
* - {a.b} - Nested object access
|
||||
*/
|
||||
|
||||
import { Logger } from 'tslog';
|
||||
|
||||
const logger = new Logger({ name: 'TemplateEngine' });
|
||||
|
||||
/**
|
||||
* Token types for template parsing
|
||||
*/
|
||||
type TokenType = 'literal' | 'variable' | 'optional' | 'conditional' | 'default';
|
||||
|
||||
interface Token {
|
||||
type: TokenType;
|
||||
value: string;
|
||||
field?: string;
|
||||
format?: string;
|
||||
defaultValue?: string;
|
||||
innerTemplate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a nested value from an object using dot notation
|
||||
* e.g., getNestedValue({titles: {eng: 'Test'}}, 'titles.eng') => 'Test'
|
||||
*/
|
||||
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
const parts = path.split('.');
|
||||
let current: unknown = obj;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof current !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a format to a value
|
||||
*/
|
||||
function applyFormat(value: unknown, format: string): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const strValue = String(value);
|
||||
|
||||
switch (format) {
|
||||
case 'pad2':
|
||||
return strValue.padStart(2, '0');
|
||||
case 'pad3':
|
||||
return strValue.padStart(3, '0');
|
||||
case 'pad4':
|
||||
return strValue.padStart(4, '0');
|
||||
case 'upper':
|
||||
case 'uppercase':
|
||||
return strValue.toUpperCase();
|
||||
case 'lower':
|
||||
case 'lowercase':
|
||||
return strValue.toLowerCase();
|
||||
default:
|
||||
// Check for custom padding: padN where N is a number
|
||||
const padMatch = format.match(/^pad(\d+)$/);
|
||||
if (padMatch) {
|
||||
return strValue.padStart(parseInt(padMatch[1], 10), '0');
|
||||
}
|
||||
return strValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a template string into tokens
|
||||
*/
|
||||
function tokenize(template: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
let literalStart = 0;
|
||||
|
||||
while (i < template.length) {
|
||||
if (template[i] === '{') {
|
||||
// Save any literal text before this
|
||||
if (i > literalStart) {
|
||||
tokens.push({
|
||||
type: 'literal',
|
||||
value: template.slice(literalStart, i),
|
||||
});
|
||||
}
|
||||
|
||||
// Find the matching closing brace
|
||||
const start = i + 1;
|
||||
let depth = 1;
|
||||
let end = start;
|
||||
|
||||
while (end < template.length && depth > 0) {
|
||||
if (template[end] === '{') depth++;
|
||||
if (template[end] === '}') depth--;
|
||||
if (depth > 0) end++;
|
||||
}
|
||||
|
||||
if (depth !== 0) {
|
||||
// Unclosed brace, treat as literal
|
||||
tokens.push({
|
||||
type: 'literal',
|
||||
value: template.slice(i, end),
|
||||
});
|
||||
} else {
|
||||
const content = template.slice(start, end);
|
||||
tokens.push(parseVariable(content));
|
||||
}
|
||||
|
||||
i = end + 1;
|
||||
literalStart = i;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining literal text
|
||||
if (literalStart < template.length) {
|
||||
tokens.push({
|
||||
type: 'literal',
|
||||
value: template.slice(literalStart),
|
||||
});
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a variable expression inside braces
|
||||
*/
|
||||
function parseVariable(content: string): Token {
|
||||
// Check for conditional section: var?(inner template)
|
||||
const conditionalMatch = content.match(/^([a-zA-Z_][a-zA-Z0-9_.]*)\?\((.*)\)$/s);
|
||||
if (conditionalMatch) {
|
||||
return {
|
||||
type: 'conditional',
|
||||
value: content,
|
||||
field: conditionalMatch[1],
|
||||
innerTemplate: conditionalMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
// Check for optional variable: var?
|
||||
if (content.endsWith('?')) {
|
||||
const field = content.slice(0, -1);
|
||||
// Check for format: var:format?
|
||||
const formatMatch = field.match(/^([a-zA-Z_][a-zA-Z0-9_.]*):([a-z0-9]+)$/);
|
||||
if (formatMatch) {
|
||||
return {
|
||||
type: 'optional',
|
||||
value: content,
|
||||
field: formatMatch[1],
|
||||
format: formatMatch[2],
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'optional',
|
||||
value: content,
|
||||
field: field,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for default value: var|default
|
||||
const defaultMatch = content.match(/^([a-zA-Z_][a-zA-Z0-9_.]*)\|(.+)$/);
|
||||
if (defaultMatch) {
|
||||
return {
|
||||
type: 'default',
|
||||
value: content,
|
||||
field: defaultMatch[1],
|
||||
defaultValue: defaultMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
// Check for format: var:format
|
||||
const formatMatch = content.match(/^([a-zA-Z_][a-zA-Z0-9_.]*):([a-z0-9]+)$/);
|
||||
if (formatMatch) {
|
||||
return {
|
||||
type: 'variable',
|
||||
value: content,
|
||||
field: formatMatch[1],
|
||||
format: formatMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
// Simple variable
|
||||
return {
|
||||
type: 'variable',
|
||||
value: content,
|
||||
field: content,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Template Engine class for interpolating templates
|
||||
*/
|
||||
export class TemplateEngine {
|
||||
/**
|
||||
* Interpolate a template string with metadata
|
||||
*
|
||||
* @param template The template string with variables
|
||||
* @param metadata The metadata object to get values from
|
||||
* @returns The interpolated string, or null if a required variable is missing
|
||||
*/
|
||||
interpolate(template: string, metadata: Record<string, unknown>): string | null {
|
||||
const tokens = tokenize(template);
|
||||
const result: string[] = [];
|
||||
|
||||
for (const token of tokens) {
|
||||
switch (token.type) {
|
||||
case 'literal':
|
||||
result.push(token.value);
|
||||
break;
|
||||
|
||||
case 'variable': {
|
||||
const value = getNestedValue(metadata, token.field!);
|
||||
if (value === null || value === undefined) {
|
||||
// Required variable is missing
|
||||
logger.debug(`Missing required variable: ${token.field}`);
|
||||
return null;
|
||||
}
|
||||
const formatted = token.format ? applyFormat(value, token.format) : String(value);
|
||||
result.push(formatted);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'optional': {
|
||||
const value = getNestedValue(metadata, token.field!);
|
||||
if (value !== null && value !== undefined) {
|
||||
const formatted = token.format ? applyFormat(value, token.format) : String(value);
|
||||
result.push(formatted);
|
||||
}
|
||||
// If missing, just skip (don't add anything)
|
||||
break;
|
||||
}
|
||||
|
||||
case 'conditional': {
|
||||
const value = getNestedValue(metadata, token.field!);
|
||||
if (value !== null && value !== undefined) {
|
||||
// Variable exists, interpolate the inner template
|
||||
const innerResult = this.interpolate(token.innerTemplate!, metadata);
|
||||
if (innerResult !== null) {
|
||||
result.push(innerResult);
|
||||
}
|
||||
}
|
||||
// If missing, skip the entire conditional section
|
||||
break;
|
||||
}
|
||||
|
||||
case 'default': {
|
||||
const value = getNestedValue(metadata, token.field!);
|
||||
if (value !== null && value !== undefined) {
|
||||
result.push(String(value));
|
||||
} else {
|
||||
// Check if defaultValue looks like a field name (for field fallback)
|
||||
// Field names: start with letter/underscore, contain alphanumeric/dot/underscore
|
||||
const fieldPattern = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
|
||||
if (fieldPattern.test(token.defaultValue!)) {
|
||||
// Try to get value from fallback field
|
||||
const fallbackValue = getNestedValue(metadata, token.defaultValue!);
|
||||
if (fallbackValue !== null && fallbackValue !== undefined) {
|
||||
result.push(String(fallbackValue));
|
||||
} else {
|
||||
// Neither field exists - treat as missing required variable
|
||||
// Don't use the field name as a literal string
|
||||
logger.debug(`Missing both primary field '${token.field}' and fallback field '${token.defaultValue}'`);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// defaultValue is not a field name, use as literal
|
||||
result.push(token.defaultValue!);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a template string for syntax errors
|
||||
*
|
||||
* @param template The template to validate
|
||||
* @returns An object with valid flag and any errors/warnings
|
||||
*/
|
||||
validate(template: string): { valid: boolean; errors: string[]; warnings: string[] } {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
const tokens = tokenize(template);
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.type !== 'literal' && !token.field) {
|
||||
errors.push(`Invalid variable syntax: {${token.value}}`);
|
||||
}
|
||||
|
||||
// Check for nested conditional templates
|
||||
if (token.type === 'conditional' && token.innerTemplate) {
|
||||
const innerValidation = this.validate(token.innerTemplate);
|
||||
errors.push(...innerValidation.errors.map(e => `In conditional {${token.field}?()}: ${e}`));
|
||||
warnings.push(...innerValidation.warnings.map(w => `In conditional {${token.field}?()}: ${w}`));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`Template parsing error: ${error}`);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all variable names from a template
|
||||
*
|
||||
* @param template The template to analyze
|
||||
* @returns Array of variable field paths used in the template
|
||||
*/
|
||||
extractVariables(template: string): string[] {
|
||||
const variables: Set<string> = new Set();
|
||||
const tokens = tokenize(template);
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.field) {
|
||||
variables.add(token.field);
|
||||
}
|
||||
// Recursively extract from conditional templates
|
||||
if (token.type === 'conditional' && token.innerTemplate) {
|
||||
const innerVars = this.extractVariables(token.innerTemplate);
|
||||
innerVars.forEach(v => variables.add(v));
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(variables);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Template module exports
|
||||
*/
|
||||
|
||||
export { TemplateEngine, getNestedValue } from './TemplateEngine.js';
|
||||
export { ConditionEvaluator } from './ConditionEvaluator.js';
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Renaming Rule Types
|
||||
*
|
||||
* Defines the schema for configurable renaming rules that replace
|
||||
* the hardcoded renamingRule() function.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Condition operators for evaluating metadata fields
|
||||
*/
|
||||
export type ConditionOperator =
|
||||
| 'EXISTS' // Field exists and is not null/undefined
|
||||
| 'NOT_EXISTS' // Field is null/undefined
|
||||
| 'EQUALS' // Field equals specified value
|
||||
| 'NOT_EQUALS' // Field does not equal specified value
|
||||
| 'CONTAINS' // String field contains specified substring
|
||||
| 'MATCHES'; // String field matches specified regex
|
||||
|
||||
/**
|
||||
* A single condition that evaluates a metadata field
|
||||
*/
|
||||
export interface Condition {
|
||||
type: ConditionOperator;
|
||||
field: string; // Metadata field path, e.g., "titles.eng", "season", "fileType"
|
||||
value?: string | number | boolean; // Required for EQUALS, NOT_EQUALS, CONTAINS, MATCHES
|
||||
}
|
||||
|
||||
/**
|
||||
* A group of conditions combined with AND or OR logic
|
||||
*/
|
||||
export interface ConditionGroup {
|
||||
operator: 'AND' | 'OR';
|
||||
conditions: (Condition | ConditionGroup)[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A single renaming rule with conditions and a template
|
||||
*/
|
||||
export interface RenamingRule {
|
||||
id: string; // Unique identifier (UUID)
|
||||
name: string; // Human-readable name
|
||||
description?: string; // Optional description
|
||||
enabled: boolean; // Can disable without deleting
|
||||
priority: number; // Higher = evaluated first (default: 0)
|
||||
conditions: ConditionGroup;
|
||||
template: string; // Template string with variables
|
||||
fallbackToUnsorted: boolean; // If template fails, put in Unsorted/
|
||||
}
|
||||
|
||||
/**
|
||||
* Full renaming configuration with all rules
|
||||
*/
|
||||
export interface RenamingConfig {
|
||||
version: number; // Schema version for migrations
|
||||
rules: RenamingRule[];
|
||||
defaultRule?: RenamingRule; // Catch-all rule when no conditions match
|
||||
lastModified?: string; // ISO timestamp of last modification
|
||||
isDefault?: boolean; // True if this is the default config (not user-customized)
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview item showing how a file would be renamed
|
||||
*/
|
||||
export interface PreviewItem {
|
||||
sourcePath: string;
|
||||
currentVirtualPath: string;
|
||||
newVirtualPath: string | null;
|
||||
matchedRule: string | null; // Rule ID that matched
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for preview endpoint
|
||||
*/
|
||||
export interface PreviewResponse {
|
||||
previews: PreviewItem[];
|
||||
total: number;
|
||||
limited: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Available template variable metadata for UI
|
||||
*/
|
||||
export interface TemplateVariable {
|
||||
name: string; // Display name
|
||||
path: string; // Field path (e.g., "titles.eng")
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
description: string;
|
||||
example?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* List of all available template variables
|
||||
*/
|
||||
export const TEMPLATE_VARIABLES: TemplateVariable[] = [
|
||||
{ name: 'English Title', path: 'titles.eng', type: 'string', description: 'English title from metadata', example: 'Breaking Bad' },
|
||||
{ name: 'Original Title', path: 'originalTitle', type: 'string', description: 'Original/native title', example: 'Breaking Bad' },
|
||||
{ name: 'Title', path: 'title', type: 'string', description: 'Simple title field', example: 'Breaking Bad' },
|
||||
{ name: 'Season', path: 'season', type: 'number', description: 'Season number', example: '1' },
|
||||
{ name: 'Episode', path: 'episode', type: 'number', description: 'Episode number', example: '1' },
|
||||
{ name: 'Is Extra', path: 'extra', type: 'boolean', description: 'Whether this is a special/extra', example: 'false' },
|
||||
{ name: 'Movie Year', path: 'movieYear', type: 'number', description: 'Movie release year', example: '2010' },
|
||||
{ name: 'Year', path: 'year', type: 'number', description: 'General year field', example: '2010' },
|
||||
{ name: 'File Type', path: 'fileType', type: 'string', description: 'Type: video, subtitle, torrent', example: 'video' },
|
||||
{ name: 'Extension', path: 'extension', type: 'string', description: 'File extension', example: 'mkv' },
|
||||
{ name: 'Version', path: 'version', type: 'string', description: 'Quality/release version', example: '1080p BluRay' },
|
||||
{ name: 'Subtitle Language', path: 'subtitleLanguage', type: 'string', description: 'Language code for subtitles', example: 'eng' },
|
||||
{ name: 'File Name', path: 'fileName', type: 'string', description: 'Original filename', example: 'Breaking.Bad.S01E01.mkv' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Type guard to check if a condition is a ConditionGroup
|
||||
*/
|
||||
export function isConditionGroup(condition: Condition | ConditionGroup): condition is ConditionGroup {
|
||||
return 'operator' in condition && 'conditions' in condition;
|
||||
}
|
||||
Reference in New Issue
Block a user