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,459 @@
/**
* Fastify API Server for FUSE operations
*
* Exposes the VirtualFileSystem over HTTP for consumption by the FUSE driver
*/
import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import cors from '@fastify/cors';
import { Logger } from 'tslog';
import { VirtualFileSystem } from '../vfs/VirtualFileSystem.js';
import type { KVManager } from '../kv/KVManager.js';
import type { RenamingConfig, RenamingRule, PreviewResponse } from '../vfs/types/RenamingRuleTypes.js';
import { TEMPLATE_VARIABLES } from '../vfs/types/RenamingRuleTypes.js';
import { TemplateEngine } from '../vfs/template/TemplateEngine.js';
import { ConditionEvaluator } from '../vfs/template/ConditionEvaluator.js';
const logger = new Logger({ name: 'APIServer' });
export interface APIServerConfig {
port?: number;
host?: string;
}
interface PathBody {
path: string;
}
export class APIServer {
private app: FastifyInstance;
private vfs: VirtualFileSystem;
private kvManager: KVManager | null = null;
private config: Required<APIServerConfig>;
constructor(vfs: VirtualFileSystem, config: APIServerConfig = {}, kvManager?: KVManager) {
this.vfs = vfs;
this.kvManager = kvManager || null;
this.config = {
port: config.port ?? parseInt(process.env.API_PORT ?? '3000', 10),
host: config.host ?? process.env.API_HOST ?? '0.0.0.0',
};
this.app = Fastify({
logger: false,
});
this.setupRoutes();
}
/**
* Setup API routes
*/
private setupRoutes(): void {
// CORS
this.app.register(cors, {
origin: true,
methods: ['GET', 'POST', 'OPTIONS'],
});
// Health check
this.app.get('/health', this.handleHealth.bind(this));
this.app.get('/api/health', this.handleHealth.bind(this));
this.app.get('/api/fuse/health', this.handleHealth.bind(this));
// Stats
this.app.get('/api/fuse/stats', this.handleStats.bind(this));
// FUSE operations
this.app.post('/api/fuse/readdir', this.handleReaddir.bind(this));
this.app.post('/api/fuse/getattr', this.handleGetattr.bind(this));
this.app.post('/api/fuse/exists', this.handleExists.bind(this));
this.app.post('/api/fuse/read', this.handleRead.bind(this));
this.app.post('/api/fuse/metadata', this.handleMetadata.bind(this));
// Directory listing endpoints
this.app.get('/api/fuse/files', this.handleFiles.bind(this));
this.app.get('/api/fuse/directories', this.handleDirectories.bind(this));
// Refresh
this.app.post('/api/fuse/refresh', this.handleRefresh.bind(this));
// Renaming rules configuration
this.app.get('/api/fuse/rules', this.handleGetRules.bind(this));
this.app.put('/api/fuse/rules', this.handleUpdateRules.bind(this));
this.app.post('/api/fuse/rules/preview', this.handlePreviewRules.bind(this));
this.app.post('/api/fuse/rules/validate', this.handleValidateRule.bind(this));
this.app.get('/api/fuse/rules/variables', this.handleGetVariables.bind(this));
// Service discovery (for inter-service navigation)
if (this.kvManager) {
this.app.get('/api/services', this.handleServices.bind(this));
}
}
/**
* Start the server
*/
async start(): Promise<void> {
try {
await this.app.listen({
port: this.config.port,
host: this.config.host,
});
logger.info(`API Server listening on http://${this.config.host}:${this.config.port}`);
} catch (error) {
logger.error('Failed to start API server:', error);
throw error;
}
}
/**
* Stop the server
*/
async stop(): Promise<void> {
await this.app.close();
logger.info('API Server stopped');
}
// Route handlers
private async handleHealth(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
reply.send({
status: 'ok',
timestamp: new Date().toISOString(),
service: 'meta-fuse',
});
}
private async handleStats(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
const stats = this.vfs.getStats();
reply.send(stats);
}
private async handleReaddir(
req: FastifyRequest<{ Body: PathBody }>,
reply: FastifyReply
): Promise<void> {
const { path } = req.body;
if (typeof path !== 'string') {
reply.status(400).send({ error: 'Missing or invalid "path" parameter' });
return;
}
const entries = this.vfs.readdir(path);
if (entries === null) {
reply.status(404).send({ error: 'Directory not found' });
return;
}
reply.send({ entries });
}
private async handleGetattr(
req: FastifyRequest<{ Body: PathBody }>,
reply: FastifyReply
): Promise<void> {
const { path } = req.body;
if (typeof path !== 'string') {
reply.status(400).send({ error: 'Missing or invalid "path" parameter' });
return;
}
const attrs = this.vfs.getattr(path);
if (attrs === null) {
reply.status(404).send({ error: 'Path not found' });
return;
}
reply.send(attrs);
}
private async handleExists(
req: FastifyRequest<{ Body: PathBody }>,
reply: FastifyReply
): Promise<void> {
const { path } = req.body;
if (typeof path !== 'string') {
reply.status(400).send({ error: 'Missing or invalid "path" parameter' });
return;
}
const exists = this.vfs.exists(path);
reply.send({ exists });
}
private async handleRead(
req: FastifyRequest<{ Body: PathBody }>,
reply: FastifyReply
): Promise<void> {
const { path } = req.body;
if (typeof path !== 'string') {
reply.status(400).send({ error: 'Missing or invalid "path" parameter' });
return;
}
const result = this.vfs.read(path);
if (result === null) {
reply.status(404).send({ error: 'File not found' });
return;
}
// Return source path for FUSE driver to read from
const response: Record<string, unknown> = {
sourcePath: result.sourcePath,
size: result.size,
};
// If content is provided (virtual file), encode as base64
if (result.content !== null) {
response.content = result.content.toString('base64');
response.contentEncoding = 'base64';
}
reply.send(response);
}
private async handleMetadata(
req: FastifyRequest<{ Body: PathBody }>,
reply: FastifyReply
): Promise<void> {
const { path } = req.body;
if (typeof path !== 'string') {
reply.status(400).send({ error: 'Missing or invalid "path" parameter' });
return;
}
const metadata = this.vfs.getMetadata(path);
if (metadata === null) {
reply.status(404).send({ error: 'Metadata not found' });
return;
}
reply.send(metadata);
}
private async handleFiles(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
const files = this.vfs.getAllFiles();
reply.send({ files });
}
private async handleDirectories(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
const directories = this.vfs.getAllDirectories();
reply.send({ directories });
}
private async handleRefresh(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
await this.vfs.refresh();
reply.send({ status: 'ok' });
}
private async handleServices(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
interface ServiceResponse {
name: string;
url: string;
api: string;
status: string;
capabilities: string[];
version: string;
}
const services: ServiceResponse[] = [];
let leaderInfo: { host: string; api: string; http: string } | null = null;
try {
const serviceDiscovery = this.kvManager?.getServiceDiscovery();
if (serviceDiscovery) {
const allServices = await serviceDiscovery.discoverAllServices();
for (const svc of allServices) {
// Build dashboard URL from API URL
const apiUrl = svc.api || '';
const dashboardPath = svc.endpoints?.dashboard || '/';
services.push({
name: svc.name || 'Unknown',
url: apiUrl + dashboardPath,
api: apiUrl,
status: svc.status || 'unknown',
capabilities: svc.capabilities || [],
version: svc.version || '',
});
}
}
} catch (error) {
logger.error('Error discovering services:', error);
}
// Get leader info
if (this.kvManager) {
const info = this.kvManager.getLeaderInfo();
if (info) {
leaderInfo = {
host: info.host,
api: info.api,
http: info.http,
};
}
}
reply.send({
services,
current: 'meta-fuse',
leader: leaderInfo,
isLeader: false, // meta-fuse is never the leader
});
}
// ============================================
// Renaming Rules API Handlers
// ============================================
/**
* Get current renaming rules configuration
*/
private async handleGetRules(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const config = this.vfs.getRulesConfig();
reply.send({
config,
lastModified: config.lastModified || new Date().toISOString(),
});
} catch (error: any) {
logger.error('Failed to get rules:', error);
reply.status(500).send({ error: 'Failed to get rules configuration' });
}
}
/**
* Update renaming rules configuration
*/
private async handleUpdateRules(
req: FastifyRequest<{ Body: { config: RenamingConfig } }>,
reply: FastifyReply
): Promise<void> {
try {
const { config } = req.body;
if (!config || typeof config !== 'object') {
reply.status(400).send({ error: 'Missing or invalid config in request body' });
return;
}
// Validate rules
const errors: string[] = [];
const templateEngine = new TemplateEngine();
const conditionEvaluator = new ConditionEvaluator();
for (const rule of config.rules) {
// Validate template
const templateValidation = templateEngine.validate(rule.template);
if (!templateValidation.valid) {
errors.push(`Rule "${rule.name}": ${templateValidation.errors.join(', ')}`);
}
// Validate conditions
const conditionValidation = conditionEvaluator.validate(rule.conditions);
if (!conditionValidation.valid) {
errors.push(`Rule "${rule.name}": ${conditionValidation.errors.join(', ')}`);
}
}
if (errors.length > 0) {
reply.status(400).send({ success: false, errors });
return;
}
// Save configuration
this.vfs.saveRulesConfig(config);
// Trigger immediate VFS refresh
await this.vfs.refresh();
reply.send({ success: true, refreshed: true });
} catch (error: any) {
logger.error('Failed to update rules:', error);
reply.status(500).send({ error: 'Failed to update rules configuration' });
}
}
/**
* Preview how files would be renamed with given rules
*/
private async handlePreviewRules(
req: FastifyRequest<{ Body: { rules?: RenamingRule[]; limit?: number } }>,
reply: FastifyReply
): Promise<void> {
try {
const { rules, limit = 100 } = req.body || {};
const preview = await this.vfs.previewRules(rules, limit);
reply.send(preview);
} catch (error: any) {
logger.error('Failed to preview rules:', error);
reply.status(500).send({ error: 'Failed to generate preview' });
}
}
/**
* Validate a single rule
*/
private async handleValidateRule(
req: FastifyRequest<{ Body: { rule: RenamingRule; sampleMetadata?: Record<string, unknown> } }>,
reply: FastifyReply
): Promise<void> {
try {
const { rule, sampleMetadata } = req.body;
if (!rule || typeof rule !== 'object') {
reply.status(400).send({ error: 'Missing or invalid rule in request body' });
return;
}
const templateEngine = new TemplateEngine();
const conditionEvaluator = new ConditionEvaluator();
const errors: string[] = [];
const warnings: string[] = [];
// Validate template
const templateValidation = templateEngine.validate(rule.template);
errors.push(...templateValidation.errors);
warnings.push(...templateValidation.warnings);
// Validate conditions
const conditionValidation = conditionEvaluator.validate(rule.conditions);
errors.push(...conditionValidation.errors);
// Generate sample output if metadata provided
let sampleOutput: string | undefined;
if (sampleMetadata && errors.length === 0) {
sampleOutput = templateEngine.interpolate(rule.template, sampleMetadata) || undefined;
}
reply.send({
valid: errors.length === 0,
errors,
warnings,
sampleOutput,
});
} catch (error: any) {
logger.error('Failed to validate rule:', error);
reply.status(500).send({ error: 'Failed to validate rule' });
}
}
/**
* Get list of available template variables
*/
private async handleGetVariables(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
reply.send({ variables: TEMPLATE_VARIABLES });
}
}
@@ -0,0 +1,171 @@
/**
* ConfigStorage - File-based configuration storage
*
* Stores configuration (like renaming rules) in JSON files
* instead of Redis, which should only be used for metadata.
*/
import * as fs from 'fs';
import * as path from 'path';
import { Logger } from 'tslog';
import type { RenamingConfig } from '../vfs/types/RenamingRuleTypes.js';
import { getDefaultRulesConfig } from '../vfs/defaults/defaultRules.js';
const logger = new Logger({ name: 'ConfigStorage' });
export interface ConfigStorageOptions {
configDir: string; // Directory to store config files
}
export class ConfigStorage {
private configDir: string;
private rulesFile: string;
constructor(options: ConfigStorageOptions) {
this.configDir = options.configDir;
this.rulesFile = path.join(this.configDir, 'renaming-rules.json');
// Ensure config directory exists
this.ensureConfigDir();
}
/**
* Ensure the config directory exists
*/
private ensureConfigDir(): void {
try {
if (!fs.existsSync(this.configDir)) {
fs.mkdirSync(this.configDir, { recursive: true });
logger.info(`Created config directory: ${this.configDir}`);
}
} catch (error) {
logger.error(`Failed to create config directory: ${this.configDir}`, error);
}
}
/**
* Get renaming rules configuration from file
* If no config file exists, creates one with default rules
*
* @returns The stored RenamingConfig
*/
getRulesConfig(): RenamingConfig {
try {
if (!fs.existsSync(this.rulesFile)) {
logger.info('No renaming rules config file found, creating default');
const defaultConfig = this.createDefaultConfig();
return defaultConfig;
}
const data = fs.readFileSync(this.rulesFile, 'utf-8');
const config = JSON.parse(data) as RenamingConfig;
logger.debug(`Loaded renaming rules config with ${config.rules.length} rules`);
return config;
} catch (error) {
logger.error('Failed to read rules config, using defaults:', error);
return this.createDefaultConfig();
}
}
/**
* Create and save the default configuration file
*/
private createDefaultConfig(): RenamingConfig {
const defaultConfig = getDefaultRulesConfig();
// Mark as default configuration
const configWithDefault: RenamingConfig = {
...defaultConfig,
isDefault: true,
lastModified: new Date().toISOString(),
};
// Save to file so user can see/edit it
try {
fs.writeFileSync(
this.rulesFile,
JSON.stringify(configWithDefault, null, 2),
'utf-8'
);
logger.info('Created default renaming rules config file');
} catch (error) {
logger.error('Failed to create default config file:', error);
}
return configWithDefault;
}
/**
* Save renaming rules configuration to file
* Removes the isDefault field since this is now user-customized
*
* @param config The RenamingConfig to save
*/
saveRulesConfig(config: RenamingConfig): void {
try {
// Create backup of current config if it exists
if (fs.existsSync(this.rulesFile)) {
const backupFile = path.join(
this.configDir,
`renaming-rules.backup.${Date.now()}.json`
);
fs.copyFileSync(this.rulesFile, backupFile);
logger.debug(`Created backup: ${backupFile}`);
// Clean up old backups (keep last 5)
this.cleanupBackups();
}
// Remove isDefault flag (user is customizing) and update timestamp
const { isDefault, ...configWithoutDefault } = config;
const configToSave: RenamingConfig = {
...configWithoutDefault,
lastModified: new Date().toISOString(),
};
// Save new config with pretty formatting
fs.writeFileSync(
this.rulesFile,
JSON.stringify(configToSave, null, 2),
'utf-8'
);
logger.info(`Saved renaming rules config with ${config.rules.length} rules`);
} catch (error) {
logger.error('Failed to save rules config:', error);
throw error;
}
}
/**
* Clean up old backup files, keeping only the most recent ones
*/
private cleanupBackups(keepCount: number = 5): void {
try {
const files = fs.readdirSync(this.configDir);
const backups = files
.filter(f => f.startsWith('renaming-rules.backup.') && f.endsWith('.json'))
.map(f => ({
name: f,
path: path.join(this.configDir, f),
time: parseInt(f.replace('renaming-rules.backup.', '').replace('.json', ''), 10),
}))
.sort((a, b) => b.time - a.time);
// Remove old backups beyond keepCount
for (let i = keepCount; i < backups.length; i++) {
fs.unlinkSync(backups[i].path);
logger.debug(`Removed old backup: ${backups[i].name}`);
}
} catch (error) {
logger.warn('Failed to cleanup backups:', error);
}
}
/**
* Get the config directory path
*/
getConfigDir(): string {
return this.configDir;
}
}
+168
View File
@@ -0,0 +1,168 @@
/**
* meta-fuse-core entry point
*
* Standalone FUSE API server that reads metadata from Redis,
* computes virtual paths, and exposes organized folder structure.
*/
import 'dotenv/config';
import { Logger } from 'tslog';
import { KVManager } from './kv/KVManager.js';
import { RedisClient } from './kv/RedisClient.js';
import { VirtualFileSystem } from './vfs/VirtualFileSystem.js';
import { APIServer } from './api/APIServer.js';
const logger = new Logger({ name: 'meta-fuse' });
// Pub/sub channel for batched updates from meta-sort
const UPDATE_CHANNEL = 'meta-sort:file:batch';
interface BatchUpdateMessage {
timestamp: number;
changes: Array<{
action: 'add' | 'update' | 'remove';
hashId: string;
}>;
}
async function main(): Promise<void> {
logger.info('Starting meta-fuse...');
logger.info(`Node.js ${process.version}`);
// Configuration
const config = {
metaCorePath: process.env.META_CORE_PATH ?? '/meta-core',
filesPath: process.env.FILES_VOLUME ?? '/files',
redisUrl: process.env.REDIS_URL,
redisPrefix: process.env.REDIS_PREFIX ?? 'meta-sort:',
apiPort: parseInt(process.env.API_PORT ?? '3000', 10),
apiHost: process.env.API_HOST ?? '0.0.0.0',
vfsRefreshInterval: parseInt(process.env.VFS_REFRESH_INTERVAL ?? '30000', 10),
baseUrl: process.env.BASE_URL,
serviceVersion: process.env.SERVICE_VERSION ?? '1.0.0',
};
logger.info(`Config: META_CORE_PATH=${config.metaCorePath}, FILES_VOLUME=${config.filesPath}`);
// Initialize KV Manager (handles leader discovery)
const kvManager = new KVManager({
metaCorePath: config.metaCorePath,
filesPath: config.filesPath,
serviceName: 'meta-fuse',
version: config.serviceVersion,
apiPort: config.apiPort,
redisUrl: config.redisUrl,
redisPrefix: config.redisPrefix,
baseUrl: config.baseUrl,
capabilities: ['read', 'vfs'],
});
let redisClient: RedisClient | null = null;
let vfs: VirtualFileSystem | null = null;
let apiServer: APIServer | null = null;
// Graceful shutdown
const shutdown = async (signal: string): Promise<void> => {
logger.info(`Received ${signal}, shutting down...`);
try {
if (apiServer) await apiServer.stop();
if (vfs) await vfs.stop();
await kvManager.stop();
logger.info('Shutdown complete');
process.exit(0);
} catch (error) {
logger.error('Error during shutdown:', error);
process.exit(1);
}
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Handle ready event from KV manager
kvManager.onReady(async () => {
logger.info('KV Manager ready, initializing VFS...');
try {
// Get Redis client from KV manager
redisClient = kvManager.getClient();
if (!redisClient) {
logger.error('Redis client not available');
return;
}
// Initialize VFS
vfs = new VirtualFileSystem(redisClient, {
fileMode: parseInt(process.env.FUSE_FILE_MODE ?? '644', 8),
directoryMode: parseInt(process.env.FUSE_DIR_MODE ?? '755', 8),
uid: parseInt(process.env.PUID ?? '1000', 10),
gid: parseInt(process.env.PGID ?? '1000', 10),
refreshInterval: config.vfsRefreshInterval,
configDir: process.env.CONFIG_DIR ?? '/meta-fuse/config',
});
// Start VFS
logger.info('Starting VirtualFileSystem...');
await vfs.start();
// Subscribe to batch updates from meta-sort
logger.info(`Subscribing to ${UPDATE_CHANNEL}...`);
await redisClient.subscribe(UPDATE_CHANNEL, async (message: string) => {
try {
const batch: BatchUpdateMessage = JSON.parse(message);
logger.debug(`Received batch update: ${batch.changes.length} changes`);
// Process each change incrementally
for (const change of batch.changes) {
await vfs!.onFileUpdate(change.hashId, change.action);
}
} catch (error: any) {
logger.error(`Error processing batch update: ${error.message}`);
}
});
// Initialize API server (with kvManager for service discovery)
apiServer = new APIServer(vfs, {
port: config.apiPort,
host: config.apiHost,
}, kvManager);
// Start API server
logger.info('Starting API server...');
await apiServer.start();
logger.info('meta-fuse is ready!');
logger.info(`API: http://${config.apiHost}:${config.apiPort}`);
// Log initial stats
const stats = vfs.getStats();
logger.info(`VFS: ${stats.fileCount} files, ${stats.directoryCount} directories`);
} catch (error) {
logger.error('Failed to initialize after KV ready:', error);
}
});
// Handle disconnect event
kvManager.onDisconnect(() => {
logger.warn('Redis disconnected, VFS will use cached data');
});
// Start KV Manager (initiates leader discovery)
try {
logger.info('Starting KV Manager...');
await kvManager.start();
// Wait for ready with timeout
await kvManager.waitForReady(60000);
} catch (error) {
logger.error('Failed to start meta-fuse:', error);
process.exit(1);
}
}
main().catch((error) => {
logger.error('Fatal error:', error);
process.exit(1);
});
+113
View File
@@ -0,0 +1,113 @@
/**
* KV Client Interface - Read-only abstraction for meta-fuse
*
* meta-fuse is a FOLLOWER service that only reads metadata from Redis.
* This interface provides the subset of operations needed for reading.
*/
export interface KeyValuePair {
key: string;
value: string;
}
export interface IKVClient {
/**
* Get a value by key (value is JSON deserialized)
*/
get(key: string): Promise<any | null>;
/**
* Count keys with a given prefix
*/
countKeysWithPrefix(prefix: string): Promise<number>;
/**
* Health check - verifies KV store is accessible
*/
health(): Promise<boolean>;
/**
* Get all key-value pairs with a given prefix
*/
getRange(prefix: string): Promise<KeyValuePair[]>;
/**
* Retrieve file metadata - reconstructs from individual property keys
*/
getMetadataFlat(hashId: string): Promise<any | null>;
/**
* Get all unique hash IDs (files) stored in KV
*/
getAllHashIds(): Promise<string[]>;
/**
* Subscribe to Redis pub/sub channel
*/
subscribe(channel: string, callback: (message: string) => void): Promise<void>;
/**
* Unsubscribe from Redis pub/sub channel
*/
unsubscribe(channel: string): Promise<void>;
/**
* Close connections and cleanup resources
*/
close(): Promise<void>;
}
/**
* Leader lock file content format
*/
export interface LeaderLockInfo {
/** Hostname of the leader */
host: string;
/** Redis connection URL (e.g., redis://10.0.1.50:6379) */
api: string;
/** HTTP API URL for the leader service */
http: string;
/** Timestamp when leadership was acquired */
timestamp: number;
/** Process ID of the leader */
pid: number;
}
/**
* Service registration info stored in JSON file
*/
export interface ServiceInfo {
/** Service name */
name: string;
/** Service version */
version: string;
/** HTTP API URL (e.g., http://10.0.1.50:3000) */
api: string;
/** Service status */
status: 'starting' | 'running' | 'stopping' | 'stopped';
/** Process ID */
pid: number;
/** Hostname */
hostname: string;
/** Service start time (ISO string) */
startedAt: string;
/** Last heartbeat time (ISO string) */
lastHeartbeat: string;
/** Service capabilities */
capabilities: string[];
/** Named endpoints */
endpoints: Record<string, string>;
}
+338
View File
@@ -0,0 +1,338 @@
/**
* KV Manager for meta-fuse (FOLLOWER-only)
*
* Simplified KV manager that:
* 1. Discovers the leader via lock file
* 2. Creates Redis client connection
* 3. Handles reconnection on leader failure
*
* Unlike meta-sort's KVManager, this version never spawns Redis -
* it only connects as a client.
*/
import { Logger } from 'tslog';
import { LeaderDiscovery } from './LeaderDiscovery.js';
import { ServiceDiscovery } from './ServiceDiscovery.js';
import { RedisClient } from './RedisClient.js';
import type { LeaderLockInfo } from './IKVClient.js';
const logger = new Logger({ name: 'KVManager' });
interface KVManagerConfig {
/** Path to META_CORE_VOLUME (e.g., /meta-core) */
metaCorePath: string;
/** Path to FILES_VOLUME (e.g., /files) */
filesPath: string;
/** Service name (for logging) */
serviceName?: string;
/** Service version */
version?: string;
/** HTTP API port */
apiPort?: number;
/** Override Redis URL (skip leader discovery if provided) */
redisUrl?: string;
/** Redis key prefix (default: 'meta-sort:') */
redisPrefix?: string;
/** Base URL for service discovery (overrides auto-detected URL) */
baseUrl?: string;
/** Service capabilities */
capabilities?: string[];
}
export class KVManager {
private config: Required<KVManagerConfig>;
private leaderDiscovery: LeaderDiscovery | null = null;
private serviceDiscovery: ServiceDiscovery | null = null;
private redisClient: RedisClient | null = null;
private isStarted = false;
private isShuttingDown = false;
// Event callbacks
private onReadyCallbacks: (() => void)[] = [];
private onDisconnectCallbacks: (() => void)[] = [];
constructor(config: KVManagerConfig) {
this.config = {
serviceName: 'meta-fuse',
version: '1.0.0',
apiPort: 3000,
redisUrl: '',
redisPrefix: 'meta-sort:',
baseUrl: '',
capabilities: ['read', 'vfs'],
...config
};
}
/**
* Start the KV manager
*/
async start(): Promise<void> {
if (this.isStarted) {
logger.warn('KVManager already started');
return;
}
logger.info(`Starting KVManager for ${this.config.serviceName}...`);
// Initialize service discovery
const apiUrl = this.config.baseUrl || `http://localhost:${this.config.apiPort}`;
this.serviceDiscovery = new ServiceDiscovery({
metaCorePath: this.config.metaCorePath,
serviceName: this.config.serviceName,
version: this.config.version,
apiUrl,
baseUrl: this.config.baseUrl || undefined,
capabilities: this.config.capabilities,
endpoints: {
health: '/api/fuse/health',
dashboard: '/',
stats: '/api/fuse/stats',
}
});
// Start service discovery
await this.serviceDiscovery.start();
// If direct Redis URL provided, skip leader discovery
if (this.config.redisUrl) {
logger.info(`Using direct Redis URL: ${this.config.redisUrl}`);
await this.connectToRedis(this.config.redisUrl);
this.isStarted = true;
return;
}
// Use leader discovery
this.leaderDiscovery = new LeaderDiscovery({
metaCorePath: this.config.metaCorePath
});
// Set up leader discovery callbacks
this.leaderDiscovery.onLeaderFound(async (info: LeaderLockInfo) => {
logger.info(`Connecting to leader at ${info.api}...`);
await this.connectToRedis(info.api);
});
this.leaderDiscovery.onLeaderLost(async () => {
logger.warn('Leader lost, disconnecting...');
await this.disconnectRedis();
this.notifyDisconnect();
});
// Start leader discovery
await this.leaderDiscovery.start();
this.isStarted = true;
}
/**
* Stop the KV manager
*/
async stop(): Promise<void> {
if (!this.isStarted) return;
logger.info('Stopping KVManager...');
this.isShuttingDown = true;
// Stop service discovery
if (this.serviceDiscovery) {
await this.serviceDiscovery.stop();
this.serviceDiscovery = null;
}
// Stop leader discovery
if (this.leaderDiscovery) {
await this.leaderDiscovery.stop();
this.leaderDiscovery = null;
}
// Disconnect Redis
await this.disconnectRedis();
this.isStarted = false;
logger.info('KVManager stopped');
}
/**
* Connect to Redis
*/
private async connectToRedis(url: string): Promise<void> {
try {
// Disconnect existing client if any
await this.disconnectRedis();
// Create new client
this.redisClient = new RedisClient({
url,
prefix: this.config.redisPrefix,
filesVolume: this.config.filesPath
});
await this.redisClient.connect();
logger.info('Connected to Redis');
// Notify ready
this.notifyReady();
} catch (error: any) {
logger.error(`Failed to connect to Redis: ${error.message}`);
this.redisClient = null;
}
}
/**
* Disconnect from Redis
*/
private async disconnectRedis(): Promise<void> {
if (this.redisClient) {
try {
await this.redisClient.disconnect();
} catch (error: any) {
logger.warn(`Error disconnecting Redis: ${error.message}`);
}
this.redisClient = null;
}
}
/**
* Notify ready callbacks
*/
private notifyReady(): void {
for (const callback of this.onReadyCallbacks) {
try {
callback();
} catch (error: any) {
logger.error(`Error in ready callback: ${error.message}`);
}
}
}
/**
* Notify disconnect callbacks
*/
private notifyDisconnect(): void {
for (const callback of this.onDisconnectCallbacks) {
try {
callback();
} catch (error: any) {
logger.error(`Error in disconnect callback: ${error.message}`);
}
}
}
// ========================================================================
// Event Registration
// ========================================================================
/**
* Register callback for when KV client is ready
*/
onReady(callback: () => void): this {
this.onReadyCallbacks.push(callback);
// If already ready, call immediately
if (this.redisClient?.connected) {
callback();
}
return this;
}
/**
* Register callback for disconnection
*/
onDisconnect(callback: () => void): this {
this.onDisconnectCallbacks.push(callback);
return this;
}
/**
* Wait for KV client to be ready
*/
async waitForReady(timeoutMs: number = 30000): Promise<void> {
if (this.redisClient?.connected) return;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`KV client not ready after ${timeoutMs}ms`));
}, timeoutMs);
this.onReady(() => {
clearTimeout(timeout);
resolve();
});
});
}
// ========================================================================
// Getters
// ========================================================================
/**
* Get the Redis client (may be null if not connected)
*/
getClient(): RedisClient | null {
return this.redisClient;
}
/**
* Get the Redis client (throws if not connected)
*/
requireClient(): RedisClient {
if (!this.redisClient) {
throw new Error('Redis client not available. Ensure start() was called and leader is available.');
}
return this.redisClient;
}
/**
* Check if connected
*/
isConnected(): boolean {
return this.redisClient?.connected ?? false;
}
/**
* Health check
*/
async isHealthy(): Promise<boolean> {
if (!this.redisClient) return false;
try {
const stats = await this.redisClient.getStats();
return stats.connected;
} catch {
return false;
}
}
/**
* Get leader info (if using leader discovery)
*/
getLeaderInfo(): LeaderLockInfo | null {
return this.leaderDiscovery?.getLeaderInfo() ?? null;
}
/**
* Get service discovery (for inter-service navigation)
*/
getServiceDiscovery(): ServiceDiscovery | null {
return this.serviceDiscovery;
}
/**
* Get configured paths
*/
getPaths() {
return {
metaCorePath: this.config.metaCorePath,
filesPath: this.config.filesPath
};
}
}
@@ -0,0 +1,276 @@
/**
* Leader Discovery for meta-fuse (FOLLOWER-only)
*
* This is a simplified version of leader election that only discovers
* the existing leader. meta-fuse never spawns Redis - it only connects
* to the leader's Redis instance.
*
* Architecture:
* - Leader holds flock on META_CORE_PATH/locks/kv-leader.lock
* - Leader writes info to META_CORE_PATH/locks/kv-leader.info
* - This class reads the info file to discover the leader
* - Watches for leader changes via health checks
* - Triggers reconnection callbacks on leader failure/change
*/
import { promises as fs, watch } from 'fs';
import { dirname } from 'path';
import { Logger } from 'tslog';
import type { LeaderLockInfo } from './IKVClient.js';
const logger = new Logger({ name: 'LeaderDiscovery' });
interface LeaderDiscoveryConfig {
/** Path to META_CORE_VOLUME (e.g., /meta-core) */
metaCorePath: string;
/** Health check interval in ms (default: 5000) */
healthCheckInterval?: number;
/** Max consecutive failures before marking leader as lost (default: 3) */
maxFailures?: number;
}
export class LeaderDiscovery {
private config: Required<LeaderDiscoveryConfig>;
private lockFilePath: string;
private leaderInfo: LeaderLockInfo | null = null;
private healthCheckTimer: NodeJS.Timeout | null = null;
private fileWatcher: ReturnType<typeof watch> | null = null;
private isStarted = false;
private consecutiveFailures = 0;
// Event callbacks
private onLeaderFoundCallback?: (info: LeaderLockInfo) => void;
private onLeaderLostCallback?: () => void;
constructor(config: LeaderDiscoveryConfig) {
this.config = {
healthCheckInterval: 5000,
maxFailures: 3,
...config
};
this.lockFilePath = `${this.config.metaCorePath}/locks/kv-leader.info`;
}
/**
* Start watching for leader
*/
async start(): Promise<void> {
if (this.isStarted) {
logger.warn('LeaderDiscovery already started');
return;
}
logger.info(`Starting leader discovery, lock file: ${this.lockFilePath}`);
// Ensure lock directory exists (for file watching)
await this.ensureLockDir();
// Try to read existing leader info
await this.checkForLeader();
// Start file watcher for immediate detection of leader changes
this.startFileWatcher();
// Start health check for ongoing monitoring
this.startHealthCheck();
this.isStarted = true;
}
/**
* Stop watching
*/
async stop(): Promise<void> {
if (!this.isStarted) return;
logger.info('Stopping leader discovery');
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
if (this.fileWatcher) {
this.fileWatcher.close();
this.fileWatcher = null;
}
this.isStarted = false;
}
/**
* Ensure lock directory exists
*/
private async ensureLockDir(): Promise<void> {
const lockDir = dirname(this.lockFilePath);
try {
await fs.mkdir(lockDir, { recursive: true });
} catch (error: any) {
if (error.code !== 'EEXIST') {
logger.warn(`Could not create lock directory: ${error.message}`);
}
}
}
/**
* Check for leader by reading lock file
*/
private async checkForLeader(): Promise<void> {
try {
const content = await fs.readFile(this.lockFilePath, 'utf-8');
const info = JSON.parse(content) as LeaderLockInfo;
// Validate leader info
if (info && info.api && info.http) {
const isNew = !this.leaderInfo || this.leaderInfo.api !== info.api;
this.leaderInfo = info;
this.consecutiveFailures = 0;
if (isNew) {
logger.info(`Leader found: ${info.host} at ${info.api}`);
this.onLeaderFoundCallback?.(info);
}
}
} catch (error: any) {
if (error.code === 'ENOENT') {
// Lock file doesn't exist - no leader yet
if (this.leaderInfo) {
logger.warn('Lock file removed, leader lost');
this.handleLeaderLost();
}
} else {
logger.warn(`Error reading lock file: ${error.message}`);
}
}
}
/**
* Check if current leader is still healthy
*/
private async isLeaderHealthy(): Promise<boolean> {
if (!this.leaderInfo) return false;
try {
const response = await fetch(`${this.leaderInfo.http}/health`, {
signal: AbortSignal.timeout(5000)
});
return response.ok;
} catch {
return false;
}
}
/**
* Handle leader being lost
*/
private handleLeaderLost(): void {
this.leaderInfo = null;
this.onLeaderLostCallback?.();
}
/**
* Start watching lock file for changes
*/
private startFileWatcher(): void {
try {
const lockDir = dirname(this.lockFilePath);
this.fileWatcher = watch(lockDir, async (eventType, filename) => {
if (filename === 'kv-leader.info') {
logger.debug(`Lock file ${eventType}, checking for leader...`);
await this.checkForLeader();
}
});
this.fileWatcher.on('error', (error) => {
logger.warn(`File watcher error: ${error.message}`);
});
} catch (error: any) {
logger.warn(`Could not start file watcher: ${error.message}`);
}
}
/**
* Start health check loop
*/
private startHealthCheck(): void {
this.healthCheckTimer = setInterval(async () => {
if (!this.leaderInfo) {
// No leader known, try to find one
await this.checkForLeader();
return;
}
// Check if current leader is healthy
const healthy = await this.isLeaderHealthy();
if (healthy) {
this.consecutiveFailures = 0;
} else {
this.consecutiveFailures++;
logger.warn(`Leader health check failed (${this.consecutiveFailures}/${this.config.maxFailures})`);
if (this.consecutiveFailures >= this.config.maxFailures) {
logger.warn('Leader appears dead, marking as lost');
this.handleLeaderLost();
// Try to find new leader
await this.checkForLeader();
}
}
}, this.config.healthCheckInterval);
}
// ========================================================================
// Event Registration
// ========================================================================
/**
* Register callback for when leader is found/changed
*/
onLeaderFound(callback: (info: LeaderLockInfo) => void): this {
this.onLeaderFoundCallback = callback;
// If we already have leader info, call immediately
if (this.leaderInfo) {
callback(this.leaderInfo);
}
return this;
}
/**
* Register callback for when leader is lost
*/
onLeaderLost(callback: () => void): this {
this.onLeaderLostCallback = callback;
return this;
}
// ========================================================================
// Getters
// ========================================================================
/**
* Get current leader info
*/
getLeaderInfo(): LeaderLockInfo | null {
return this.leaderInfo;
}
/**
* Get Redis URL of current leader
*/
getRedisUrl(): string | null {
return this.leaderInfo?.api ?? null;
}
/**
* Check if a leader is known
*/
hasLeader(): boolean {
return this.leaderInfo !== null;
}
}
@@ -0,0 +1,511 @@
/**
* Redis Client for meta-fuse
* Reads metadata from Redis (written by meta-sort)
*
* Key features:
* - FILES_VOLUME path resolution (prepends /files/ to relative paths)
* - Parses meta-sort hash format into structured metadata
* - Redis pub/sub support for real-time updates
*/
import { Redis } from 'ioredis';
import { Logger } from 'tslog';
import type { IKVClient, KeyValuePair } from './IKVClient.js';
const logger = new Logger({ name: 'RedisClient' });
/**
* Full metadata structure including fields needed for virtual path computation
*/
export interface FileMetadata {
// Path information
sourcePath: string; // Full path: /files/media1/Movies/...
originalPath: string; // Relative path from meta-sort: media1/Movies/...
// File stats
size: number;
mtime: number;
ctime: number;
// Identification
hashId?: string; // midhash256 hash ID
// Title information (for folder organization)
title?: string;
titles?: { eng?: string; [key: string]: string | undefined };
originalTitle?: string;
fileName?: string;
// Series metadata
season?: number;
episode?: number;
extra?: boolean;
// Movie metadata
movieYear?: number;
year?: number;
// File type
fileType?: string; // 'video', 'subtitle', 'torrent', etc.
extension?: string;
// Version/variant info
version?: string;
subtitleLanguage?: string;
// Generic metadata container
[key: string]: unknown;
}
export interface RedisClientConfig {
url?: string;
prefix?: string;
filesVolume?: string;
reconnectInterval?: number;
}
export class RedisClient implements Partial<IKVClient> {
private client: Redis | null = null;
private subscriber: Redis | null = null;
private config: Required<RedisClientConfig>;
private isConnected = false;
private reconnectTimer: NodeJS.Timeout | null = null;
private subscriptions: Map<string, (message: string) => void> = new Map();
constructor(config: RedisClientConfig = {}) {
this.config = {
url: config.url ?? process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
prefix: config.prefix ?? process.env.REDIS_PREFIX ?? 'meta-sort:',
filesVolume: config.filesVolume ?? process.env.FILES_VOLUME ?? '/files',
reconnectInterval: config.reconnectInterval ?? 5000,
};
}
/**
* Connect to Redis
*/
async connect(): Promise<void> {
if (this.client && this.isConnected) {
return;
}
try {
this.client = new Redis(this.config.url, {
retryStrategy: (times: number) => {
if (times > 10) {
logger.warn('Max reconnection attempts reached');
return null;
}
return Math.min(times * 500, 5000);
},
maxRetriesPerRequest: 3,
});
this.client.on('connect', () => {
logger.info('Connected to Redis');
this.isConnected = true;
});
this.client.on('error', (err: Error) => {
logger.error('Redis error:', err.message);
this.isConnected = false;
});
this.client.on('close', () => {
logger.warn('Redis connection closed');
this.isConnected = false;
this.scheduleReconnect();
});
// Wait for connection
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Redis connection timeout'));
}, 10000);
this.client!.once('ready', () => {
clearTimeout(timeout);
this.isConnected = true;
resolve();
});
this.client!.once('error', (err: Error) => {
clearTimeout(timeout);
reject(err);
});
});
} catch (error) {
logger.error('Failed to connect to Redis:', error);
this.scheduleReconnect();
throw error;
}
}
/**
* Schedule reconnection attempt
*/
private scheduleReconnect(): void {
if (this.reconnectTimer) {
return;
}
this.reconnectTimer = setTimeout(async () => {
this.reconnectTimer = null;
try {
await this.connect();
} catch {
// Will retry via scheduleReconnect
}
}, this.config.reconnectInterval);
}
/**
* Disconnect from Redis
*/
async disconnect(): Promise<void> {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.subscriber) {
await this.subscriber.quit();
this.subscriber = null;
}
if (this.client) {
await this.client.quit();
this.client = null;
this.isConnected = false;
}
this.subscriptions.clear();
}
/**
* Check if connected
*/
get connected(): boolean {
return this.isConnected;
}
/**
* Get all file metadata from Redis
*/
async getAllFiles(): Promise<Map<string, FileMetadata>> {
const files = new Map<string, FileMetadata>();
if (!this.client || !this.isConnected) {
logger.warn('Redis not connected, returning empty file list');
return files;
}
try {
// Get all file keys using the index
const indexKey = `${this.config.prefix}file:__index__`;
const hashIds = await this.client.smembers(indexKey);
if (hashIds.length === 0) {
// Fallback: scan for file keys directly
const pattern = `${this.config.prefix}file:*`;
const keys = await this.scanKeys(pattern);
logger.debug(`Found ${keys.length} file keys in Redis (via scan)`);
for (const key of keys) {
if (key.includes('__index__')) continue;
try {
const data = await this.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
const hashId = key.replace(`${this.config.prefix}file:`, '');
const metadata = this.parseMetadata(data, hashId);
files.set(metadata.sourcePath, metadata);
}
} catch (err) {
logger.warn(`Failed to get metadata for key ${key}:`, err);
}
}
} else {
logger.debug(`Found ${hashIds.length} files in index`);
// Fetch metadata for each hash ID
for (const hashId of hashIds) {
try {
const key = `${this.config.prefix}file:${hashId}`;
const data = await this.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
const metadata = this.parseMetadata(data, hashId);
files.set(metadata.sourcePath, metadata);
}
} catch (err) {
logger.warn(`Failed to get metadata for hashId ${hashId}:`, err);
}
}
}
logger.info(`Loaded ${files.size} files from Redis`);
} catch (error) {
logger.error('Failed to get files from Redis:', error);
}
return files;
}
/**
* Get file metadata by hash ID
*/
async getFileByHashId(hashId: string): Promise<FileMetadata | null> {
if (!this.client || !this.isConnected) {
return null;
}
try {
const key = `${this.config.prefix}file:${hashId}`;
const data = await this.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
return this.parseMetadata(data, hashId);
}
return null;
} catch (error) {
logger.error(`Failed to get file ${hashId}:`, error);
return null;
}
}
/**
* Get all hash IDs from Redis index
*/
async getAllHashIds(): Promise<string[]> {
if (!this.client || !this.isConnected) {
return [];
}
try {
const indexKey = `${this.config.prefix}file:__index__`;
return await this.client.smembers(indexKey);
} catch (error) {
logger.error('Failed to get hash IDs:', error);
return [];
}
}
/**
* Scan Redis keys matching pattern
*/
private async scanKeys(pattern: string): Promise<string[]> {
if (!this.client) return [];
const keys: string[] = [];
let cursor = '0';
do {
const [newCursor, batch] = await this.client.scan(cursor, 'MATCH', pattern, 'COUNT', 1000);
cursor = newCursor;
keys.push(...batch);
} while (cursor !== '0');
return keys;
}
/**
* Parse metadata from Redis hash
* Handles meta-sort format and resolves paths with FILES_VOLUME
*/
private parseMetadata(data: Record<string, string>, hashId?: string): FileMetadata {
// Get the original path (relative to FILES_VOLUME)
const originalPath = data.filePath ?? data.sourcePath ?? '';
// Resolve full source path by prepending FILES_VOLUME
let sourcePath = originalPath;
if (originalPath && !originalPath.startsWith('/')) {
// Relative path - prepend FILES_VOLUME
sourcePath = `${this.config.filesVolume}/${originalPath}`;
} else if (!originalPath.startsWith(this.config.filesVolume) && originalPath.startsWith('/')) {
// Absolute path but not in FILES_VOLUME - still prepend
sourcePath = `${this.config.filesVolume}${originalPath}`;
}
// Parse size
const size = parseInt(data.fileSize ?? data.size ?? data.sizeByte ?? '0', 10);
// Parse timestamps
let mtime = 0;
if (data.mtime) {
const parsed = Date.parse(data.mtime);
mtime = isNaN(parsed) ? parseFloat(data.mtime) : parsed;
}
// Parse titles - can be nested object or simple string
let titles: { eng?: string; [key: string]: string | undefined } | undefined;
if (data['titles/eng']) {
titles = { eng: data['titles/eng'] };
}
// Parse season/episode (can be 0 for specials)
const season = data.season !== undefined ? parseInt(data.season, 10) : undefined;
const episode = data.episode !== undefined ? parseInt(data.episode, 10) : undefined;
// Parse year
const movieYear = data.movieYear ? parseInt(data.movieYear, 10) : undefined;
const year = data.year ? parseInt(data.year, 10) : movieYear;
return {
// Path information
sourcePath,
originalPath,
// File stats
size,
mtime,
ctime: data.ctime ? parseFloat(data.ctime) : mtime,
// Identification
hashId: hashId ?? data.cid_midhash256 ?? data.hashId,
// Title information
title: data.title,
titles,
originalTitle: data.originalTitle,
fileName: data.fileName,
// Series metadata
season,
episode,
extra: data.extra === 'true',
// Movie metadata
movieYear,
year,
// File type
fileType: data.fileType,
extension: data.extension,
// Version/variant
version: data.version,
subtitleLanguage: data.subtitleLanguage,
};
}
/**
* Subscribe to Redis pub/sub channel
*/
async subscribe(channel: string, callback: (message: string) => void): Promise<void> {
if (!this.client) {
throw new Error('Redis not connected');
}
// Create subscriber connection if not exists
if (!this.subscriber) {
this.subscriber = this.client.duplicate();
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Subscriber connection timeout')), 10000);
this.subscriber!.once('ready', () => {
clearTimeout(timeout);
resolve();
});
this.subscriber!.once('error', (err) => {
clearTimeout(timeout);
reject(err);
});
});
// Set up message handler
this.subscriber.on('message', (ch: string, message: string) => {
const handler = this.subscriptions.get(ch);
if (handler) {
try {
handler(message);
} catch (error: any) {
logger.error(`Error in subscription handler for ${ch}:`, error.message);
}
}
});
}
// Store callback and subscribe
this.subscriptions.set(channel, callback);
await this.subscriber.subscribe(channel);
logger.info(`Subscribed to channel: ${channel}`);
}
/**
* Unsubscribe from Redis pub/sub channel
*/
async unsubscribe(channel: string): Promise<void> {
if (this.subscriber) {
await this.subscriber.unsubscribe(channel);
this.subscriptions.delete(channel);
logger.info(`Unsubscribed from channel: ${channel}`);
}
}
/**
* Get stats about Redis data
*/
async getStats(): Promise<{ totalKeys: number; fileCount: number; connected: boolean }> {
if (!this.client || !this.isConnected) {
return { totalKeys: 0, fileCount: 0, connected: false };
}
try {
// Try index first
const indexKey = `${this.config.prefix}file:__index__`;
const count = await this.client.scard(indexKey);
if (count > 0) {
return {
totalKeys: count,
fileCount: count,
connected: true,
};
}
// Fallback to scanning
const filePattern = `${this.config.prefix}file:*`;
const fileKeys = await this.scanKeys(filePattern);
const actualFileKeys = fileKeys.filter(k => !k.includes('__index__'));
return {
totalKeys: actualFileKeys.length,
fileCount: actualFileKeys.length,
connected: true,
};
} catch {
return { totalKeys: 0, fileCount: 0, connected: false };
}
}
/**
* Health check
*/
async health(): Promise<boolean> {
if (!this.client || !this.isConnected) {
return false;
}
try {
const result = await this.client.ping();
return result === 'PONG';
} catch {
return false;
}
}
/**
* Close connections
*/
async close(): Promise<void> {
await this.disconnect();
}
/**
* Get the raw ioredis client for direct access if needed
* Use with caution - prefer using the typed methods above
*/
getRawClient(): Redis | null {
return this.client;
}
}
@@ -0,0 +1,306 @@
/**
* Service Discovery using shared filesystem
*
* Each service registers itself in /meta-core/services/{service-name}.json
* with its API endpoint, status, and heartbeat timestamp.
*
* Services can discover each other by reading these JSON files.
* Stale detection: if lastHeartbeat > 60 seconds ago, service is considered dead.
*/
import { promises as fs } from 'fs';
import { hostname } from 'os';
import { join } from 'path';
import { Logger } from 'tslog';
import type { ServiceInfo } from './IKVClient.js';
const logger = new Logger({ name: 'ServiceDiscovery' });
interface ServiceDiscoveryConfig {
/** Path to META_CORE_VOLUME (e.g., /meta-core) */
metaCorePath: string;
/** Service name */
serviceName: string;
/** Service version */
version: string;
/** HTTP API URL (can be overridden by baseUrl) */
apiUrl?: string;
/** Base URL for service discovery (overrides auto-detected URL) */
baseUrl?: string;
/** Service capabilities */
capabilities?: string[];
/** Named endpoints */
endpoints?: Record<string, string>;
/** Heartbeat interval in ms (default: 30000) */
heartbeatInterval?: number;
/** Stale threshold in ms (default: 60000) */
staleThreshold?: number;
}
export class ServiceDiscovery {
private config: ServiceDiscoveryConfig;
private servicesDir: string;
private serviceFilePath: string;
private heartbeatTimer: NodeJS.Timeout | null = null;
private isShuttingDown = false;
private isStarted = false;
constructor(config: ServiceDiscoveryConfig) {
this.config = {
heartbeatInterval: 30000,
staleThreshold: 60000,
capabilities: [],
endpoints: {},
...config
};
this.servicesDir = `${this.config.metaCorePath}/services`;
this.serviceFilePath = `${this.servicesDir}/${this.config.serviceName}.json`;
}
/**
* Ensure services directory exists
*/
private async ensureServicesDir(): Promise<void> {
await fs.mkdir(this.servicesDir, { recursive: true });
}
/**
* Build service info for this service
*/
private buildServiceInfo(status: ServiceInfo['status']): ServiceInfo {
return {
name: this.config.serviceName,
version: this.config.version,
api: this.config.baseUrl || this.config.apiUrl || '',
status,
pid: process.pid,
hostname: hostname(),
startedAt: new Date().toISOString(),
lastHeartbeat: new Date().toISOString(),
capabilities: this.config.capabilities || [],
endpoints: this.config.endpoints || {}
};
}
/**
* Register this service
*/
async register(): Promise<void> {
await this.ensureServicesDir();
const info = this.buildServiceInfo('starting');
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
logger.info(`Registered ${this.config.serviceName}`);
}
/**
* Update service status
*/
async updateStatus(status: ServiceInfo['status']): Promise<void> {
try {
const content = await fs.readFile(this.serviceFilePath, 'utf-8');
const info = JSON.parse(content) as ServiceInfo;
info.status = status;
info.lastHeartbeat = new Date().toISOString();
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
} catch (error) {
logger.error('Failed to update status:', error);
}
}
/**
* Send heartbeat (update lastHeartbeat timestamp)
*/
async heartbeat(): Promise<void> {
try {
const content = await fs.readFile(this.serviceFilePath, 'utf-8');
const info = JSON.parse(content) as ServiceInfo;
info.lastHeartbeat = new Date().toISOString();
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
} catch (error: any) {
// If file was deleted, re-register
if (error.code === 'ENOENT') {
await this.register();
await this.updateStatus('running');
} else {
logger.error('Heartbeat failed:', error);
}
}
}
/**
* Start heartbeat loop
*/
startHeartbeat(): void {
this.heartbeatTimer = setInterval(async () => {
if (this.isShuttingDown) return;
await this.heartbeat();
}, this.config.heartbeatInterval);
}
/**
* Stop heartbeat loop
*/
stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
/**
* Unregister this service (on shutdown)
*/
async unregister(): Promise<void> {
this.isShuttingDown = true;
this.stopHeartbeat();
try {
// Update status to stopped before removing
await this.updateStatus('stopped');
logger.info(`Unregistered ${this.config.serviceName}`);
} catch (error) {
logger.error('Failed to unregister:', error);
}
}
// ========================================================================
// Discovery Methods
// ========================================================================
/**
* Discover a service by name
*/
async discoverService(name: string): Promise<ServiceInfo | null> {
const servicePath = join(this.servicesDir, `${name}.json`);
try {
const content = await fs.readFile(servicePath, 'utf-8');
const service = JSON.parse(content) as ServiceInfo;
// Check if service is alive (heartbeat within threshold)
const lastHeartbeat = new Date(service.lastHeartbeat).getTime();
const isAlive = Date.now() - lastHeartbeat < this.config.staleThreshold!;
if (!isAlive) {
logger.warn(`Service ${name} appears stale (last heartbeat: ${service.lastHeartbeat})`);
return null;
}
return service;
} catch (error: any) {
if (error.code === 'ENOENT') {
return null; // Service not registered
}
throw error;
}
}
/**
* Discover all registered services
*/
async discoverAllServices(): Promise<ServiceInfo[]> {
try {
const files = await fs.readdir(this.servicesDir);
const services: ServiceInfo[] = [];
for (const file of files) {
if (!file.endsWith('.json')) continue;
const name = file.replace('.json', '');
const service = await this.discoverService(name);
if (service && service.status === 'running') {
services.push(service);
}
}
return services;
} catch (error: any) {
if (error.code === 'ENOENT') {
return []; // Services directory doesn't exist yet
}
throw error;
}
}
/**
* Check if a service is healthy
*/
async isServiceHealthy(name: string): Promise<boolean> {
const service = await this.discoverService(name);
if (!service) return false;
// Try to ping the service's health endpoint
const healthEndpoint = service.endpoints.health || '/health';
const url = `${service.api}${healthEndpoint}`;
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(5000)
});
return response.ok;
} catch {
return false;
}
}
// ========================================================================
// Lifecycle
// ========================================================================
/**
* Full startup sequence
*/
async start(): Promise<void> {
if (this.isStarted) return;
await this.register();
await this.updateStatus('running');
this.startHeartbeat();
this.isStarted = true;
logger.info(`Started ${this.config.serviceName} at ${this.config.baseUrl || this.config.apiUrl}`);
}
/**
* Full shutdown sequence
*/
async stop(): Promise<void> {
if (!this.isStarted) return;
await this.unregister();
this.isStarted = false;
}
// ========================================================================
// Getters
// ========================================================================
getServiceFilePath(): string {
return this.serviceFilePath;
}
getServicesDir(): string {
return this.servicesDir;
}
getIsStarted(): boolean {
return this.isStarted;
}
}
@@ -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;
}