new flat KV structure

This commit is contained in:
Pierre
2026-02-08 21:54:02 +01:00
parent 04add9eb94
commit 4efe2b34ee
5 changed files with 1140 additions and 127 deletions
@@ -0,0 +1,163 @@
/**
* Rules Property Extractor
*
* Extracts the set of metadata properties that are relevant for VFS
* virtual path computation. This is used to filter which Redis key
* change events should trigger VFS updates.
*
* Properties are extracted from:
* 1. Rule templates: {title|originalTitle}, {season:pad2}, etc.
* 2. Rule conditions: { field: 'fileType', value: 'video' }, etc.
*/
import { Logger } from 'tslog';
import { TemplateEngine } from './template/TemplateEngine.js';
import type {
RenamingConfig,
RenamingRule,
Condition,
ConditionGroup,
} from './types/RenamingRuleTypes.js';
import { isConditionGroup } from './types/RenamingRuleTypes.js';
const logger = new Logger({ name: 'RulesPropertyExtractor' });
/**
* Core properties that are always required for VFS operation
* These are needed regardless of what rules are configured
*/
const CORE_PROPERTIES = new Set([
'filePath', // Source file location (must exist for file to appear in VFS)
'size', // File size attribute
'fileSize', // Alternative name for size
'mtime', // Modification time
'ctime', // Creation time
'fileName', // Filename for fallback naming
'extension', // File extension
]);
/**
* Extract all property names used in a ConditionGroup (recursively)
*/
function extractConditionFields(conditionOrGroup: Condition | ConditionGroup): string[] {
if (isConditionGroup(conditionOrGroup)) {
const fields: string[] = [];
for (const condition of conditionOrGroup.conditions) {
fields.push(...extractConditionFields(condition));
}
return fields;
}
// It's a Condition
return [conditionOrGroup.field];
}
/**
* Extract all property names from a single rule
*/
function extractRuleProperties(rule: RenamingRule, templateEngine: TemplateEngine): string[] {
const properties: string[] = [];
// Extract from template
const templateVars = templateEngine.extractVariables(rule.template);
properties.push(...templateVars);
// Extract from conditions
const conditionFields = extractConditionFields(rule.conditions);
properties.push(...conditionFields);
return properties;
}
/**
* Normalize a property path for comparison
* Handles nested paths like 'titles/eng' vs 'titles.eng'
*/
function normalizePropertyPath(path: string): string {
// Convert slash-separated to dot-separated
return path.replace(/\//g, '.');
}
/**
* Check if a property path matches any of the VFS-relevant properties
* Handles both exact matches and prefix matches for nested properties
*/
export function isVfsRelevantProperty(property: string, vfsProperties: Set<string>): boolean {
const normalized = normalizePropertyPath(property);
// Direct match
if (vfsProperties.has(normalized)) {
return true;
}
// Check if this is a nested property of a tracked property
// e.g., if we track 'titles', then 'titles.eng' or 'titles/eng' should match
for (const vfsProp of vfsProperties) {
const normalizedVfsProp = normalizePropertyPath(vfsProp);
// Check if the property starts with a tracked path
if (normalized.startsWith(normalizedVfsProp + '.')) {
return true;
}
// Check if a tracked path starts with this property
// e.g., if we track 'titles.eng' and get update for 'titles'
if (normalizedVfsProp.startsWith(normalized + '.')) {
return true;
}
}
return false;
}
/**
* RulesPropertyExtractor class
*
* Analyzes renaming rules configuration and extracts the set of
* metadata properties that are needed for VFS virtual path computation.
*/
export class RulesPropertyExtractor {
private templateEngine: TemplateEngine;
constructor() {
this.templateEngine = new TemplateEngine();
}
/**
* Extract all VFS-relevant properties from a RenamingConfig
*
* @param config The renaming configuration to analyze
* @returns Set of property names that affect VFS virtual paths
*/
extractVfsProperties(config: RenamingConfig): Set<string> {
const properties = new Set<string>(CORE_PROPERTIES);
// Extract from all rules
for (const rule of config.rules) {
if (!rule.enabled) continue;
const ruleProps = extractRuleProperties(rule, this.templateEngine);
for (const prop of ruleProps) {
properties.add(normalizePropertyPath(prop));
}
}
// Extract from default rule if present
if (config.defaultRule) {
const defaultProps = extractRuleProperties(config.defaultRule, this.templateEngine);
for (const prop of defaultProps) {
properties.add(normalizePropertyPath(prop));
}
}
logger.debug(`Extracted ${properties.size} VFS-relevant properties from rules config`);
return properties;
}
/**
* Get the core properties that are always required
*/
getCoreProperties(): Set<string> {
return new Set(CORE_PROPERTIES);
}
}
@@ -0,0 +1,338 @@
/**
* Streaming State Builder
*
* Manages file metadata state by processing events from the meta:events stream.
* Each event triggers a GET for the changed property, building state incrementally.
*
* This replaces the batch HGETALL/SCAN approach with a streaming model:
* 1. On startup: Replay meta:events from position 0 to rebuild state
* 2. Live: Continue consuming new events for real-time updates
*/
import { Logger } from 'tslog';
import { RedisClient, StreamMessage, ParsedMetaEventKey } from '../kv/RedisClient.js';
import { RulesPropertyExtractor, isVfsRelevantProperty } from './RulesPropertyExtractor.js';
import type { RenamingConfig } from './types/RenamingRuleTypes.js';
const logger = new Logger({ name: 'StreamingStateBuilder' });
/**
* File metadata state - map of property name to value
*/
export type FileState = Map<string, string>;
/**
* All files state - map of hashId to FileState
*/
export type FilesState = Map<string, FileState>;
/**
* Callback for VFS updates
*/
export interface VFSUpdateCallback {
onPropertyChange(hashId: string, property: string, value: string): void;
onPropertyDelete(hashId: string, property: string): void;
onFileDelete(hashId: string): void;
onFileComplete(hashId: string, metadata: Record<string, string>): void;
}
/**
* Configuration for StreamingStateBuilder
*/
export interface StreamingStateBuilderConfig {
/** Redis client for fetching property values */
redisClient: RedisClient;
/** Renaming rules config for extracting VFS-relevant properties */
rulesConfig: RenamingConfig;
/** VFS update callback */
vfsCallback?: VFSUpdateCallback;
/** FILES_VOLUME path prefix */
filesPath?: string;
}
/**
* StreamingStateBuilder class
*
* Builds and maintains file metadata state by processing stream events.
* Only fetches and stores VFS-relevant properties to minimize memory usage.
*/
export class StreamingStateBuilder {
private state: FilesState = new Map();
private vfsRelevantProps: Set<string>;
private redisClient: RedisClient;
private vfsCallback?: VFSUpdateCallback;
private filesPath: string;
private propertyExtractor: RulesPropertyExtractor;
// Stats
private stats = {
eventsProcessed: 0,
propertiesFetched: 0,
propertiesSkipped: 0,
filesComplete: 0,
lastEventId: '0',
};
constructor(config: StreamingStateBuilderConfig) {
this.redisClient = config.redisClient;
this.vfsCallback = config.vfsCallback;
this.filesPath = config.filesPath ?? '/files';
// Extract VFS-relevant properties from rules
this.propertyExtractor = new RulesPropertyExtractor();
this.vfsRelevantProps = this.propertyExtractor.extractVfsProperties(config.rulesConfig);
logger.info(`StreamingStateBuilder initialized with ${this.vfsRelevantProps.size} VFS-relevant properties`);
logger.debug('VFS-relevant properties:', Array.from(this.vfsRelevantProps).join(', '));
}
/**
* Set the VFS update callback
*/
setVFSCallback(callback: VFSUpdateCallback): void {
this.vfsCallback = callback;
}
/**
* Update rules config and re-extract VFS-relevant properties
*/
updateRulesConfig(rulesConfig: RenamingConfig): void {
this.vfsRelevantProps = this.propertyExtractor.extractVfsProperties(rulesConfig);
logger.info(`Updated VFS-relevant properties: ${this.vfsRelevantProps.size}`);
}
/**
* Process a stream message event
* This is the main entry point for event processing
*/
async processEvent(message: StreamMessage): Promise<void> {
this.stats.eventsProcessed++;
this.stats.lastEventId = message.id;
// Handle new meta:events format (set/del with key field)
if ((message.type === 'set' || message.type === 'del') && message.key) {
await this.processMetaEvent(message);
return;
}
// Ignore legacy event types - they're not supported in flat key architecture
// Legacy events used path/payload format which doesn't apply to flat keys
logger.debug(`Ignoring legacy event type: ${message.type}`);
}
/**
* Process a meta:events format event (set/del with key)
*/
private async processMetaEvent(message: StreamMessage): Promise<void> {
const parsed = this.redisClient.parseMetaEventKey(message.key!);
if (!parsed) {
// Not a file metadata key (could be index or other key)
return;
}
const { hashId, property } = parsed;
if (message.type === 'del') {
this.handlePropertyDelete(hashId, property);
return;
}
// message.type === 'set'
await this.handlePropertySet(hashId, property);
}
/**
* Handle a property set event
* Fetches the property value if VFS-relevant and updates state
*/
private async handlePropertySet(hashId: string, property: string): Promise<void> {
// Check if this property is VFS-relevant
if (!isVfsRelevantProperty(property, this.vfsRelevantProps)) {
this.stats.propertiesSkipped++;
return;
}
// Fetch the property value from Redis
const value = await this.redisClient.getProperty(hashId, property);
if (value === null) {
// Property was set then deleted before we could fetch it
// Or it's a different data type (shouldn't happen in flat key architecture)
return;
}
this.stats.propertiesFetched++;
// Check if file was complete BEFORE updating state
const wasComplete = this.isFileComplete(hashId);
// Update internal state
this.updateProperty(hashId, property, value);
// Notify VFS of changes
if (this.vfsCallback) {
this.vfsCallback.onPropertyChange(hashId, property, value);
// If file just became complete (has filePath now), notify VFS
if (!wasComplete && this.isFileComplete(hashId)) {
this.stats.filesComplete++;
const metadata = this.getFileMetadata(hashId);
if (metadata) {
this.vfsCallback.onFileComplete(hashId, metadata);
}
} else if (wasComplete) {
// File was already complete, but properties changed - update VFS
// This handles cases where title, season, etc. arrive after filePath
const metadata = this.getFileMetadata(hashId);
if (metadata) {
this.vfsCallback.onFileComplete(hashId, metadata);
}
}
}
}
/**
* Handle a property delete event
*/
private handlePropertyDelete(hashId: string, property: string): void {
const fileState = this.state.get(hashId);
if (!fileState) {
return;
}
// Remove the property
fileState.delete(property);
// If this was the filePath, the file is no longer in VFS
if (property === 'filePath') {
this.state.delete(hashId);
if (this.vfsCallback) {
this.vfsCallback.onFileDelete(hashId);
}
return;
}
// If file has no more properties, remove it
if (fileState.size === 0) {
this.state.delete(hashId);
if (this.vfsCallback) {
this.vfsCallback.onFileDelete(hashId);
}
return;
}
// Notify VFS of property deletion
if (this.vfsCallback) {
this.vfsCallback.onPropertyDelete(hashId, property);
}
}
/**
* Update a property in the state
*/
private updateProperty(hashId: string, property: string, value: string): void {
let fileState = this.state.get(hashId);
if (!fileState) {
fileState = new Map();
this.state.set(hashId, fileState);
}
fileState.set(property, value);
}
/**
* Check if a file has filePath (required to appear in VFS)
*/
private isFileComplete(hashId: string): boolean {
const fileState = this.state.get(hashId);
return fileState?.has('filePath') ?? false;
}
/**
* Get metadata for a single file
*/
getFileMetadata(hashId: string): Record<string, string> | null {
const fileState = this.state.get(hashId);
if (!fileState) {
return null;
}
return Object.fromEntries(fileState);
}
/**
* Get all files with their metadata
* Only returns files that are "complete" (have filePath)
*/
getAllFiles(): Map<string, Record<string, string>> {
const result = new Map<string, Record<string, string>>();
for (const [hashId, fileState] of this.state) {
if (fileState.has('filePath')) {
result.set(hashId, Object.fromEntries(fileState));
}
}
return result;
}
/**
* Get file count (complete files only)
*/
getFileCount(): number {
let count = 0;
for (const fileState of this.state.values()) {
if (fileState.has('filePath')) {
count++;
}
}
return count;
}
/**
* Get statistics
*/
getStats(): typeof this.stats & { fileCount: number; stateSize: number } {
return {
...this.stats,
fileCount: this.getFileCount(),
stateSize: this.state.size,
};
}
/**
* Clear all state (for reset)
*/
clear(): void {
this.state.clear();
this.stats = {
eventsProcessed: 0,
propertiesFetched: 0,
propertiesSkipped: 0,
filesComplete: 0,
lastEventId: '0',
};
logger.info('State cleared');
}
/**
* Get the last processed event ID
*/
getLastEventId(): string {
return this.stats.lastEventId;
}
/**
* Resolve source path from filePath property
* Prepends FILES_VOLUME if needed
*/
resolveSourcePath(filePath: string): string {
if (filePath.startsWith('/')) {
// Already absolute
if (filePath.startsWith(this.filesPath)) {
return filePath;
}
// Absolute but not in filesPath - prepend
return `${this.filesPath}${filePath}`;
}
// Relative path - prepend filesPath
return `${this.filesPath}/${filePath}`;
}
}
@@ -15,6 +15,7 @@ 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';
import type { VFSUpdateCallback } from './StreamingStateBuilder.js';
const logger = new Logger({ name: 'VirtualFileSystem' });
@@ -58,9 +59,10 @@ export interface VFSConfig {
filesPath?: string; // Local files path prefix to strip when building WebDAV URLs
}
export class VirtualFileSystem {
export class VirtualFileSystem implements VFSUpdateCallback {
private nodes: Map<string, VFSNode> = new Map();
private sourcePathToVirtualPath: Map<string, string> = new Map();
private hashIdToVirtualPath: Map<string, string> = new Map(); // Track hashId -> virtualPath for streaming updates
private redisClient: RedisClient;
private configStorage: ConfigStorage;
private metaDataToFolderStruct: MetaDataToFolderStruct;
@@ -69,6 +71,7 @@ export class VirtualFileSystem {
private rulesConfig: RenamingConfig | null = null;
private config: Required<VFSConfig>;
private lastRefresh: Date | null = null;
private useStreamingMode: boolean = false; // Flag to indicate streaming mode
private cachedStats = {
fileCount: 0,
@@ -106,6 +109,7 @@ export class VirtualFileSystem {
private initRoot(): void {
this.nodes.clear();
this.sourcePathToVirtualPath.clear();
this.hashIdToVirtualPath.clear();
this.nodes.set('/', {
type: 'directory',
name: '',
@@ -115,17 +119,125 @@ export class VirtualFileSystem {
this.cachedStats = { fileCount: 0, directoryCount: 1, totalSize: 0 };
}
/**
* Enable streaming mode
* In streaming mode, VFS is populated via event callbacks instead of refresh()
*/
enableStreamingMode(): void {
this.useStreamingMode = true;
logger.info('VFS streaming mode enabled');
}
/**
* Convert flat properties map to FileMetadata
* Used when processing streaming events
*
* Note: During migration, not all metadata may be in flat key format yet.
* We extract what we can from filePath as a fallback.
*/
private flatPropertiesToMetadata(props: Record<string, string>, hashId: string): FileMetadata {
// Get the original path (relative to FILES_VOLUME)
const originalPath = props.filePath ?? '';
// Resolve full source path by prepending FILES_VOLUME
let sourcePath = originalPath;
if (originalPath && !originalPath.startsWith('/')) {
sourcePath = `${this.config.filesPath}/${originalPath}`;
} else if (!originalPath.startsWith(this.config.filesPath) && originalPath.startsWith('/')) {
sourcePath = `${this.config.filesPath}${originalPath}`;
}
// Extract filename from path as fallback
const pathFileName = sourcePath ? path.basename(sourcePath) : undefined;
const fileName = props.fileName ?? pathFileName;
// Extract extension from filename
const extension = props.extension ?? (pathFileName ? path.extname(pathFileName).slice(1) : undefined);
// Parse size
const size = parseInt(props.fileSize ?? props.size ?? props.sizeByte ?? '0', 10);
// Parse timestamps
let mtime = 0;
if (props.mtime) {
const parsed = Date.parse(props.mtime);
mtime = isNaN(parsed) ? parseFloat(props.mtime) : parsed;
}
// Parse titles - can be from nested paths like 'titles/eng'
let titles: { eng?: string; [key: string]: string | undefined } | undefined;
if (props['titles/eng']) {
titles = { eng: props['titles/eng'] };
}
// Parse season/episode
const season = props.season !== undefined ? parseInt(props.season, 10) : undefined;
const episode = props.episode !== undefined ? parseInt(props.episode, 10) : undefined;
// Parse year
const movieYear = props.movieYear ? parseInt(props.movieYear, 10) : undefined;
const year = props.year ? parseInt(props.year, 10) : movieYear;
// Infer fileType from extension if not provided
let fileType = props.fileType;
if (!fileType && extension) {
const videoExts = ['mkv', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'];
const subtitleExts = ['srt', 'ass', 'ssa', 'sub', 'idx', 'vtt'];
const torrentExts = ['torrent'];
if (videoExts.includes(extension.toLowerCase())) {
fileType = 'video';
} else if (subtitleExts.includes(extension.toLowerCase())) {
fileType = 'subtitle';
} else if (torrentExts.includes(extension.toLowerCase())) {
fileType = 'torrent';
}
}
return {
sourcePath,
originalPath,
size,
mtime,
ctime: props.ctime ? parseFloat(props.ctime) : mtime,
hashId,
title: props.title,
titles,
originalTitle: props.originalTitle,
fileName,
season,
episode,
extra: props.extra === 'true',
movieYear,
year,
fileType,
extension,
version: props.version,
subtitleLanguage: props.subtitleLanguage,
};
}
/**
* Start the VFS (connect to Redis and load data)
* Initial bootstrap only - updates come via Redis Streams
*
* In legacy mode: Does initial load from Redis using HGETALL/SCAN
* In streaming mode: Just initializes, data comes via StreamingStateBuilder events
*/
async start(): Promise<void> {
logger.info('Starting VirtualFileSystem');
// Initial load from Redis (bootstrap)
await this.refresh();
// Load rules configuration
this.loadRulesConfig();
logger.info('VirtualFileSystem started (updates via Redis Streams)');
if (this.useStreamingMode) {
// In streaming mode, VFS is populated via event callbacks
// StreamingStateBuilder will call onFileComplete for each file
logger.info('VirtualFileSystem started in streaming mode (data via events)');
} else {
// Legacy mode: Initial load from Redis (bootstrap)
await this.refresh();
logger.info('VirtualFileSystem started in legacy mode (updates via Redis Streams)');
}
}
/**
@@ -903,4 +1015,151 @@ export class VirtualFileSystem {
return null;
}
// ============================================
// VFSUpdateCallback Implementation
// (for streaming state builder integration)
// ============================================
/**
* Handle a property change event from the streaming state builder
* This is called for each VFS-relevant property that changes
*
* In streaming mode, we don't immediately update the VFS on every property change.
* Instead, we wait for onFileComplete which has the full metadata.
*/
onPropertyChange(hashId: string, property: string, value: string): void {
// In streaming mode, individual property changes don't trigger VFS updates
// The VFS is updated when onFileComplete is called with full metadata
logger.debug(`Property changed for ${hashId}: ${property}=${value.substring(0, 50)}...`);
}
/**
* Handle a property delete event from the streaming state builder
*/
onPropertyDelete(hashId: string, property: string): void {
logger.debug(`Property deleted for ${hashId}: ${property}`);
// If filePath was deleted, the file should be removed from VFS
// This is handled by onFileDelete, so we just log here
}
/**
* Handle file deletion from the streaming state builder
* Called when filePath is deleted or all properties are removed
*/
onFileDelete(hashId: string): void {
const virtualPath = this.hashIdToVirtualPath.get(hashId);
if (virtualPath) {
this.removeFile(virtualPath);
this.hashIdToVirtualPath.delete(hashId);
logger.debug(`File deleted from VFS: ${hashId} -> ${virtualPath}`);
}
}
/**
* Handle file complete event from the streaming state builder
* Called when a file has filePath and enough metadata to compute virtual path
*
* @param hashId - The file hash ID
* @param metadata - Flat properties map from the streaming state builder
*/
onFileComplete(hashId: string, metadata: Record<string, string>): void {
// Load rules if not loaded
if (!this.rulesConfig) {
this.loadRulesConfig();
}
// Convert flat properties to FileMetadata
const fileMetadata = this.flatPropertiesToMetadata(metadata, hashId);
if (!fileMetadata.sourcePath) {
logger.warn(`onFileComplete: No sourcePath for ${hashId}`);
return;
}
// Compute virtual path
const virtualPath = this.computeVirtualPath(fileMetadata, fileMetadata.sourcePath);
if (!virtualPath) {
logger.debug(`Could not compute virtual path for: ${fileMetadata.sourcePath}`);
return;
}
// If file already exists at a different virtual path, remove old entry
const oldVirtualPath = this.hashIdToVirtualPath.get(hashId);
if (oldVirtualPath && oldVirtualPath !== virtualPath) {
this.removeFile(oldVirtualPath);
}
// Add/update file in VFS
this.addFileSync(virtualPath, fileMetadata);
this.sourcePathToVirtualPath.set(fileMetadata.sourcePath, virtualPath);
this.hashIdToVirtualPath.set(hashId, virtualPath);
logger.debug(`File complete: ${hashId} -> ${virtualPath}`);
this.lastRefresh = new Date();
}
/**
* Synchronous version of addFile for streaming updates
* Used by onFileComplete which is called synchronously from event processing
*/
private addFileSync(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 from metadata
const size = metadata.size || 0;
const mtime = new Date(metadata.mtime || Date.now());
const ctime = new Date(metadata.ctime || metadata.mtime || Date.now());
// 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;
}
/**
* Update a file in VFS when its metadata changes
* Called when streaming events indicate a property change for an existing file
*
* @param hashId - The file hash ID
* @param metadata - Updated metadata properties
*/
updateFileFromStreaming(hashId: string, metadata: Record<string, string>): void {
// Just delegate to onFileComplete which handles both add and update
this.onFileComplete(hashId, metadata);
}
}