import { Router, Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import safeRegex from 'safe-regex'; import rulesRepo from '../db/repositories/rules'; import settingsRepo from '../db/repositories/settings'; import collectionsRepo from '../db/repositories/collections'; import { getDatabase } from '../db/index'; import { CreateRuleSchema, UpdateRuleSchema, RuleCondition, MediaItem, Rule } from '../types'; import { evaluateNode, evaluateRuleConditions, upgradeToV2, type EvaluationContext, } from '../rules/engine'; import { buildEvaluationContext } from '../rules/context'; import { ruleScopeMatches } from '../rules/scope'; import { nodeUsesDiskPressure, ruleUsesDiskPressure } from '../rules/diskPressure'; import type { ConditionNode } from '../rules/types'; import type { RuleForecastInput } from '../services/forecast'; import { leavesOf } from '../rules/forecast'; import logger from '../utils/logger'; import { formatBytes } from '../utils/format'; import { toThumbnailUrl } from '../utils/posterUrl'; import { queueItemForDeletion, notifyItemsQueued, type QueuedMatch, } from '../scheduler/tasks'; import { logActivity } from '../db/repositories/activity'; import { loadInProgressConfig, isProtectedInProgress, usersInProgress } from '../rules/inProgress'; // ============================================================================ // V2 Condition Schema + Safe Regex Validation // ============================================================================ const REGEX_OPERATORS = new Set(['regex_match']); /** * Recursively validate a v2 condition tree, rejecting unsafe regex patterns. * Throws a ZodError-shaped object on the first unsafe pattern found. */ function validateConditionTree(node: unknown, path: string[] = []): void { if (!node || typeof node !== 'object') { throw new Error(`Invalid condition node at ${path.join('.') || 'root'}`); } const n = node as Record; if (n['kind'] === 'condition') { if (REGEX_OPERATORS.has(String(n['operator']))) { const pattern = String(n['value'] ?? ''); if (!safeRegex(pattern)) { throw new UnsafeRegexError(pattern, path); } } return; } if (n['kind'] === 'group' && Array.isArray(n['children'])) { if (path.length > 40) { throw new Error(`Condition tree exceeds maximum nesting depth at ${path.join('.')}`); } (n['children'] as unknown[]).forEach((child, i) => validateConditionTree(child, [...path, 'children', String(i)]) ); return; } throw new Error(`Unknown node kind at ${path.join('.') || 'root'}`); } class UnsafeRegexError extends Error { constructor(public readonly pattern: string, public readonly path: string[]) { super(`Unsafe regex pattern rejected: ${pattern}`); } } /** * Zod schema for v2 condition tree. Uses z.lazy for recursion. */ const ConditionLeafSchema: z.ZodType = z.object({ kind: z.literal('condition'), field: z.string().min(1), operator: z.string().min(1), value: z.unknown(), params: z.record(z.string(), z.unknown()).optional(), }); const ConditionGroupSchema: z.ZodType = z.lazy(() => z.object({ kind: z.literal('group'), logic: z.enum(['AND', 'OR', 'NOT']), children: z.array(z.union([ConditionLeafSchema, ConditionGroupSchema])), }) ); const ConditionNodeSchema: z.ZodType = z.union([ ConditionLeafSchema, ConditionGroupSchema, ]); const V2ConditionsSchema = z.object({ version: z.literal(2), root: ConditionNodeSchema, }); // ============================================================================ // Helper Functions // ============================================================================ /** * Transforms a rule from snake_case (database) to camelCase (client format) */ interface ClientRule { id: number; name: string; profileId: number | null; type: string; mediaType: 'all' | 'movie' | 'tv'; /** Plex library section keys the rule targets. Empty = all libraries. */ libraryKeys: string[]; conditions: string; /** * v2 parsed condition tree — the stored JSON is upgraded on read so the * client always sees the nested-group form. */ conditionsV2: { version: 2; root: ConditionNode }; action: string; enabled: boolean; gracePeriodDays: number; deletionAction: string; resetOverseerr: boolean; priority: number; createdAt: string; updatedAt: string; } function toClientRule(rule: Rule): ClientRule { // Convert 'show' to 'tv' for client (client uses 'tv', server uses 'show') const clientMediaType = rule.media_type === 'show' ? 'tv' : (rule.media_type || 'all'); let conditionsV2: { version: 2; root: ConditionNode }; try { const parsed = JSON.parse(rule.conditions); conditionsV2 = upgradeToV2(parsed) as { version: 2; root: ConditionNode }; } catch { conditionsV2 = { version: 2, root: { kind: 'group', logic: 'AND', children: [] } }; } return { id: rule.id, name: rule.name, profileId: rule.profile_id, type: rule.type, mediaType: clientMediaType as 'all' | 'movie' | 'tv', libraryKeys: rule.library_keys ?? [], conditions: rule.conditions, conditionsV2, action: rule.action, enabled: rule.enabled, gracePeriodDays: rule.grace_period_days, deletionAction: rule.deletion_action, resetOverseerr: rule.reset_overseerr, priority: rule.priority, createdAt: rule.created_at, updatedAt: rule.updated_at, }; } /** * Evaluates whether a media item matches all the given rule conditions. * All conditions must be true for the item to match (AND logic). */ function evaluateConditions(item: MediaItem, conditions: RuleCondition[]): boolean { if (conditions.length === 0) { return false; // No conditions means no match (safety) } for (const condition of conditions) { if (!evaluateSingleCondition(item, condition)) { return false; } } return true; } /** * Evaluates a single condition against a media item. */ function evaluateSingleCondition(item: MediaItem, condition: RuleCondition): boolean { const { field, operator, value } = condition; // Get the field value from the item (handle nested fields like 'type', 'status', etc.) const fieldValue = getFieldValue(item, field); switch (operator) { case 'equals': return fieldValue === value; case 'not_equals': return fieldValue !== value; case 'greater_than': return typeof fieldValue === 'number' && typeof value === 'number' && fieldValue > value; case 'less_than': return typeof fieldValue === 'number' && typeof value === 'number' && fieldValue < value; case 'contains': return typeof fieldValue === 'string' && typeof value === 'string' && fieldValue.toLowerCase().includes(value.toLowerCase()); case 'not_contains': return typeof fieldValue === 'string' && typeof value === 'string' && !fieldValue.toLowerCase().includes(value.toLowerCase()); case 'is_empty': return fieldValue === null || fieldValue === undefined || fieldValue === ''; case 'is_not_empty': return fieldValue !== null && fieldValue !== undefined && fieldValue !== ''; default: logger.warn(`Unknown operator: ${operator}`); return false; } } /** * Gets the value of a field from a media item. * Supports special computed fields like 'days_since_added', 'days_since_watched'. */ function getFieldValue(item: MediaItem, field: string): string | number | boolean | null { // Handle special computed fields switch (field) { case 'days_since_added': { if (!item.added_at) return null; const addedDate = new Date(item.added_at); const now = new Date(); return Math.floor((now.getTime() - addedDate.getTime()) / (1000 * 60 * 60 * 24)); } case 'days_since_watched': { if (!item.last_watched_at) return null; const watchedDate = new Date(item.last_watched_at); const now = new Date(); return Math.floor((now.getTime() - watchedDate.getTime()) / (1000 * 60 * 60 * 24)); } case 'size_gb': { if (!item.file_size) return null; return item.file_size / (1024 * 1024 * 1024); } case 'resolution_number': { // Parse resolution string to number: "1080p" → 1080, "4K" → 2160, "720p" → 720, "480p" → 480 const res = String(item.resolution || ''); if (res.toLowerCase().includes('4k') || res.includes('2160')) return 2160; const match = res.match(/(\d+)/); return match && match[1] ? parseInt(match[1], 10) : 0; } case 'year': return item.year || 0; case 'codec': return (item.codec || '').toLowerCase(); case 'library_key': return item.library_key || ''; case 'file_path': return item.file_path || ''; case 'watched_by_count': { // Parse watched_by JSON array and count unique watchers try { const wb = item.watched_by; if (!wb) return 0; const parsed = typeof wb === 'string' ? JSON.parse(wb) : wb; return Array.isArray(parsed) ? parsed.length : 0; } catch { return 0; } } default: // Direct field access if (field in item) { return item[field as keyof MediaItem] as string | number | boolean | null; } return null; } } const router = Router(); // Validation middleware function validateBody(schema: z.ZodSchema) { return (req: Request, res: Response, next: NextFunction) => { const result = schema.safeParse(req.body); if (!result.success) { res.status(400).json({ success: false, error: 'Validation failed', details: result.error.issues, }); return; } req.body = result.data; next(); }; } // ============================================================================ // Rules Endpoints // ============================================================================ // GET /api/rules - Get all rules router.get('/', (_req: Request, res: Response) => { try { const rules = rulesRepo.rules.getAll(); res.json({ success: true, data: rules.map(toClientRule), total: rules.length, }); } catch (error) { logger.error('Failed to get rules:', error); res.status(500).json({ success: false, error: 'Failed to retrieve rules', }); } }); // GET /api/rules/enabled - Get only enabled rules router.get('/enabled', (_req: Request, res: Response) => { try { const rules = rulesRepo.rules.getEnabled(); res.json({ success: true, data: rules.map(toClientRule), total: rules.length, }); } catch (error) { logger.error('Failed to get enabled rules:', error); res.status(500).json({ success: false, error: 'Failed to retrieve enabled rules', }); } }); // GET /api/rules/suggestions - Get smart rule suggestions based on library analysis // NOTE: This must be defined BEFORE /:id to avoid matching "suggestions" as an ID router.get('/suggestions', async (_req: Request, res: Response) => { try { const mediaItemsRepo = await import('../db/repositories/mediaItems'); // Suggestions estimate how much a proposed rule would reclaim, so they must // ignore tombstones for the same reason /preview does, and protected items, // which a rule never deletes. const inProgress = loadInProgressConfig(); const items = mediaItemsRepo.default .fetchAll({ excludeDeleted: true }) .filter((item) => !item.is_protected && !isProtectedInProgress(item, inProgress)); const now = new Date(); const suggestions: Array<{ id: string; name: string; description: string; icon: string; matchCount: number; totalSize: number; totalSizeFormatted: string; conditions: RuleCondition[]; mediaType: 'all' | 'movie' | 'show'; }> = []; // 1. Never watched (added 30+ days ago, play_count = 0) const neverWatched = items.filter((item) => { if (item.play_count > 0) return false; if (!item.added_at) return false; const days = Math.floor((now.getTime() - new Date(item.added_at).getTime()) / (1000 * 60 * 60 * 24)); return days >= 30; }); if (neverWatched.length > 0) { const size = neverWatched.reduce((sum, i) => sum + (i.file_size || 0), 0); suggestions.push({ id: 'never-watched', name: 'Never Watched', description: 'Added 30+ days ago, never played', icon: 'download', matchCount: neverWatched.length, totalSize: size, totalSizeFormatted: formatBytes(size), conditions: [ { field: 'play_count', operator: 'equals', value: 0 }, { field: 'days_since_added', operator: 'greater_than', value: 30 }, ], mediaType: 'all', }); } // 2. Watched once (watched exactly once, 60+ days ago) const watchedOnce = items.filter((item) => { if (item.play_count !== 1) return false; if (!item.last_watched_at) return false; const days = Math.floor((now.getTime() - new Date(item.last_watched_at).getTime()) / (1000 * 60 * 60 * 24)); return days >= 60; }); if (watchedOnce.length > 0) { const size = watchedOnce.reduce((sum, i) => sum + (i.file_size || 0), 0); suggestions.push({ id: 'watched-once', name: 'Watched Once', description: 'Played once, 60+ days ago', icon: 'eye', matchCount: watchedOnce.length, totalSize: size, totalSizeFormatted: formatBytes(size), conditions: [ { field: 'play_count', operator: 'equals', value: 1 }, { field: 'days_since_watched', operator: 'greater_than', value: 60 }, ], mediaType: 'all', }); } // 3. Large files (10GB+, not watched in 30+ days) const largeFiles = items.filter((item) => { if (!item.file_size || item.file_size < 10 * 1024 * 1024 * 1024) return false; // 10GB if (!item.last_watched_at) return true; const days = Math.floor((now.getTime() - new Date(item.last_watched_at).getTime()) / (1000 * 60 * 60 * 24)); return days >= 30; }); if (largeFiles.length > 0) { const size = largeFiles.reduce((sum, i) => sum + (i.file_size || 0), 0); suggestions.push({ id: 'large-files', name: 'Large Files', description: '10GB+ files, not watched in 30+ days', icon: 'hard-drive', matchCount: largeFiles.length, totalSize: size, totalSizeFormatted: formatBytes(size), conditions: [ { field: 'size_gb', operator: 'greater_than', value: 10 }, { field: 'days_since_watched', operator: 'greater_than', value: 30 }, ], mediaType: 'all', }); } // 4. Stale content (not watched in 90+ days) const stale = items.filter((item) => { if (!item.last_watched_at) { if (!item.added_at) return false; const days = Math.floor((now.getTime() - new Date(item.added_at).getTime()) / (1000 * 60 * 60 * 24)); return days >= 90; } const days = Math.floor((now.getTime() - new Date(item.last_watched_at).getTime()) / (1000 * 60 * 60 * 24)); return days >= 90; }); if (stale.length > 0) { const size = stale.reduce((sum, i) => sum + (i.file_size || 0), 0); suggestions.push({ id: 'stale', name: 'Stale Content', description: 'Not watched in 90+ days', icon: 'clock', matchCount: stale.length, totalSize: size, totalSizeFormatted: formatBytes(size), conditions: [{ field: 'days_since_watched', operator: 'greater_than', value: 90 }], mediaType: 'all', }); } // 5. Old movies (movies watched once, 30+ days ago) const oldMovies = items.filter((item) => { if (item.type !== 'movie') return false; if (item.play_count !== 1) return false; if (!item.last_watched_at) return false; const days = Math.floor((now.getTime() - new Date(item.last_watched_at).getTime()) / (1000 * 60 * 60 * 24)); return days >= 30; }); if (oldMovies.length > 0) { const size = oldMovies.reduce((sum, i) => sum + (i.file_size || 0), 0); suggestions.push({ id: 'old-movies', name: 'Old Movies', description: 'Movies watched once, 30+ days ago', icon: 'film', matchCount: oldMovies.length, totalSize: size, totalSizeFormatted: formatBytes(size), conditions: [ { field: 'play_count', operator: 'equals', value: 1 }, { field: 'days_since_watched', operator: 'greater_than', value: 30 }, ], mediaType: 'movie', }); } // 6. Completed TV shows (TV shows not watched in 60+ days) const completedShows = items.filter((item) => { if (item.type !== 'show') return false; if (!item.last_watched_at) return false; const days = Math.floor((now.getTime() - new Date(item.last_watched_at).getTime()) / (1000 * 60 * 60 * 24)); return days >= 60; }); if (completedShows.length > 0) { const size = completedShows.reduce((sum, i) => sum + (i.file_size || 0), 0); suggestions.push({ id: 'completed-shows', name: 'Old TV Shows', description: 'TV shows not watched in 60+ days', icon: 'tv', matchCount: completedShows.length, totalSize: size, totalSizeFormatted: formatBytes(size), conditions: [{ field: 'days_since_watched', operator: 'greater_than', value: 60 }], mediaType: 'show', }); } // 7. Low Quality Files (SD and 720p content) const lowQuality = items.filter((item) => { const res = String(item.resolution || ''); let resNum = 0; if (res.toLowerCase().includes('4k') || res.includes('2160')) resNum = 2160; else { const m = res.match(/(\d+)/); resNum = m && m[1] ? parseInt(m[1], 10) : 0; } return resNum > 0 && resNum < 1080; }); if (lowQuality.length > 0) { const size = lowQuality.reduce((sum, i) => sum + (i.file_size || 0), 0); suggestions.push({ id: 'low-quality', name: 'Low Quality Files', description: 'Remove SD and 720p content to save space', icon: 'monitor', matchCount: lowQuality.length, totalSize: size, totalSizeFormatted: formatBytes(size), conditions: [ { field: 'resolution_number', operator: 'less_than', value: 1080 }, ], mediaType: 'all', }); } // 8. Old Codec Cleanup (H.264 or similar) const oldCodec = items.filter((item) => { if (item.type !== 'movie') return false; const codec = (item.codec || '').toLowerCase(); return codec.includes('h264') || codec.includes('h.264'); }); if (oldCodec.length > 0) { const size = oldCodec.reduce((sum, i) => sum + (i.file_size || 0), 0); suggestions.push({ id: 'old-codec', name: 'Old Codec Cleanup', description: 'Remove files using older codecs like H.264 or MPEG', icon: 'film', matchCount: oldCodec.length, totalSize: size, totalSizeFormatted: formatBytes(size), conditions: [ { field: 'codec', operator: 'contains', value: 'h264' }, ], mediaType: 'movie', }); } // 9. Classic Movies Never Rewatched (before 2015, not watched in 180+ days) const classicNeverRewatched = items.filter((item) => { if (item.type !== 'movie') return false; if (!item.year || item.year >= 2015) return false; if (!item.last_watched_at) return true; // Never watched counts const days = Math.floor((now.getTime() - new Date(item.last_watched_at).getTime()) / (1000 * 60 * 60 * 24)); return days > 180; }); if (classicNeverRewatched.length > 0) { const size = classicNeverRewatched.reduce((sum, i) => sum + (i.file_size || 0), 0); suggestions.push({ id: 'classic-never-rewatched', name: 'Classic Movies Never Rewatched', description: "Movies released before 2015 that haven't been watched recently", icon: 'clock', matchCount: classicNeverRewatched.length, totalSize: size, totalSizeFormatted: formatBytes(size), conditions: [ { field: 'year', operator: 'less_than', value: 2015 }, { field: 'days_since_watched', operator: 'greater_than', value: 180 }, ], mediaType: 'movie', }); } // 10. Large 4K Files Watched Once (4K, play_count = 1, 20GB+) const large4kWatchedOnce = items.filter((item) => { if (item.type !== 'movie') return false; if (item.play_count !== 1) return false; if (!item.file_size || item.file_size < 20 * 1024 * 1024 * 1024) return false; // 20GB const res = String(item.resolution || ''); let resNum = 0; if (res.toLowerCase().includes('4k') || res.includes('2160')) resNum = 2160; else { const m = res.match(/(\d+)/); resNum = m && m[1] ? parseInt(m[1], 10) : 0; } return resNum > 2000; }); if (large4kWatchedOnce.length > 0) { const size = large4kWatchedOnce.reduce((sum, i) => sum + (i.file_size || 0), 0); suggestions.push({ id: 'large-4k-watched-once', name: 'Large 4K Files Watched Once', description: 'Large 4K files that have only been watched once', icon: 'hard-drive', matchCount: large4kWatchedOnce.length, totalSize: size, totalSizeFormatted: formatBytes(size), conditions: [ { field: 'resolution_number', operator: 'greater_than', value: 2000 }, { field: 'play_count', operator: 'equals', value: 1 }, { field: 'size_gb', operator: 'greater_than', value: 20 }, ], mediaType: 'movie', }); } // 11. Low play count (watched 2 or fewer times, 90+ days ago) const lowPlayCount = items.filter((item) => { if (item.play_count > 2) return false; if (!item.last_watched_at && !item.added_at) return false; const dateToCheck = item.last_watched_at || item.added_at; const days = Math.floor((now.getTime() - new Date(dateToCheck!).getTime()) / (1000 * 60 * 60 * 24)); return days >= 90; }); if (lowPlayCount.length > 0) { const size = lowPlayCount.reduce((sum, i) => sum + (i.file_size || 0), 0); suggestions.push({ id: 'low-play-count', name: 'Rarely Watched', description: 'Played 2 or fewer times, 90+ days old', icon: 'eye', matchCount: lowPlayCount.length, totalSize: size, totalSizeFormatted: formatBytes(size), conditions: [ { field: 'play_count', operator: 'less_than', value: 3 }, { field: 'days_since_watched', operator: 'greater_than', value: 90 }, ], mediaType: 'all', }); } res.json({ success: true, data: { suggestions, libraryStats: { totalItems: items.length, movies: items.filter((i) => i.type === 'movie').length, shows: items.filter((i) => i.type === 'show').length, totalSize: items.reduce((sum, i) => sum + (i.file_size || 0), 0), }, }, }); } catch (error) { logger.error('Failed to get rule suggestions:', error); res.status(500).json({ success: false, error: 'Failed to get rule suggestions', }); } }); // GET /api/rules/:id - Get a specific rule router.get('/:id', (req: Request, res: Response) => { try { const id = parseInt(req.params['id'] as string, 10); if (isNaN(id)) { res.status(400).json({ success: false, error: 'Invalid rule ID', }); return; } const rule = rulesRepo.rules.getById(id); if (!rule) { res.status(404).json({ success: false, error: `Rule not found: ${id}`, }); return; } res.json({ success: true, data: toClientRule(rule), }); } catch (error) { logger.error('Failed to get rule:', error); res.status(500).json({ success: false, error: 'Failed to retrieve rule', }); } }); /** * Middleware that validates any regex_match operators in the incoming rule * body. Supports both v1 (flat `conditions` array) and v2 (tree `root`). * Rejects unsafe patterns with a 400. */ function validateRegexSafety(req: Request, res: Response, next: NextFunction): void { try { const body = req.body; // v2 at top level: look for `root` if (body && typeof body === 'object' && 'root' in body && body.root) { validateConditionTree(body.root); } // v2 nested inside `conditions`: { version: 2, root: ... } if ( body && typeof body === 'object' && body.conditions && typeof body.conditions === 'object' && !Array.isArray(body.conditions) && body.conditions.root ) { validateConditionTree(body.conditions.root); } // v1: look for flat conditions array if (body && Array.isArray(body.conditions)) { for (const c of body.conditions) { if (c && REGEX_OPERATORS.has(String(c.operator))) { const pattern = String(c.value ?? ''); if (!safeRegex(pattern)) { throw new UnsafeRegexError(pattern, ['conditions']); } } } } next(); } catch (err) { if (err instanceof UnsafeRegexError) { res.status(400).json({ success: false, error: `Unsafe regex pattern rejected: ${err.pattern}`, }); return; } res.status(400).json({ success: false, error: (err as Error).message, }); } } // POST /api/rules - Create a new rule router.post('/', validateRegexSafety, validateBody(CreateRuleSchema), (req: Request, res: Response) => { try { const rule = rulesRepo.rules.create(req.body); res.status(201).json({ success: true, data: toClientRule(rule), message: 'Rule created successfully', }); } catch (error) { logger.error('Failed to create rule:', error); res.status(500).json({ success: false, error: 'Failed to create rule', }); } }); // PUT /api/rules/:id - Update a rule router.put('/:id', validateRegexSafety, validateBody(UpdateRuleSchema), (req: Request, res: Response) => { try { const id = parseInt(req.params['id'] as string, 10); if (isNaN(id)) { res.status(400).json({ success: false, error: 'Invalid rule ID', }); return; } const rule = rulesRepo.rules.update(id, req.body); if (!rule) { res.status(404).json({ success: false, error: `Rule not found: ${id}`, }); return; } res.json({ success: true, data: toClientRule(rule), message: 'Rule updated successfully', }); } catch (error) { logger.error('Failed to update rule:', error); res.status(500).json({ success: false, error: 'Failed to update rule', }); } }); // DELETE /api/rules/:id - Delete a rule router.delete('/:id', (req: Request, res: Response) => { try { const id = parseInt(req.params['id'] as string, 10); if (isNaN(id)) { res.status(400).json({ success: false, error: 'Invalid rule ID', }); return; } const deleted = rulesRepo.rules.delete(id); if (!deleted) { res.status(404).json({ success: false, error: `Rule not found: ${id}`, }); return; } res.json({ success: true, message: 'Rule deleted successfully', }); } catch (error) { logger.error('Failed to delete rule:', error); res.status(500).json({ success: false, error: 'Failed to delete rule', }); } }); // PATCH /api/rules/:id/toggle - Toggle rule enabled/disabled router.patch('/:id/toggle', (req: Request, res: Response) => { try { const id = parseInt(req.params['id'] as string, 10); if (isNaN(id)) { res.status(400).json({ success: false, error: 'Invalid rule ID', }); return; } // Use the enabled value from request body if provided, otherwise toggle const { enabled } = req.body; let rule; if (typeof enabled === 'boolean') { rule = rulesRepo.rules.update(id, { enabled }); } else { rule = rulesRepo.rules.toggle(id); } if (!rule) { res.status(404).json({ success: false, error: `Rule not found: ${id}`, }); return; } res.json({ success: true, data: toClientRule(rule), message: `Rule ${rule.enabled ? 'enabled' : 'disabled'}`, }); } catch (error) { logger.error('Failed to toggle rule:', error); res.status(500).json({ success: false, error: 'Failed to toggle rule', }); } }); // POST /api/rules/:id/run - Run a rule manually (scan library and mark matching items) router.post('/:id/run', async (req: Request, res: Response) => { try { const id = parseInt(req.params['id'] as string, 10); if (isNaN(id)) { res.status(400).json({ success: false, error: 'Invalid rule ID', }); return; } const rule = rulesRepo.rules.getById(id); if (!rule) { res.status(404).json({ success: false, error: `Rule not found: ${id}`, }); return; } // Running it now would queue every match; the disk-pressure monitor runs // it when space is low and only takes what it needs. if (ruleUsesDiskPressure(rule)) { res.status(400).json({ success: false, error: 'This rule uses Disk pressure, so it runs on its own when disk space is low.', }); return; } // Get all media items. Tombstones are excluded for the same reason as in // /preview — a soft-deleted row still satisfies the conditions that got it // deleted, and re-queueing it would be a no-op at best. const mediaItemsRepo = await import('../db/repositories/mediaItems'); const mediaItems = mediaItemsRepo.default.fetchAll({ excludeDeleted: true }); // Filter by rule scope (media type + targeted libraries) const filteredItems = mediaItems.filter((item) => ruleScopeMatches(rule, item)); // Build evaluation context once for this run (v2 engine — supports nested // groups, new operators, collection_membership, watched_by_user) const ctx: EvaluationContext = buildEvaluationContext(); // Evaluate each media item against the rule conditions via the v2 engine. // evaluateRuleConditions handles v1→v2 upgrade + tree walking internally. const matchingItems: typeof mediaItems = []; const inProgress = loadInProgressConfig(); for (const item of filteredItems) { // Skip protected items, and shows someone is part-way through if (item.is_protected || isProtectedInProgress(item, inProgress)) { continue; } try { if (evaluateRuleConditions(rule.conditions, item, ctx)) { matchingItems.push(item); } } catch (evalError) { logger.error(`Failed to evaluate rule ${rule.id} against item ${item.id}:`, evalError); } } const now = new Date(); const markedAt = now.toISOString(); // Apply the rule action to matching items let processed = 0; let failed = 0; const results: { id: number; title: string; action: string; success: boolean }[] = []; const queuedMatches: QueuedMatch[] = []; for (const item of matchingItems) { try { switch (rule.action) { case 'flag': mediaItemsRepo.default.update(item.id, { status: 'flagged', marked_at: markedAt, matched_rule_id: rule.id, } as any); try { logActivity({ eventType: 'rule_match', action: 'item_flagged', actorType: 'rule', actorId: rule.id.toString(), actorName: rule.name, targetType: 'media_item', targetId: item.id, targetTitle: item.title, }); } catch (activityError) { logger.warn('Failed to log activity for manual rule run (flag):', activityError); } results.push({ id: item.id, title: item.title, action: 'flagged', success: true }); processed++; break; case 'delete': { // Use the shared helper so the item gets full queue metadata AND // an activity log entry with actorType='rule' (powers the Activity // Log "Rule" filter and the Discord ITEMS_MARKED notification). const { deleteAfter } = queueItemForDeletion(item as any, rule); queuedMatches.push({ item: item as any, rule, deleteAfter }); results.push({ id: item.id, title: item.title, action: 'pending_deletion', success: true }); processed++; break; } case 'notify': // For notify action, just mark them as flagged but don't delete // In a full implementation, this would trigger a notification results.push({ id: item.id, title: item.title, action: 'notified', success: true }); processed++; break; } } catch (itemError) { logger.error(`Failed to process item ${item.id}:`, itemError); results.push({ id: item.id, title: item.title, action: rule.action, success: false }); failed++; } } // Fire one ITEMS_MARKED Discord notification covering everything this rule queued await notifyItemsQueued(queuedMatches); logger.info(`Rule ${id} (${rule.name}) run complete: ${processed} processed, ${failed} failed`); res.json({ success: true, data: { rule: { id: rule.id, name: rule.name, action: rule.action, }, summary: { totalScanned: mediaItems.length, matched: matchingItems.length, processed, failed, }, results, }, message: `Rule executed: ${processed} items ${rule.action === 'flag' ? 'flagged' : rule.action === 'delete' ? 'marked for deletion' : 'notified'}`, }); } catch (error) { logger.error('Failed to run rule:', error); res.status(500).json({ success: false, error: 'Failed to run rule', }); } }); // ============================================================================ // Preview Endpoint // ============================================================================ // POST /api/rules/preview - Preview which items would match a rule (before saving) // Accepts either v1 (legacy flat conditions) or v2 (nested tree) payloads. const PreviewRuleSchema = z.object({ mediaType: z.enum(['all', 'movie', 'show', 'tv']).optional(), libraryKeys: z.array(z.string().min(1)).max(100).optional(), // Paging through the matched samples (largest first). sampleOffset: z.number().int().min(0).optional(), sampleLimit: z.number().int().min(1).max(50).optional(), // Rules never act on protected items; callers can leave them out of samples. includeProtectedSamples: z.boolean().optional(), // v2 version: z.literal(2).optional(), root: ConditionNodeSchema.optional(), // v1 (legacy) conditions: z .array( z.object({ field: z.string(), operator: z.string(), value: z.union([z.string(), z.number(), z.boolean(), z.array(z.unknown())]), }) ) .optional(), logic: z.enum(['AND', 'OR']).optional(), }); router.post('/preview', validateBody(PreviewRuleSchema), async (req: Request, res: Response) => { try { const { mediaType, libraryKeys, sampleOffset = 0, sampleLimit = 10, includeProtectedSamples = true, } = req.body as { mediaType?: string; libraryKeys?: string[]; sampleOffset?: number; sampleLimit?: number; includeProtectedSamples?: boolean; }; // Build v2 tree from payload let v2; try { v2 = upgradeToV2(req.body); validateConditionTree(v2.root); } catch (validationError) { if (validationError instanceof UnsafeRegexError) { res.status(400).json({ success: false, error: `Unsafe regex pattern rejected: ${validationError.pattern}`, }); return; } res.status(400).json({ success: false, error: (validationError as Error).message, }); return; } // Fetch all media items. excludeDeleted keeps soft-deleted tombstones out // of the count — without it a rule that has already cleaned up its library // reports every past deletion as a fresh match. const mediaItemsRepo = await import('../db/repositories/mediaItems'); const allItems = mediaItemsRepo.default.fetchAll({ excludeDeleted: true }); // Normalize mediaType: client 'tv' → server 'show' const normalizedType = mediaType === 'tv' ? 'show' : mediaType; const typeFiltered = !normalizedType || normalizedType === 'all' ? allItems : allItems.filter((item) => item.type === normalizedType); // Restrict to targeted Plex libraries when the rule names any. Items with // no library_key (legacy rows) fall outside a library-restricted rule, // mirroring ruleScopeMatches. const items = libraryKeys && libraryKeys.length > 0 ? typeFiltered.filter( (item) => item.library_key != null && libraryKeys.includes(item.library_key) ) : typeFiltered; // Build watch lookup from cache (one-time prefetch for this run). A Disk // pressure rule is previewed as if space were critically low, so it shows // what the monitor could pick from. const diskPressure = nodeUsesDiskPressure(v2.root as ConditionNode); const ctx: EvaluationContext = { ...buildEvaluationContext(), ...(diskPressure ? { diskPressure: { severity: 'critical' as const, isOnDrive: () => true } } : {}), }; // Evaluate const allMatching = items.filter((item) => evaluateNode(v2.root as ConditionNode, item, ctx)); // Items already sitting in the deletion queue still satisfy the conditions, // but they aren't new work — surface them separately so the headline count // means "what this rule would newly pick up". const alreadyPending = allMatching.filter((i) => i.status === 'pending_deletion').length; const matching = allMatching.filter((i) => i.status !== 'pending_deletion'); // Separate protected vs queueable. Shows someone is part-way through are // skipped too (Settings → Safety) and counted on their own. const inProgress = loadInProgressConfig(); const isInProgress = (i: (typeof matching)[number]) => !i.is_protected && isProtectedInProgress(i, inProgress); const isSkipped = (i: (typeof matching)[number]) => Boolean(i.is_protected) || isInProgress(i); const wouldSkipProtected = matching.filter((i) => i.is_protected).length; const wouldSkipInProgress = matching.filter(isInProgress).length; const wouldQueue = matching.length - wouldSkipProtected - wouldSkipInProgress; // Reclaimable space counts only what the rule would actually delete — // protected and in-progress items are skipped, so their size is never freed. const totalBytes = matching .filter((i) => !isSkipped(i)) .reduce((sum, i) => sum + (i.file_size || 0), 0); const storageFreedGB = totalBytes / (1024 * 1024 * 1024); // One page of matches, largest first, so the whole match list can be // browsed. sampleTotal is the size of the list being paged. const sampleable = includeProtectedSamples ? matching : matching.filter((i) => !isSkipped(i)); const samples = [...sampleable] .sort((a, b) => (b.file_size || 0) - (a.file_size || 0)) .slice(sampleOffset, sampleOffset + sampleLimit) .map((item) => ({ id: item.id, title: item.title, size: item.file_size || 0, rating: item.rating_imdb ?? item.rating_tmdb ?? null, posterUrl: toThumbnailUrl(item.poster_url) || null, isProtected: Boolean(item.is_protected), inProgress: isInProgress(item), // Who is part-way through it, so the preview can say why it's kept. inProgressFor: usersInProgress(item, inProgress.recentDays), reason: describeMatchReason(v2.root as ConditionNode), })); res.json({ success: true, data: { totalMatches: matching.length, wouldQueue, wouldSkipProtected, wouldSkipInProgress, alreadyPending, storageFreedGB: Math.round(storageFreedGB * 100) / 100, totalSize: totalBytes, totalSizeFormatted: formatBytes(totalBytes), samples, sampleTotal: sampleable.length, diskPressure, ...(diskPressure ? { diskPressureEnabled: settingsRepo.getBoolean('diskPressure_enabled', false) } : {}), }, }); } catch (error) { logger.error('Failed to preview rule:', error); res.status(500).json({ success: false, error: 'Failed to preview rule', }); } }); /** Conditions and days-since values a rule forecast will take on. */ const FORECAST_MAX_LEAVES = 50; const FORECAST_MAX_DAY_VALUES = 20; function forecastable(root: ConditionNode): boolean { const leaves = leavesOf(root); if (leaves.length > FORECAST_MAX_LEAVES) return false; return leaves.every( (leaf) => !(leaf.field === 'days_since_added' || leaf.field === 'days_since_watched') || !Array.isArray(leaf.value) || leaf.value.length <= FORECAST_MAX_DAY_VALUES ); } const RuleForecastSchema = z.object({ version: z.literal(2), root: ConditionNodeSchema, mediaType: z.enum(['all', 'movie', 'show', 'tv']).optional(), libraryKeys: z.array(z.string().min(1)).max(100).optional(), deletionAction: z.string().max(64).optional(), /** The saved rule being edited, to compare against. */ ruleId: z.number().int().positive().optional(), }); /** * POST /api/rules/forecast * What a rule (as being edited) would make eligible by the next scan, in three * months and in a year, judged on its own, and the same for the saved version * when editing. */ router.post('/forecast', validateBody(RuleForecastSchema), async (req: Request, res: Response) => { try { const { mediaType, libraryKeys, deletionAction, ruleId } = req.body as z.infer; let v2; try { v2 = upgradeToV2(req.body); validateConditionTree(v2.root); } catch (validationError) { res.status(400).json({ success: false, error: (validationError as Error).message }); return; } // Every number in a days-since condition adds days to try for every item, // so a rule built to be slow could hold up the server. Real rules use a few. if (!forecastable(v2.root as ConditionNode)) { res.status(400).json({ success: false, error: 'This rule is too complex to forecast' }); return; } const { forecastSingleRule } = await import('../services/forecast'); const inputs: RuleForecastInput[] = [{ root: v2.root as ConditionNode, mediaType, libraryKeys, deletionAction }]; const savedRule = ruleId ? rulesRepo.rules.getById(ruleId) : null; if (savedRule) { try { inputs.push({ root: upgradeToV2(JSON.parse(savedRule.conditions)).root as ConditionNode, mediaType: savedRule.media_type, libraryKeys: savedRule.library_keys ?? [], deletionAction: savedRule.deletion_action, }); } catch (error) { logger.warn('Could not forecast the saved rule', { message: (error as Error).message }); } } const [proposed, saved = null] = forecastSingleRule(inputs); res.json({ success: true, data: { proposed, saved } }); } catch (error) { logger.error('Failed to forecast rule:', error); res.status(500).json({ success: false, error: 'Failed to forecast rule' }); } }); function describeMatchReason(root: ConditionNode): string { if (root.kind === 'condition') { return `${root.field} ${root.operator}`; } return `${root.logic} of ${root.children.length} condition(s)`; } // ============================================================================ // Profiles Endpoints // ============================================================================ // GET /api/rules/profiles - Get all profiles router.get('/profiles/all', (_req: Request, res: Response) => { try { const profiles = rulesRepo.profiles.getAll(); res.json({ success: true, data: profiles, total: profiles.length, }); } catch (error) { logger.error('Failed to get profiles:', error); res.status(500).json({ success: false, error: 'Failed to retrieve profiles', }); } }); // GET /api/rules/profiles/active - Get active profile router.get('/profiles/active', (_req: Request, res: Response) => { try { const profile = rulesRepo.profiles.getActive(); if (!profile) { res.status(404).json({ success: false, error: 'No active profile found', }); return; } res.json({ success: true, data: profile, }); } catch (error) { logger.error('Failed to get active profile:', error); res.status(500).json({ success: false, error: 'Failed to retrieve active profile', }); } }); // POST /api/rules/profiles - Create a new profile const CreateProfileSchema = z.object({ name: z.string().min(1), }); router.post('/profiles', validateBody(CreateProfileSchema), (req: Request, res: Response) => { try { const profile = rulesRepo.profiles.create(req.body.name); res.status(201).json({ success: true, data: profile, message: 'Profile created successfully', }); } catch (error) { logger.error('Failed to create profile:', error); res.status(500).json({ success: false, error: 'Failed to create profile', }); } }); // PATCH /api/rules/profiles/:id/activate - Set active profile router.patch('/profiles/:id/activate', (req: Request, res: Response) => { try { const id = parseInt(req.params['id'] as string, 10); if (isNaN(id)) { res.status(400).json({ success: false, error: 'Invalid profile ID', }); return; } const profile = rulesRepo.profiles.setActive(id); if (!profile) { res.status(404).json({ success: false, error: `Profile not found: ${id}`, }); return; } res.json({ success: true, data: profile, message: 'Profile activated', }); } catch (error) { logger.error('Failed to activate profile:', error); res.status(500).json({ success: false, error: 'Failed to activate profile', }); } }); // DELETE /api/rules/profiles/:id - Delete a profile router.delete('/profiles/:id', (req: Request, res: Response) => { try { const id = parseInt(req.params['id'] as string, 10); if (isNaN(id)) { res.status(400).json({ success: false, error: 'Invalid profile ID', }); return; } const deleted = rulesRepo.profiles.delete(id); if (!deleted) { res.status(404).json({ success: false, error: `Profile not found: ${id}`, }); return; } res.json({ success: true, message: 'Profile deleted successfully', }); } catch (error) { logger.error('Failed to delete profile:', error); res.status(500).json({ success: false, error: 'Failed to delete profile', }); } }); // ============================================================================ // Deletion History Endpoints // ============================================================================ // GET /api/rules/history/deletions - Get deletion history router.get('/history/deletions', (req: Request, res: Response) => { try { const limit = parseInt(req.query['limit'] as string, 10) || 100; const offset = parseInt(req.query['offset'] as string, 10) || 0; const history = rulesRepo.deletionHistory.getAll(limit, offset); res.json({ success: true, data: history, total: history.length, limit, offset, }); } catch (error) { logger.error('Failed to get deletion history:', error); res.status(500).json({ success: false, error: 'Failed to retrieve deletion history', }); } }); // GET /api/rules/history/deletions/stats - Get deletion statistics router.get('/history/deletions/stats', (_req: Request, res: Response) => { try { const stats = rulesRepo.deletionHistory.getStats(); res.json({ success: true, data: stats, }); } catch (error) { logger.error('Failed to get deletion stats:', error); res.status(500).json({ success: false, error: 'Failed to retrieve deletion statistics', }); } }); export default router;