initial commit

This commit is contained in:
Pierre
2026-01-11 17:26:07 +01:00
commit ff4fbf136f
49 changed files with 13118 additions and 0 deletions
@@ -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;
}