import logger from '../utils/logger'; import rulesRepo from '../db/repositories/rules'; import mediaItemsRepo from '../db/repositories/mediaItems'; import storageSnapshotsRepo from '../db/repositories/storageSnapshots'; import unraidSnapshotsRepo from '../db/repositories/unraidSnapshots'; import settingsRepo from '../db/repositories/settings'; import { loadInProgressConfig, isProtectedInProgress } from '../rules/inProgress'; import { getPressureUsages, readArrStorage, storageAvailability } from '../services/storage'; import { UnraidService } from '../services/unraid'; import { getDeletionService } from '../services/deletion'; import { processDueEpisodeDeletions } from '../services/episodeDeletions'; import { PlexUsersService } from '../services/plexUsers'; import { isSyncInProgress, runLibrarySync } from '../services/syncCoordinator'; import { logActivity } from '../db/repositories/activity'; import { evaluateNode, evaluateRuleConditions, upgradeToV2 } from '../rules/engine'; import { ruleUsesDiskPressure, type DiskPressureState } from '../rules/diskPressure'; import { ruleScopeMatches } from '../rules/scope'; import { buildEvaluationContext } from '../rules/context'; import { getNotificationService } from '../notifications'; import { sendHeartbeat } from '../services/telemetry'; import { refreshAnnouncements as fetchAnnouncementsFeed } from '../services/announcements'; import { resolveTargetBytes, GiB, type FsUsage, type TargetMode } from '../services/diskSpace'; import { DeletionAction } from '../rules/types'; import type { DiskPressureData } from '../notifications/templates'; import type { EvaluationContext } from '../rules/conditions'; import type { MediaItem, Rule } from '../types'; // ============================================================================ // Task Result Types // ============================================================================ export interface TaskResult { success: boolean; taskName: string; startedAt: Date; completedAt: Date; durationMs: number; message?: string; error?: string; data?: Record; } export interface ScanResult extends TaskResult { data: { itemsScanned: number; itemsFlagged: number; itemsProtected: number; }; } export interface DeletionProcessingResult extends TaskResult { data: { itemsProcessed: number; itemsDeleted: number; spaceFreedBytes: number; errors: Array<{ itemId: number; error: string }>; }; } export interface ReminderResult extends TaskResult { data: { remindersSent: number; itemsPendingDeletion: number; }; } // ============================================================================ // Notification Service Dependency // ============================================================================ export interface TaskDependencies { notificationService?: { notify(event: string, data: Record): Promise; }; } let dependencies: TaskDependencies = {}; /** * Set task dependencies (notification service) */ export function setTaskDependencies(deps: TaskDependencies): void { dependencies = deps; logger.info('Task dependencies configured'); } /** * Get current task dependencies */ export function getTaskDependencies(): TaskDependencies { return dependencies; } // ============================================================================ // Condition Evaluation (shared with scan route) // ============================================================================ function getFieldValue(item: MediaItem, field: string): unknown { switch (field) { case 'type': return item.type; case 'title': return item.title; case 'file_size': return item.file_size; case 'size_gb': return item.file_size ? item.file_size / (1024 * 1024 * 1024) : null; case 'resolution': return item.resolution; case 'codec': return item.codec; case 'play_count': return item.play_count; case 'added_at': return item.added_at; case 'last_watched_at': return item.last_watched_at; case 'days_since_added': if (!item.added_at) return null; return Math.floor((Date.now() - new Date(item.added_at).getTime()) / (1000 * 60 * 60 * 24)); case 'days_since_watched': if (!item.last_watched_at) return null; return Math.floor((Date.now() - new Date(item.last_watched_at).getTime()) / (1000 * 60 * 60 * 24)); case 'never_watched': return item.play_count === 0; default: return undefined; } } function evaluateCondition( itemValue: unknown, operator: string, conditionValue: string | number | boolean ): boolean { switch (operator) { case 'equals': return itemValue === conditionValue; case 'not_equals': return itemValue !== conditionValue; case 'greater_than': return typeof itemValue === 'number' && typeof conditionValue === 'number' && itemValue > conditionValue; case 'less_than': return typeof itemValue === 'number' && typeof conditionValue === 'number' && itemValue < conditionValue; case 'contains': return typeof itemValue === 'string' && typeof conditionValue === 'string' && itemValue.toLowerCase().includes(conditionValue.toLowerCase()); case 'not_contains': return typeof itemValue === 'string' && typeof conditionValue === 'string' && !itemValue.toLowerCase().includes(conditionValue.toLowerCase()); case 'is_empty': return itemValue === null || itemValue === undefined || itemValue === ''; case 'is_not_empty': return itemValue !== null && itemValue !== undefined && itemValue !== ''; default: logger.warn(`Unknown operator: ${operator}`); return false; } } function evaluateConditions( item: MediaItem, conditions: Array<{ field: string; operator: string; value: string | number | boolean }> ): boolean { if (!item || conditions.length === 0) { return false; } for (const condition of conditions) { const itemValue = getFieldValue(item, condition.field); const matches = evaluateCondition(itemValue, condition.operator, condition.value); if (!matches) { return false; } } return true; } // ============================================================================ // Rule-triggered deletion queueing // ============================================================================ /** * Rule info needed to queue an item for deletion. * Shared between scheduled scans, manual full scans, and "Run Rule Now". */ export interface QueueingRule { id: number; name: string; grace_period_days: number | null; deletion_action: string | null; reset_overseerr: boolean; } /** * Match recorded by a caller during a scan pass, to be batched into one * ITEMS_MARKED notification per scan. */ export interface QueuedMatch { item: MediaItem; rule: QueueingRule; deleteAfter: string; } /** * Queue a media item for deletion based on a rule match. * Sets all the metadata required by the deletion queue (marked_at, delete_after, * deletion_action, reset_overseerr, matched_rule_id) and logs to activity. * * Shared between scheduled scans (scheduler/tasks.ts) and manual scans (routes/scan.ts) * so rule-triggered deletions properly flow through the queue → history → notifications. */ export function queueItemForDeletion( item: MediaItem, rule: QueueingRule ): { deleteAfter: string } { const gracePeriodDays = rule.grace_period_days ?? 7; const deletionAction = rule.deletion_action ?? 'unmonitor_and_delete'; const resetOverseerr = rule.reset_overseerr ? 1 : 0; const now = new Date(); const deleteAfter = new Date(now); deleteAfter.setDate(deleteAfter.getDate() + gracePeriodDays); const deleteAfterIso = deleteAfter.toISOString(); // Preserve existing marked_at if the item is already queued (idempotent re-runs) const isAlreadyQueued = item.status === 'pending_deletion'; const markedAt = isAlreadyQueued ? (item.marked_at ?? now.toISOString()) : now.toISOString(); mediaItemsRepo.update(item.id, { status: 'pending_deletion', marked_at: markedAt, delete_after: deleteAfterIso, deletion_action: deletionAction, reset_overseerr: resetOverseerr, matched_rule_id: rule.id, } as any); try { logActivity({ eventType: 'rule_match', action: 'item_queued', actorType: 'rule', actorId: rule.id.toString(), actorName: rule.name, targetType: 'media_item', targetId: item.id, targetTitle: item.title, metadata: JSON.stringify({ gracePeriodDays, deleteAfter: deleteAfterIso, deletionAction, resetOverseerr: Boolean(resetOverseerr), }), }); } catch (activityError) { logger.warn('Failed to log activity for rule-triggered queue:', activityError); } return { deleteAfter: deleteAfterIso }; } /** * Fire a single batched ITEMS_MARKED notification for all items queued during a * scan. Groups items by rule so one Discord message summarizes the whole scan. * No-ops when there are no matches. Falls back to the global notification * service if task dependencies haven't been wired yet (e.g. the route fires * before the scheduler DI runs — DI still preferred when set for testability). */ export async function notifyItemsQueued(matches: QueuedMatch[]): Promise { if (matches.length === 0) { return; } const notifier = dependencies.notificationService ?? { notify: (event: string, data: Record) => getNotificationService().notify(event as any, data).then(() => undefined), }; const groupMap = new Map< number, { ruleId: number; ruleName: string; gracePeriodDays: number; deleteAfter: string; items: Array<{ id: number; title: string; type: string }>; } >(); let latestDeleteAfter = matches[0]!.deleteAfter; for (const m of matches) { if (new Date(m.deleteAfter).getTime() > new Date(latestDeleteAfter).getTime()) { latestDeleteAfter = m.deleteAfter; } const existing = groupMap.get(m.rule.id); if (existing) { existing.items.push({ id: m.item.id, title: m.item.title, type: m.item.type }); } else { groupMap.set(m.rule.id, { ruleId: m.rule.id, ruleName: m.rule.name, gracePeriodDays: m.rule.grace_period_days ?? 7, deleteAfter: m.deleteAfter, items: [{ id: m.item.id, title: m.item.title, type: m.item.type }], }); } } const groups = Array.from(groupMap.values()).sort((a, b) => b.items.length - a.items.length); const count = matches.length; // Pick the grace period of the first (largest) group for the legacy top-level // fields, for back-compat with template fallbacks and downstream consumers. const primary = groups[0]!; try { await notifier.notify('ITEMS_MARKED', { groups, count, gracePeriodDays: primary.gracePeriodDays, deleteAfter: latestDeleteAfter, }); } catch (notifyError) { logger.error('Failed to send batched ITEMS_MARKED notification:', notifyError); } } // ============================================================================ // Library Scan Task // ============================================================================ /** * Run a full library scan and evaluate all items against configured rules. * Uses the database repos directly (same approach as manual scan in scan route). */ export async function scanLibraries(): Promise { const startedAt = new Date(); const taskName = 'scanLibraries'; logger.info('Starting library scan task'); // Create scan history record const scan = rulesRepo.scanHistory.start(); const scanId = scan.id; try { // Log scan start to activity log try { logActivity({ eventType: 'scan', action: 'started', actorType: 'scheduler', actorId: 'system', actorName: 'Scheduled Scan', targetType: null, targetId: null, targetTitle: null, metadata: JSON.stringify({ scanId }), }); } catch (activityError) { logger.warn('Failed to log scan start activity:', activityError); } // Get all enabled rules. Rules with a Disk pressure condition are left to // the disk-pressure monitor, which only removes enough to recover. const enabledRules = rulesRepo.rules.getEnabled().filter((rule) => !ruleUsesDiskPressure(rule)); logger.info(`Found ${enabledRules.length} enabled rule(s)`); // Get all monitored media items const mediaItems = mediaItemsRepo.fetchAll({ status: 'monitored' as any }); logger.info(`Found ${mediaItems.length} monitored media item(s)`); // Load exclusion patterns const exclusionPatterns = loadExclusionPatterns(); if (exclusionPatterns.length > 0) { logger.info(`Loaded ${exclusionPatterns.length} exclusion pattern(s)`); } const inProgress = loadInProgressConfig(); // Built once for the whole scan — includes the watch lookup, without which // watched_by conditions treat every item as never-watched. const ctx: EvaluationContext = buildEvaluationContext(); let itemsScanned = 0; let itemsFlagged = 0; let itemsProtected = 0; const queuedMatches: QueuedMatch[] = []; for (const item of mediaItems) { itemsScanned++; // Skip protected items if (item.is_protected) { itemsProtected++; continue; } // Skip items matching exclusion patterns if (exclusionPatterns.length > 0 && matchesExclusionPattern(item, exclusionPatterns)) { itemsProtected++; continue; } // Skip shows someone is part-way through (Settings → Safety) if (isProtectedInProgress(item, inProgress)) { itemsProtected++; continue; } // Check each rule for (const rule of enabledRules) { // Skip rule if the item is outside its scope (media type / libraries) if (!ruleScopeMatches(rule, item)) { continue; } const matches = evaluateRuleConditions(rule.conditions, item, ctx); if (matches) { logger.debug(`Rule "${rule.name}" matched item "${item.title}"`); switch (rule.action) { case 'flag': mediaItemsRepo.updateStatus(item.id, 'flagged'); itemsFlagged++; break; case 'delete': { const { deleteAfter } = queueItemForDeletion(item, rule); queuedMatches.push({ item, rule, deleteAfter }); itemsFlagged++; break; } case 'notify': logger.info(`Notification triggered for "${item.title}" by rule "${rule.name}"`); break; } // Only apply first matching rule break; } } } // One batched ITEMS_MARKED notification for the whole scan await notifyItemsQueued(queuedMatches); // Complete the scan history record rulesRepo.scanHistory.complete(scanId, itemsScanned, itemsFlagged); const completedAt = new Date(); const durationMs = completedAt.getTime() - startedAt.getTime(); // Send notification (respecting scan notification preference) const scanNotify = settingsRepo.getValue('notifications_scanNotify') || 'flagged_only'; if (dependencies.notificationService && scanNotify !== 'never') { if (scanNotify === 'always' || itemsFlagged > 0) { try { await dependencies.notificationService.notify('SCAN_COMPLETE', { itemsScanned, itemsFlagged, itemsProtected, durationMs, }); } catch (notifyError) { logger.error('Failed to send scan complete notification:', notifyError); } } } // Log scan completion to activity log try { logActivity({ eventType: 'scan', action: 'completed', actorType: 'scheduler', actorId: 'system', actorName: 'Scheduled Scan', targetType: 'scan', targetId: scanId, targetTitle: null, metadata: JSON.stringify({ itemsScanned, itemsFlagged, itemsProtected, duration: durationMs }), }); } catch (activityError) { logger.warn('Failed to log scan completion activity:', activityError); } logger.info( `Library scan completed: ${itemsScanned} items scanned, ` + `${itemsFlagged} flagged, ${itemsProtected} protected (${durationMs}ms)` ); return { success: true, taskName, startedAt, completedAt, durationMs, message: `Scan completed successfully`, data: { itemsScanned, itemsFlagged, itemsProtected, }, }; } catch (error) { const completedAt = new Date(); const durationMs = completedAt.getTime() - startedAt.getTime(); const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Library scan task failed:', error); // Update scan history with failure rulesRepo.scanHistory.fail(scanId); // Log scan failure to activity log try { logActivity({ eventType: 'scan', action: 'failed', actorType: 'scheduler', actorId: 'system', actorName: 'Scheduled Scan', targetType: 'scan', targetId: scanId, targetTitle: null, metadata: JSON.stringify({ error: errorMessage, duration: durationMs }), }); } catch (activityError) { logger.warn('Failed to log scan failure activity:', activityError); } // Send error notification if (dependencies.notificationService) { try { await dependencies.notificationService.notify('SCAN_ERROR', { error: errorMessage, phase: 'scanning', itemsScannedBeforeError: 0, timestamp: new Date().toISOString(), }); } catch (notifyError) { logger.error('Failed to send scan error notification:', notifyError); } } return { success: false, taskName, startedAt, completedAt, durationMs, error: errorMessage, data: { itemsScanned: 0, itemsFlagged: 0, itemsProtected: 0, }, }; } } // ============================================================================ // Deletion Processing Task // ============================================================================ /** * Process items that have passed their grace period and are ready for deletion. * Uses the DeletionService singleton directly. */ export async function processDeletionQueue(): Promise { const startedAt = new Date(); const taskName = 'processDeletionQueue'; logger.info('Starting deletion queue processing task'); try { const deletionService = getDeletionService(); // Snapshot the queue BEFORE deletion so we can enrich the notification with // item types and matched rule names (the DeletionResult stream only has title + id). const preSnapshot = await deletionService.getPendingDeletions(); const snapshotById = new Map(preSnapshot.map((p) => [p.id, p])); // Process pending deletions (not a dry run) const results = await deletionService.processPendingDeletions(false); // Queued episode/season deletions expire on the same schedule as whole // items. A failure in that queue must not cost us the item deletions that // already ran, so it degrades to "nothing processed" and is logged. let episodeResult = { deleted: 0, freedBytes: 0, outcomes: [] as Array<{ episodeId: number; success: boolean; error?: string }> }; try { episodeResult = await processDueEpisodeDeletions(); } catch (episodeError) { logger.error('Failed to process queued episode deletions:', episodeError); } const itemsDeleted = results.filter((r) => r.success).length + episodeResult.deleted; const spaceFreedBytes = results .filter((r) => r.success && r.fileSizeFreed) .reduce((sum, r) => sum + (r.fileSizeFreed || 0), 0) + episodeResult.freedBytes; const errors = results .filter((r) => !r.success) .map((r) => ({ itemId: r.itemId, error: r.error || 'Unknown error' })) .concat( episodeResult.outcomes .filter((o) => !o.success) .map((o) => ({ itemId: o.episodeId, error: o.error || 'Unknown error' })) ); const completedAt = new Date(); const durationMs = completedAt.getTime() - startedAt.getTime(); // Send notification if any items were deleted. Falls back to the global // notification service when DI hasn't been wired yet so the notification // never silently no-ops on us. if (itemsDeleted > 0) { const notifier = dependencies.notificationService ?? { notify: (event: string, data: Record) => getNotificationService().notify(event as any, data).then(() => undefined), }; // Resolve rule names once per ruleId (sync DB lookups — cheap, small cache) const ruleNameCache = new Map(); const resolveRuleName = (ruleId: number | undefined): string | undefined => { if (ruleId === undefined) return undefined; if (!ruleNameCache.has(ruleId)) { const rule = rulesRepo.rules.getById(ruleId); ruleNameCache.set(ruleId, rule?.name ?? `Rule #${ruleId}`); } return ruleNameCache.get(ruleId); }; const notifyItems = results .filter((r) => r.success) .map((r) => { const snap = snapshotById.get(r.itemId); return { title: r.title, type: snap?.mediaItem.type ?? 'unknown', ruleName: resolveRuleName(snap?.ruleId), }; }); try { await notifier.notify('DELETION_COMPLETE', { itemsDeleted, spaceFreedBytes, spaceFreedGB: (spaceFreedBytes / (1024 * 1024 * 1024)).toFixed(2), errors: errors.length, items: notifyItems, }); } catch (notifyError) { logger.error('Failed to send deletion complete notification:', notifyError); } } logger.info( `Deletion queue processing completed: ${itemsDeleted} items deleted, ` + `${(spaceFreedBytes / (1024 * 1024 * 1024)).toFixed(2)}GB freed (${durationMs}ms)` ); return { success: errors.length === 0, taskName, startedAt, completedAt, durationMs, message: `Processed ${results.length} items, deleted ${itemsDeleted}`, data: { itemsProcessed: results.length, itemsDeleted, spaceFreedBytes, errors, }, }; } catch (error) { const completedAt = new Date(); const durationMs = completedAt.getTime() - startedAt.getTime(); const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Deletion queue processing task failed:', error); // Send error notification if (dependencies.notificationService) { try { await dependencies.notificationService.notify('DELETION_ERROR', { error: errorMessage, itemsProcessedBeforeError: 0, timestamp: new Date().toISOString(), }); } catch (notifyError) { logger.error('Failed to send deletion error notification:', notifyError); } } return { success: false, taskName, startedAt, completedAt, durationMs, error: errorMessage, data: { itemsProcessed: 0, itemsDeleted: 0, spaceFreedBytes: 0, errors: [], }, }; } } // ============================================================================ // Deletion Reminder Task // ============================================================================ /** * Send notifications for items with upcoming deletions. * Uses the DeletionService singleton directly. */ export async function sendDeletionReminders(): Promise { const startedAt = new Date(); const taskName = 'sendDeletionReminders'; logger.info('Starting deletion reminder task'); try { const deletionService = getDeletionService(); // Falls back to the global notification service when DI hasn't been wired // (matches the pattern used by notifyItemsQueued and processDeletionQueue). const notifier = dependencies.notificationService ?? { notify: (event: string, data: Record) => getNotificationService().notify(event as any, data).then(() => undefined), }; // Get items in deletion queue const queue = await deletionService.getQueue(); // Filter items that are due for deletion within the next 24 hours const imminentDeletions = queue.filter((item) => item.daysRemaining <= 1); // Filter items that are due for deletion within the next 3 days (for warning) const upcomingDeletions = queue.filter( (item) => item.daysRemaining > 1 && item.daysRemaining <= 3 ); let remindersSent = 0; // Send imminent deletion notifications if (imminentDeletions.length > 0) { await notifier.notify('DELETION_IMMINENT', { items: imminentDeletions.map((item) => ({ id: item.id, title: item.mediaItem.title, daysRemaining: item.daysRemaining, })), count: imminentDeletions.length, urgency: 'high', }); remindersSent++; } // Send upcoming deletion warnings if (upcomingDeletions.length > 0) { await notifier.notify('DELETION_IMMINENT', { items: upcomingDeletions.map((item) => ({ id: item.id, title: item.mediaItem.title, daysRemaining: item.daysRemaining, })), count: upcomingDeletions.length, urgency: 'medium', }); remindersSent++; } const completedAt = new Date(); const durationMs = completedAt.getTime() - startedAt.getTime(); logger.info( `Deletion reminders sent: ${remindersSent} notifications, ` + `${queue.length} items pending deletion (${durationMs}ms)` ); return { success: true, taskName, startedAt, completedAt, durationMs, message: `Sent ${remindersSent} reminder notifications`, data: { remindersSent, itemsPendingDeletion: queue.length, }, }; } catch (error) { const completedAt = new Date(); const durationMs = completedAt.getTime() - startedAt.getTime(); const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Deletion reminder task failed:', error); return { success: false, taskName, startedAt, completedAt, durationMs, error: errorMessage, data: { remindersSent: 0, itemsPendingDeletion: 0, }, }; } } // ============================================================================ // Storage Snapshot Task // ============================================================================ /** * Capture a storage snapshot and prune old ones. */ export async function captureStorageSnapshot(): Promise { const startedAt = new Date(); const taskName = 'captureStorageSnapshot'; logger.info('Starting storage snapshot capture'); try { const snapshot = storageSnapshotsRepo.capture(); storageSnapshotsRepo.pruneOld(90); const completedAt = new Date(); const durationMs = completedAt.getTime() - startedAt.getTime(); logger.info(`Storage snapshot captured in ${durationMs}ms`); return { success: true, taskName, startedAt, completedAt, durationMs, message: 'Storage snapshot captured successfully', data: { totalSize: snapshot.total_size, itemCount: snapshot.item_count, }, }; } catch (error) { const completedAt = new Date(); const durationMs = completedAt.getTime() - startedAt.getTime(); const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Storage snapshot capture failed:', error); return { success: false, taskName, startedAt, completedAt, durationMs, error: errorMessage, }; } } /** * Capture a daily capacity snapshot for the trend/forecast UI, from Unraid * and from Sonarr/Radarr's drives, whichever are connected. Idempotent per * day per source. */ export async function captureUnraidCapacitySnapshot(): Promise { const startedAt = new Date(); const taskName = 'captureUnraidCapacitySnapshot'; // Sonarr/Radarr drives: reading them records the day's snapshot. if (storageAvailability().arr && !unraidSnapshotsRepo.hasTodaySnapshot('arr')) { try { await readArrStorage(); unraidSnapshotsRepo.pruneOld(400); } catch (error) { logger.warn('Sonarr/Radarr capacity snapshot failed:', error); } } const url = settingsRepo.getValue('unraid_url'); const apiKey = settingsRepo.getValue('unraid_apiKey'); if (!url || !apiKey) { const completedAt = new Date(); return { success: true, taskName, startedAt, completedAt, durationMs: completedAt.getTime() - startedAt.getTime(), message: 'Unraid not configured, skipping', }; } if (unraidSnapshotsRepo.hasTodaySnapshot()) { const completedAt = new Date(); return { success: true, taskName, startedAt, completedAt, durationMs: completedAt.getTime() - startedAt.getTime(), message: 'Snapshot already captured today', }; } try { const service = new UnraidService(url, apiKey); const stats = await service.getArrayStats(); const KB = 1024; const total = stats.capacity.kilobytes.total * KB; const used = stats.capacity.kilobytes.used * KB; const free = stats.capacity.kilobytes.free * KB; unraidSnapshotsRepo.capture({ total, used, free }); unraidSnapshotsRepo.pruneOld(400); const completedAt = new Date(); return { success: true, taskName, startedAt, completedAt, durationMs: completedAt.getTime() - startedAt.getTime(), message: 'Unraid capacity snapshot captured', data: { totalBytes: total, usedBytes: used, freeBytes: free }, }; } catch (error) { const completedAt = new Date(); const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Unraid capacity snapshot failed:', error); return { success: false, taskName, startedAt, completedAt, durationMs: completedAt.getTime() - startedAt.getTime(), error: errorMessage, }; } } // ============================================================================ // Exclusion Pattern Helpers // ============================================================================ export interface ExclusionPattern { field: 'title' | 'type'; operator: 'contains' | 'equals' | 'starts_with' | 'ends_with' | 'regex'; value: string; } /** * Load exclusion patterns from settings */ export function loadExclusionPatterns(): ExclusionPattern[] { try { const raw = settingsRepo.getValue('exclusion_patterns'); if (!raw) return []; return JSON.parse(raw) as ExclusionPattern[]; } catch { return []; } } /** * Check if a media item matches any exclusion pattern */ export function matchesExclusionPattern(item: MediaItem, patterns: ExclusionPattern[]): boolean { for (const pattern of patterns) { const fieldValue = pattern.field === 'title' ? item.title : item.type; if (!fieldValue) continue; let matches = false; switch (pattern.operator) { case 'contains': matches = fieldValue.toLowerCase().includes(pattern.value.toLowerCase()); break; case 'equals': matches = fieldValue.toLowerCase() === pattern.value.toLowerCase(); break; case 'starts_with': matches = fieldValue.toLowerCase().startsWith(pattern.value.toLowerCase()); break; case 'ends_with': matches = fieldValue.toLowerCase().endsWith(pattern.value.toLowerCase()); break; case 'regex': try { matches = new RegExp(pattern.value, 'i').test(fieldValue); } catch { matches = false; } break; } if (matches) return true; } return false; } // ============================================================================ // Plex Users Sync Task // ============================================================================ /** * Sync Plex users (owner + shared/home users) to the local database. * Requires Plex URL and token to be configured in settings. */ export async function syncPlexUsers(): Promise { const startedAt = new Date(); const taskName = 'syncPlexUsers'; logger.info('Starting Plex users sync task'); try { const plexUrl = settingsRepo.getValue('plex_url'); const plexToken = settingsRepo.getValue('plex_token'); if (!plexUrl || !plexToken) { const completedAt = new Date(); const durationMs = completedAt.getTime() - startedAt.getTime(); logger.warn('Plex users sync skipped: Plex not configured'); return { success: true, taskName, startedAt, completedAt, durationMs, message: 'Skipped — Plex not configured', data: { usersSynced: 0 }, }; } const service = new PlexUsersService(plexUrl, plexToken); const users = await service.syncUsers(); const completedAt = new Date(); const durationMs = completedAt.getTime() - startedAt.getTime(); logger.info(`Plex users sync completed: ${users.length} users (${durationMs}ms)`); return { success: true, taskName, startedAt, completedAt, durationMs, message: `Synced ${users.length} Plex user(s)`, data: { usersSynced: users.length }, }; } catch (error) { const completedAt = new Date(); const durationMs = completedAt.getTime() - startedAt.getTime(); const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Plex users sync task failed:', error); return { success: false, taskName, startedAt, completedAt, durationMs, error: errorMessage, data: { usersSynced: 0 }, }; } } // ============================================================================ // Plex Library Sync Task // ============================================================================ /** * Pull the catalog from Plex into the local DB on a schedule, so users don't * have to click "Sync Library" manually to pick up new movies/shows. Shares * the syncCoordinator's in-progress flag with the manual button so the two * can never run at the same time. */ export async function syncPlexLibrary(): Promise { const startedAt = new Date(); const taskName = 'syncPlexLibrary'; logger.info('Starting scheduled Plex library sync'); // Early-exit if another sync is already running so we don't write an orphan // 'started' activity entry that never gets a matching 'completed'. The // isSyncInProgress() check and the runLibrarySync() call below are both // synchronous in JS's single-threaded loop, so the coordinator's internal // 'busy' guard is only reached if we somehow miss here — handled below // as a defensive fallback. if (isSyncInProgress()) { const completedAt = new Date(); const durationMs = completedAt.getTime() - startedAt.getTime(); logger.warn('Scheduled Plex sync skipped: another sync is already running'); try { logActivity({ eventType: 'scan', action: 'skipped', actorType: 'scheduler', actorId: 'system', actorName: 'Scheduled Plex Sync', targetType: null, targetId: null, targetTitle: null, metadata: JSON.stringify({ reason: 'another sync is already running' }), }); } catch (activityError) { logger.warn('Failed to log Plex sync skipped activity:', activityError); } return { success: true, taskName, startedAt, completedAt, durationMs, message: 'Skipped — another sync was already running', data: { skipped: true }, }; } try { logActivity({ eventType: 'scan', action: 'started', actorType: 'scheduler', actorId: 'system', actorName: 'Scheduled Plex Sync', targetType: null, targetId: null, targetTitle: null, metadata: null, }); } catch (activityError) { logger.warn('Failed to log Plex sync start activity:', activityError); } const outcome = await runLibrarySync(); const completedAt = new Date(); const durationMs = completedAt.getTime() - startedAt.getTime(); // Defensive: a coordinator-level 'busy' should be impossible here given the // pre-check, but handle it anyway so the run is still represented in the log. if (outcome.status === 'busy') { try { logActivity({ eventType: 'scan', action: 'skipped', actorType: 'scheduler', actorId: 'system', actorName: 'Scheduled Plex Sync', targetType: null, targetId: null, targetTitle: null, metadata: JSON.stringify({ reason: 'coordinator reported busy after pre-check' }), }); } catch (activityError) { logger.warn('Failed to log Plex sync skipped activity:', activityError); } return { success: true, taskName, startedAt, completedAt, durationMs, message: 'Skipped — coordinator reported busy', data: { skipped: true }, }; } if (outcome.status === 'failed') { try { logActivity({ eventType: 'scan', action: 'failed', actorType: 'scheduler', actorId: 'system', actorName: 'Scheduled Plex Sync', targetType: null, targetId: null, targetTitle: null, metadata: JSON.stringify({ error: outcome.error, duration: durationMs }), }); } catch (activityError) { logger.warn('Failed to log Plex sync failure activity:', activityError); } return { success: false, taskName, startedAt, completedAt, durationMs, error: outcome.error, }; } const result = outcome.result!; const errorCount = result.errors.length; try { logActivity({ eventType: 'scan', action: 'completed', actorType: 'scheduler', actorId: 'system', actorName: 'Scheduled Plex Sync', targetType: null, targetId: null, targetTitle: null, metadata: JSON.stringify({ itemsScanned: result.itemsScanned, itemsAdded: result.itemsAdded, itemsUpdated: result.itemsUpdated, errors: errorCount, duration: durationMs, }), }); } catch (activityError) { logger.warn('Failed to log Plex sync completion activity:', activityError); } logger.info( `Scheduled Plex sync completed: ${result.itemsScanned} scanned, ` + `${result.itemsAdded} added, ${result.itemsUpdated} updated, ${errorCount} errors (${durationMs}ms)` ); return { success: errorCount === 0, taskName, startedAt, completedAt, durationMs, message: `Synced ${result.itemsScanned} items from Plex`, data: { itemsScanned: result.itemsScanned, itemsAdded: result.itemsAdded, itemsUpdated: result.itemsUpdated, errors: errorCount, }, }; } // ============================================================================ // Task Registry // ============================================================================ // ============================================================================ // Disk-Pressure Reactive Monitor // ============================================================================ /** Read the configured media paths for the disk-pressure monitor. */ export function loadDiskPressurePaths(): string[] { try { const raw = settingsRepo.getValue('diskPressure_paths'); if (!raw) return []; const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.filter((p) => typeof p === 'string' && p.trim().length > 0) : []; } catch { return []; } } interface DiskPressureConfig { targetMode: TargetMode; targetValue: number; criticalValue: number; bufferGb: number; observeOnly: boolean; softGraceDays: number; criticalGraceDays: number; criticalAutoProcess: boolean; deletionAction: DeletionAction; maxItemsPerRun: number; maxGbPerRun: number; unwatchedDays: number; } function loadDiskPressureConfig(): DiskPressureConfig { const action = settingsRepo.getValue('diskPressure_deletionAction') as DeletionAction | null; const validAction = action && Object.values(DeletionAction).includes(action) ? action : DeletionAction.UNMONITOR_AND_DELETE; return { targetMode: (settingsRepo.getValue('diskPressure_targetMode') as TargetMode) || 'percent', targetValue: settingsRepo.getNumber('diskPressure_targetValue', 10), criticalValue: settingsRepo.getNumber('diskPressure_criticalValue', 5), bufferGb: settingsRepo.getNumber('diskPressure_bufferGb', 50), observeOnly: settingsRepo.getBoolean('diskPressure_observeOnly', true), softGraceDays: settingsRepo.getNumber('diskPressure_softGraceDays', 7), criticalGraceDays: settingsRepo.getNumber('diskPressure_criticalGraceDays', 0), criticalAutoProcess: settingsRepo.getBoolean('diskPressure_criticalAutoProcess', false), deletionAction: validAction, maxItemsPerRun: settingsRepo.getNumber('diskPressure_maxItemsPerRun', 25), maxGbPerRun: settingsRepo.getNumber('diskPressure_maxGbPerRun', 500), unwatchedDays: settingsRepo.getNumber('diskPressure_unwatchedDays', 90), }; } /** * Rank candidate items for reclamation, reusing the recommendations ordering * (oldest-watched first, then largest). Excludes protected / already-pending * items and anything matching the user's exclusion patterns. When a breached * path is given, items on that filesystem are preferred. */ /** Whether an item is stored on the breached filesystem, as far as can be told. */ function isOnFilesystem(item: MediaItem, fs: FsUsage): boolean { const prefixes = [fs.path, ...(fs.pathPrefixes ?? [])].filter((p) => p.length > 0); if (item.file_path && prefixes.some((p) => item.file_path!.startsWith(p))) return true; // Sonarr/Radarr report their own container paths, which often differ from // the paths stored here. A drive only one app uses at least says which kind. if (fs.apps?.length === 1) return fs.apps[0] === 'sonarr' ? item.type === 'show' : item.type === 'movie'; return false; } /** A reclaim candidate, and the Disk pressure rule that picked it (null for the built-in pick). */ interface PressureCandidate { item: MediaItem; rule: Rule | null; } /** Items no cleanup may touch: protected, already queued, excluded, or part-watched. */ function pressureCandidateFilter(): (item: MediaItem) => boolean { const exclusionPatterns = loadExclusionPatterns(); const inProgress = loadInProgressConfig(); return (item) => !item.is_protected && item.status !== 'pending_deletion' && !(exclusionPatterns.length > 0 && matchesExclusionPattern(item, exclusionPatterns)) && !isProtectedInProgress(item, inProgress); } function rankCandidates(candidates: PressureCandidate[], breached: FsUsage): PressureCandidate[] { return candidates.sort(({ item: a }, { item: b }) => { // Prefer items stored on the breached filesystem (so we free the right one) const aOnPath = isOnFilesystem(a, breached) ? 0 : 1; const bOnPath = isOnFilesystem(b, breached) ? 0 : 1; if (aOnPath !== bOnPath) return aOnPath - bOnPath; // Oldest watched first const aDate = a.last_watched_at ? new Date(a.last_watched_at).getTime() : 0; const bDate = b.last_watched_at ? new Date(b.last_watched_at).getTime() : 0; if (aDate !== bDate) return aDate - bDate; // Then largest first return (b.file_size || 0) - (a.file_size || 0); }); } function selectDiskPressureCandidates(unwatchedDays: number, breached: FsUsage): PressureCandidate[] { const allowed = pressureCandidateFilter(); const candidates = mediaItemsRepo .getUnwatched(unwatchedDays) .filter(allowed) .map((item) => ({ item, rule: null })); return rankCandidates(candidates, breached); } /** * Candidates from the rules that use the Disk pressure condition. Like the * scan, an item goes to the first rule (by priority) that matches it. */ function selectRuleCandidates(rules: Rule[], pressure: DiskPressureState, breached: FsUsage): PressureCandidate[] { const parsed: Array<{ rule: Rule; root: ReturnType['root'] }> = []; for (const rule of rules) { try { parsed.push({ rule, root: upgradeToV2(JSON.parse(rule.conditions)).root }); } catch (error) { logger.warn(`Disk-pressure: skipped rule "${rule.name}", its conditions could not be read`, error); } } const allowed = pressureCandidateFilter(); const ctx: EvaluationContext = { ...buildEvaluationContext(), diskPressure: pressure }; const candidates: PressureCandidate[] = []; for (const item of mediaItemsRepo.fetchAll({ status: 'monitored' as MediaItem['status'] })) { if (!allowed(item)) continue; const match = parsed.find(({ rule, root }) => ruleScopeMatches(rule, item) && evaluateNode(root, item, ctx)); if (match) candidates.push({ item, rule: match.rule }); } return rankCandidates(candidates, breached); } /** * Reactive disk-pressure monitor. Runs frequently; when free space on a * configured filesystem drops below the soft/critical threshold, it reclaims * just enough lowest-value content to recover (or, in observe-only mode, only * reports what it would do). Self-disables when `diskPressure_enabled` is off. */ export async function monitorDiskPressure(): Promise { const startedAt = new Date(); const taskName = 'monitorDiskPressure'; const done = (message: string, data?: Record, success = true): TaskResult => { const completedAt = new Date(); return { success, taskName, startedAt, completedAt, durationMs: completedAt.getTime() - startedAt.getTime(), message, data }; }; if (!settingsRepo.getBoolean('diskPressure_enabled', false)) { return done('Disk-pressure monitoring disabled'); } // Don't fight the nightly scan/sync over the same items. if (isSyncInProgress() || rulesRepo.scanHistory.getRunning()) { return done('Scan or sync in progress, skipping disk-pressure check'); } // Typed-in folder paths win; otherwise watch the chosen storage source // (Unraid's array, or Sonarr/Radarr's media drives). const paths = loadDiskPressurePaths(); const { usages, source } = await getPressureUsages(paths); if (!source) { return done('No media paths or storage source configured for disk-pressure monitoring'); } const cfg = loadDiskPressureConfig(); if (usages.length === 0) { return done(source === 'paths' ? 'Could not read any configured paths' : `Could not read storage from ${source}`, undefined, true); } // Find the most-breached filesystem (largest deficit vs its soft target). type Breach = { fs: FsUsage; severity: 'soft' | 'critical'; softTarget: number; criticalTarget: number; deficit: number }; let worst: Breach | null = null; for (const fs of usages) { const softTarget = resolveTargetBytes(fs, cfg.targetMode, cfg.targetValue); const criticalTarget = resolveTargetBytes(fs, cfg.targetMode, cfg.criticalValue); const severity = fs.freeBytes < criticalTarget ? 'critical' : fs.freeBytes < softTarget ? 'soft' : null; if (!severity) continue; const deficit = softTarget - fs.freeBytes; if (!worst || deficit > worst.deficit) { worst = { fs, severity, softTarget, criticalTarget, deficit }; } } if (!worst) { return done('All filesystems above target', { checked: usages.length }); } const { fs, severity, softTarget, deficit } = worst; const targetBytes = severity === 'critical' ? worst.criticalTarget : softTarget; // Reclaim enough to clear the soft target plus a buffer so we don't re-trigger. const reclaimGoal = deficit + cfg.bufferGb * GiB; const maxBytes = cfg.maxGbPerRun * GiB; // Rules with a Disk pressure condition choose what goes; without any, the // built-in pick takes anything unwatched for long enough. With one watched // drive (one pool, one array) everything is on it; otherwise a rule only // matches items known to be on the drive that's low. const pressureRules = rulesRepo.rules .getEnabled() .filter((rule) => rule.action === 'delete' && ruleUsesDiskPressure(rule)); const pressure: DiskPressureState = { severity, isOnDrive: usages.length === 1 ? () => true : (item) => isOnFilesystem(item, fs), }; // What's already queued on this drive gets freed when its grace period ends, // so it counts toward the goal; otherwise every check would queue another // batch until the grace period ran out. Under critical pressure only what // goes within the critical grace period counts. const freedBy = severity === 'critical' ? startedAt.getTime() + cfg.criticalGraceDays * 86_400_000 : Infinity; const alreadyQueued = mediaItemsRepo .getPendingDeletion() .filter((item) => { const action = (item as MediaItem & { deletion_action?: string | null }).deletion_action; if (action === DeletionAction.UNMONITOR_ONLY || !pressure.isOnDrive(item)) return false; return !item.delete_after || new Date(item.delete_after).getTime() <= freedBy; }) .reduce((sum, item) => sum + (item.file_size || 0), 0); if (alreadyQueued >= reclaimGoal) { return done(`Enough is already queued for ${severity} pressure on ${fs.path}`, { severity, path: fs.path, alreadyQueuedBytes: alreadyQueued, }); } const ranked = pressureRules.length > 0 ? selectRuleCandidates(pressureRules, pressure, fs) : selectDiskPressureCandidates(cfg.unwatchedDays, fs); // A rule's own deletion action applies to what it picked. const actionFor = (c: PressureCandidate) => (c.rule?.deletion_action as DeletionAction | undefined) ?? cfg.deletionAction; // Pick items until we've projected enough reclaim or hit a safety cap. const chosen: PressureCandidate[] = []; let projected = 0; for (const candidate of ranked) { if (chosen.length >= cfg.maxItemsPerRun) break; if (alreadyQueued + projected >= reclaimGoal) break; const bytes = actionFor(candidate) !== DeletionAction.UNMONITOR_ONLY ? candidate.item.file_size || 0 : 0; if (bytes > 0 && projected + bytes > maxBytes && chosen.length > 0) break; chosen.push(candidate); projected += bytes; } // Critical pressure always uses the critical grace period. Otherwise a rule // keeps its own grace period, and the built-in pick uses the soft one. const graceFor = (c: PressureCandidate) => severity === 'critical' ? cfg.criticalGraceDays : (c.rule?.grace_period_days ?? cfg.softGraceDays); // Queue (unless observe-only). Suppress per-item notifications so we emit a // single DISK_PRESSURE_TRIGGERED event below instead of ITEMS_MARKED noise. let itemsQueued = 0; if (!cfg.observeOnly && chosen.length > 0) { const deletionService = getDeletionService(); for (const candidate of chosen) { const { item, rule } = candidate; try { await deletionService.markForDeletion(item.id, { gracePeriodDays: graceFor(candidate), deletionAction: actionFor(candidate), skipNotification: true, ...(rule ? { ruleId: rule.id, resetOverseerr: Boolean(rule.reset_overseerr) } : {}), }); itemsQueued++; } catch (error) { logger.warn(`Disk-pressure: failed to queue item ${item.id}: ${error instanceof Error ? error.message : String(error)}`); } } // Critical + auto-process: reclaim immediately rather than waiting for grace. if (severity === 'critical' && cfg.criticalAutoProcess && itemsQueued > 0) { try { await deletionService.processPendingDeletions(false); } catch (error) { logger.error('Disk-pressure: critical auto-process failed:', error); } } } // Emit the event (Discord + webhooks). Use DI notifier when wired. const notifier = dependencies.notificationService ?? { notify: (event: string, data: Record) => getNotificationService().notify(event as any, data).then(() => undefined), }; const eventData: DiskPressureData = { severity, path: fs.path, freeBytes: fs.freeBytes, totalBytes: fs.totalBytes, targetBytes, deficitBytes: Math.max(0, deficit), observeOnly: cfg.observeOnly, itemsQueued, projectedReclaimBytes: projected, deletionAction: chosen[0] ? actionFor(chosen[0]) : cfg.deletionAction, items: chosen.map(({ item: i }) => ({ id: i.id, title: i.title, type: i.type, sizeBytes: i.file_size || 0 })), timestamp: startedAt.toISOString(), }; try { await notifier.notify('DISK_PRESSURE_TRIGGERED', eventData as unknown as Record); } catch (error) { logger.error('Disk-pressure: failed to send notification:', error); } // Activity log try { logActivity({ eventType: 'disk_pressure', action: cfg.observeOnly ? `Disk pressure (${severity}) on ${fs.path} — ${chosen.length} item(s) would be reclaimed (observe-only)` : `Disk pressure (${severity}) on ${fs.path} — queued ${itemsQueued} item(s)`, actorType: 'scheduler', actorName: 'Disk-pressure monitor', metadata: JSON.stringify({ severity, path: fs.path, freeBytes: fs.freeBytes, targetBytes, itemsQueued, observeOnly: cfg.observeOnly, projectedReclaimBytes: projected, rules: pressureRules.map((rule) => rule.name), }), }); } catch (error) { logger.warn('Disk-pressure: failed to log activity:', error); } return done( cfg.observeOnly ? `Observe-only: ${chosen.length} item(s) flagged for ${severity} pressure on ${fs.path}` : `Queued ${itemsQueued} item(s) for ${severity} pressure on ${fs.path}`, { severity, path: fs.path, itemsQueued, projectedReclaimBytes: projected, observeOnly: cfg.observeOnly } ); } // ============================================================================ // Telemetry Heartbeat // ============================================================================ /** * Send the anonymous install-count heartbeat. * * Always reports success: a heartbeat that could not be delivered — because * telemetry is off, because it is not due yet, or because the machine is * offline — is a normal outcome, not a failed scheduled task. Nobody should * see a red job in Prunerr because a counter somewhere missed a tick. */ export async function sendTelemetryHeartbeat(): Promise { const startedAt = new Date(); const taskName = 'sendTelemetryHeartbeat'; const result = await sendHeartbeat(); const completedAt = new Date(); const messages: Record = { disabled: 'Telemetry is disabled — nothing sent', 'not-due': 'Heartbeat not due yet', failed: 'Heartbeat could not be delivered — will retry on the next run', }; return { success: true, taskName, startedAt, completedAt, durationMs: completedAt.getTime() - startedAt.getTime(), message: result.sent ? 'Heartbeat sent' : messages[result.reason ?? 'failed'], data: { sent: result.sent, reason: result.reason ?? null }, }; } // ============================================================================ // Announcements Feed // ============================================================================ /** * Refresh the "What's new" feed. * * Always reports success for the same reason the heartbeat does: a feed that * could not be fetched — switched off, not due, or offline — is a normal * outcome, and the cached copy keeps serving the panel in the meantime. */ export async function refreshAnnouncements(): Promise { const startedAt = new Date(); const taskName = 'refreshAnnouncements'; const result = await fetchAnnouncementsFeed(); const completedAt = new Date(); const messages: Record = { disabled: 'Announcements are disabled — nothing fetched', 'not-due': 'Feed refresh not due yet', failed: 'Feed could not be fetched — the cached copy stays in use', }; return { success: true, taskName, startedAt, completedAt, durationMs: completedAt.getTime() - startedAt.getTime(), message: result.fetched ? `Feed refreshed (${result.count ?? 0} entries)` : messages[result.reason ?? 'failed'], data: { fetched: result.fetched, count: result.count ?? null, reason: result.reason ?? null }, }; } export type TaskFunction = () => Promise; export const taskRegistry: Record = { syncPlexLibrary, scanLibraries, processDeletionQueue, sendDeletionReminders, captureStorageSnapshot, captureUnraidCapacitySnapshot, syncPlexUsers, monitorDiskPressure, sendTelemetryHeartbeat, refreshAnnouncements, }; /** * Get a task function by name */ export function getTask(taskName: string): TaskFunction | undefined { return taskRegistry[taskName]; } /** * Get all available task names */ export function getAvailableTasks(): string[] { return Object.keys(taskRegistry); }