initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user