# Meta-Sort Plugin System Architecture This document describes the plugin system architecture for meta-sort, enabling extensible metadata extraction through dynamically loaded plugins. ## Overview The plugin system provides a modular architecture for metadata extraction. Plugins are self-contained units that: - Are discovered and loaded from a plugins directory at startup - Can be activated/deactivated at runtime without restart - Process files sequentially in dependency order - Store metadata in a flat key-value format - Have access to their own persistent cache folder ## Directory Structure ``` packages/meta-sort/packages/meta-sort-core/ ├── plugins/ # All plugins (shipped + custom) │ ├── file-info/ │ │ ├── manifest.yml # Plugin metadata & config schema │ │ └── index.ts # Plugin implementation │ ├── ffmpeg/ │ ├── filename-parser/ │ ├── anime-detector/ │ ├── jellyfin-nfo/ │ ├── subtitle/ │ ├── torrent/ │ ├── language/ │ ├── tmdb/ │ └── debug/ ├── plugins.json # Runtime state (active/inactive + configs) └── src/ └── plugin/ ├── types.ts # Type definitions ├── PluginLoader.ts # Manifest parsing, dynamic import ├── PluginContext.ts # Context factory ├── PluginManager.ts # Lifecycle management └── PluginExecutor.ts # Dependency-ordered execution ``` Cache folders are stored separately under `CACHE_FOLDER_PATH` (default `/data/cache/plugins/`): ``` /data/cache/plugins/ ├── ffmpeg/ │ ├── {cid1}.json │ └── {cid2}.json ├── tmdb/ │ ├── {cid1}.json │ └── {cid2}.json └── ... ``` ## Plugin Manifest Each plugin must have a `manifest.yml` file defining its metadata, dependencies, and configuration schema. ```yaml # manifest.yml id: tmdb # Unique identifier (matches folder name) name: TMDB Enricher # Human-readable name version: 1.0.0 # Semantic version description: Enriches metadata from The Movie Database API # Plugins that must execute BEFORE this one dependencies: - filename-parser # Needs parsed title/year # Configuration schema (rendered in dashboard) config: apiKey: type: string label: API Key required: true secret: true # Masked in UI language: type: string label: Language default: en-US includeAdult: type: boolean label: Include Adult Content default: false # Metadata schema for editor UI (optional) # Describes keys this plugin writes schema: tmdb/id: label: TMDB ID type: string readonly: true tmdb/title: label: Title type: string tmdb/overview: label: Overview type: text tmdb/poster: label: Poster URL type: string format: url tmdb/genre/{n}: label: Genre type: string indexed: true # Indicates indexed keys (0, 1, 2...) ``` ### Manifest Fields | Field | Required | Description | |-------|----------|-------------| | `id` | Yes | Unique plugin identifier, must match folder name | | `name` | Yes | Display name for dashboard | | `version` | Yes | Semantic version string | | `description` | No | Brief description | | `dependencies` | No | Array of plugin IDs that must run before this one | | `config` | No | Configuration schema for dashboard UI | | `schema` | No | Metadata keys written by this plugin (for editor) | ### Config Field Types ```yaml config: fieldName: type: string | number | boolean | select label: Display Label required: true | false # Default: false default: # Default value secret: true | false # Mask in UI (default: false) options: # For type: select - value: opt1 label: Option 1 - value: opt2 label: Option 2 ``` ## Plugin Interface Plugins export a default object implementing the `Plugin` interface. ```typescript // types.ts export interface Plugin { /** * Called once when the plugin is loaded at startup. * Use for one-time initialization. */ onLoad?(ctx: PluginLifecycleContext): Promise; /** * Called when the plugin is activated. * Triggered at startup (if previously active) or from dashboard. */ onActivate?(ctx: PluginLifecycleContext): Promise; /** * Called when the plugin is deactivated from the dashboard. * Use for cleanup, releasing resources. */ onDeactivate?(ctx: PluginLifecycleContext): Promise; /** * Called when the plugin is unloaded at shutdown. */ onUnload?(ctx: PluginLifecycleContext): Promise; /** * Process a single file. Called for each file in the library. * Must be implemented by all plugins. */ process(ctx: PluginContext): Promise; } export interface PluginLifecycleContext { /** Plugin configuration from plugins.json */ config: Record; /** Absolute path to plugin's cache directory */ cacheDir: string; /** Scoped logger */ log: PluginLogger; } ``` ## Plugin Context The `PluginContext` is injected into the `process()` function, providing access to file metadata and utilities. ```typescript export interface PluginContext { /** * Key-value storage for the current file's metadata. * Keys use path notation: "video/codec", "audio/0/language" */ kv: PluginKVStore; /** Absolute path to the file being processed */ filePath: string; /** Plugin configuration values */ config: Record; /** Absolute path to plugin's cache directory */ cacheDir: string; /** Cache utilities for persistent storage */ cache: PluginCache; /** Scoped logger (prefixed with plugin name) */ log: PluginLogger; } ``` ### KV Store Interface ```typescript export interface PluginKVStore { /** * Get a value by key. * @param key Path-style key, e.g., "video/codec" or "audio/0/language" */ get(key: string): string | undefined; /** * Set a value. Only string values are supported. * @param key Path-style key * @param value String value */ set(key: string, value: string): void; /** * Delete a key. */ delete(key: string): void; /** * Get all keys, optionally filtered by prefix. * @param prefix Optional prefix filter, e.g., "audio/" returns ["audio/0/codec", "audio/0/language", ...] */ keys(prefix?: string): string[]; /** * Get all key-value pairs, optionally filtered by prefix. */ entries(prefix?: string): Array<[string, string]>; } ``` ### Cache Interface ```typescript export interface PluginCache { /** * Get full path for a cache file. * @param filename Relative filename within cache directory */ getPath(filename: string): string; /** * Read and parse a JSON cache file. * @returns Parsed data or null if not found */ readJson(filename: string): Promise; /** * Write data as JSON to cache file. */ writeJson(filename: string, data: unknown): Promise; /** * Check if a cache file exists. */ exists(filename: string): Promise; /** * Delete a cache file. */ delete(filename: string): Promise; /** * Clear all files in the plugin's cache directory. */ clear(): Promise; } ``` ### Logger Interface ```typescript export interface PluginLogger { debug(message: string, ...args: unknown[]): void; info(message: string, ...args: unknown[]): void; warn(message: string, ...args: unknown[]): void; error(message: string, ...args: unknown[]): void; } ``` ## Plugin Implementation Example ```typescript // plugins/tmdb/index.ts import type { Plugin, PluginContext, PluginLifecycleContext } from '../../src/plugin/types'; interface TmdbConfig { apiKey: string; language: string; includeAdult: boolean; } const plugin: Plugin = { async onLoad(ctx: PluginLifecycleContext) { ctx.log.info('TMDB plugin loaded'); }, async onActivate(ctx: PluginLifecycleContext) { const config = ctx.config as TmdbConfig; if (!config.apiKey) { ctx.log.warn('No API key configured, plugin will skip processing'); } }, async onDeactivate(ctx: PluginLifecycleContext) { ctx.log.info('TMDB plugin deactivated'); }, async process(ctx: PluginContext) { const config = ctx.config as TmdbConfig; // Skip if not configured if (!config.apiKey) return; // Get data from previous plugins const title = ctx.kv.get('filename/title'); const year = ctx.kv.get('filename/year'); const videoType = ctx.kv.get('video/type'); if (!title) return; // Check cache first (keyed by title+year) const cacheKey = `${sanitize(title)}-${year || 'unknown'}.json`; const cached = await ctx.cache.readJson(cacheKey); if (cached) { applyResult(ctx.kv, cached); return; } // Fetch from TMDB API try { const result = await fetchTmdb(title, year, videoType, config); if (result) { await ctx.cache.writeJson(cacheKey, result); applyResult(ctx.kv, result); } } catch (error) { ctx.log.error('TMDB fetch failed', error); } } }; function applyResult(kv: PluginContext['kv'], result: TmdbResult) { kv.set('tmdb/id', String(result.id)); kv.set('tmdb/title', result.title); kv.set('tmdb/overview', result.overview); kv.set('tmdb/poster', result.poster_path); kv.set('tmdb/backdrop', result.backdrop_path); kv.set('tmdb/rating', String(result.vote_average)); kv.set('tmdb/year', result.release_date?.slice(0, 4) || ''); // Genres as indexed keys result.genres?.forEach((genre, i) => { kv.set(`tmdb/genre/${i}`, genre.name); }); } export default plugin; ``` ## Metadata Key Format All metadata is stored as flat key-value pairs with string values only. Keys use path notation with `/` as separator. ### Key Naming Convention ``` {plugin-id}/{field} # Simple field {plugin-id}/{group}/{field} # Grouped field {plugin-id}/{group}/{index}/{field} # Indexed field (for lists) ``` ### Examples ``` # File info plugin file/name = "Movie.2024.1080p.mkv" file/size = "4294967296" file/type = "video" file/mime = "video/x-matroska" # FFmpeg plugin video/codec = "h264" video/width = "1920" video/height = "1080" video/duration = "7200.5" video/bitrate = "8000000" audio/0/codec = "aac" audio/0/language = "eng" audio/0/channels = "6" audio/1/codec = "dts" audio/1/language = "fra" audio/1/channels = "6" subtitle/0/language = "eng" subtitle/0/format = "subrip" subtitle/1/language = "fra" subtitle/1/format = "subrip" # Filename parser plugin filename/title = "Movie Title" filename/year = "2024" filename/quality = "1080p" filename/source = "bluray" # TMDB plugin tmdb/id = "12345" tmdb/title = "Movie Title" tmdb/overview = "A movie about..." tmdb/poster = "/path/to/poster.jpg" tmdb/genre/0 = "Action" tmdb/genre/1 = "Adventure" ``` ### Key Constraints - Keys must be non-empty strings - Keys use `/` as path separator - Values must be strings (numbers converted via `String()`) - No nested objects or arrays (use indexed keys instead) - Keys are case-sensitive - Recommended: use lowercase with hyphens for multi-word keys ## Plugin Manager The `PluginManager` handles plugin lifecycle and orchestration. ```typescript export class PluginManager extends EventEmitter { /** * Scan plugins directory and load manifests. * Called once at startup. */ async scanPlugins(): Promise; /** * Load all plugins and activate those marked active in state. * Called once at startup after scanning. */ async loadAll(): Promise; /** * Activate a plugin (hot, no restart required). * Calls plugin.onActivate(), rebuilds execution order, saves state. * Emits 'plugin:activated' event. */ async activate(pluginId: string): Promise; /** * Deactivate a plugin (hot, no restart required). * Calls plugin.onDeactivate(), rebuilds execution order, saves state. * Emits 'plugin:deactivated' event. */ async deactivate(pluginId: string): Promise; /** * Update plugin configuration. * Saves to plugins.json. * Emits 'plugin:config-changed' event. */ async updateConfig(pluginId: string, config: Record): Promise; /** * Clear a plugin's cache directory. */ async clearCache(pluginId: string): Promise; /** * Get all discovered plugins. */ getPlugins(): PluginInfo[]; /** * Get only active plugins. */ getActivePlugins(): PluginInfo[]; /** * Get execution order (dependency-sorted active plugin IDs). */ getExecutionOrder(): string[]; /** * Process a file through all active plugins. */ async processFile(filePath: string, kv: KVStore): Promise; /** * Shutdown: deactivate and unload all plugins. */ async shutdown(): Promise; } ``` ### Events | Event | Payload | Description | |-------|---------|-------------| | `plugin:activated` | `{ pluginId: string }` | Plugin was activated | | `plugin:deactivated` | `{ pluginId: string }` | Plugin was deactivated | | `plugin:config-changed` | `{ pluginId: string, config: object }` | Plugin config updated | | `plugin:error` | `{ pluginId: string, error: Error }` | Plugin error occurred | ### Plugin Info ```typescript export interface PluginInfo { id: string; name: string; version: string; description?: string; dependencies: string[]; active: boolean; config: Record; configSchema?: ConfigSchema; metadataSchema?: MetadataSchema; status: 'loaded' | 'error' | 'unloaded'; error?: string; } ``` ## State Management Plugin state is persisted in `plugins.json` located in the meta-sort-core package root. ```json { "plugins": { "file-info": { "active": true, "config": {} }, "ffmpeg": { "active": true, "config": {} }, "tmdb": { "active": true, "config": { "apiKey": "your-api-key", "language": "en-US", "includeAdult": false } }, "custom-plugin": { "active": false, "config": {} } } } ``` ### State Behavior - **New plugins**: Discovered plugins not in state file are added with `active: false` - **Removed plugins**: Plugins in state but not on disk are kept (preserves config for reinstall) - **Config merge**: Default values from manifest are merged with saved config - **Persistence**: State is saved immediately on any change ## Execution Pipeline ### Startup Sequence ``` 1. PluginManager.scanPlugins() └── Scan plugins/ directory └── Parse each manifest.yml └── Validate manifest structure 2. PluginManager.loadAll() └── Load plugins.json state └── For each discovered plugin: ├── Dynamic import(plugin/index.js) ├── Validate Plugin interface ├── Call plugin.onLoad() └── If active in state: └── Call plugin.onActivate() └── Resolve execution order (topological sort) ``` ### File Processing Sequence ``` 1. Create KVStore for file 2. For each active plugin (in dependency order): ├── Create PluginContext ├── Start timing ├── try: │ └── await plugin.process(ctx) └── catch: └── Log error, continue to next plugin 3. Persist KV to Redis ``` ### Activation Sequence (Hot) ``` 1. PluginManager.activate(pluginId) ├── Validate plugin exists and is loaded ├── Check dependencies are active ├── Call plugin.onActivate() ├── Mark as active in memory ├── Rebuild execution order ├── Save plugins.json └── Emit 'plugin:activated' event 2. Caller receives event └── Trigger library rescan ``` ### Deactivation Sequence (Hot) ``` 1. PluginManager.deactivate(pluginId) ├── Check no active plugins depend on this one ├── Call plugin.onDeactivate() ├── Mark as inactive in memory ├── Rebuild execution order ├── Save plugins.json └── Emit 'plugin:deactivated' event 2. Caller receives event └── Trigger library rescan Note: Metadata written by the plugin remains in Redis. A separate "clean metadata" feature can remove orphaned keys. ``` ## Dependency Resolution Plugins declare dependencies in their manifest. The system performs topological sort to determine execution order. ### Rules 1. A plugin cannot be activated if any of its dependencies are inactive 2. A plugin cannot be deactivated if any active plugin depends on it 3. Circular dependencies are detected and rejected at load time 4. Missing dependencies are logged as warnings; plugin can still load but may fail ### Example ```yaml # filename-parser depends on file-info # tmdb depends on filename-parser # anime-detector depends on filename-parser Resolved order: file-info → filename-parser → [tmdb, anime-detector] ``` Plugins at the same dependency level may execute in any order (or parallel in future). ## REST API The plugin system exposes REST endpoints for dashboard integration. ### Endpoints ``` GET /api/plugins # List all plugins GET /api/plugins/:id # Get plugin details POST /api/plugins/:id/activate # Activate plugin POST /api/plugins/:id/deactivate # Deactivate plugin PUT /api/plugins/:id/config # Update plugin config DELETE /api/plugins/:id/cache # Clear plugin cache GET /api/plugins/:id/schema # Get metadata schema (for editor) ``` ### Response Format ```typescript // GET /api/plugins interface PluginsResponse { plugins: PluginInfo[]; executionOrder: string[]; } // POST /api/plugins/:id/activate interface ActivateResponse { success: boolean; error?: string; # e.g., "Dependency 'x' is not active" } // PUT /api/plugins/:id/config interface ConfigUpdateRequest { config: Record; } ``` ## Error Handling ### Plugin Load Errors - Invalid manifest: Plugin marked as `status: 'error'`, logged, skipped - Import failure: Plugin marked as `status: 'error'`, logged, skipped - Missing dependencies: Warning logged, plugin can load but activation may fail ### Plugin Execution Errors - Errors in `process()` are caught, logged, and execution continues to next plugin - The file's metadata from previous plugins is preserved - Error count is tracked per plugin for monitoring ### Lifecycle Hook Errors - Errors in `onActivate()` prevent activation, error returned to caller - Errors in `onDeactivate()` are logged but deactivation proceeds - Errors in `onLoad()`/`onUnload()` are logged, plugin marked as error state ## Future Considerations ### Metadata Cleanup A future feature will allow cleaning metadata keys written by a specific plugin: ```typescript // Future API await pluginManager.cleanMetadata(pluginId); // Removes all keys matching "{pluginId}/*" from Redis ``` ### Parallel Execution Plugins at the same dependency level could execute in parallel: ```typescript // Future: parallel execution of independent plugins const level1 = ['file-info']; const level2 = ['ffmpeg', 'filename-parser']; // parallel const level3 = ['tmdb', 'anime-detector']; // parallel ``` ### Plugin Hot Reload Reloading a plugin's code without full restart (for development): ```typescript // Future API await pluginManager.reload(pluginId); // Re-imports plugin code, preserves state ```