server/src/rules/engine.ts
Rama main · réplica de lectura del repositorio original.
import type { MediaItem, Rule } from '../types';
import type {
ItemEvaluationResult,
EvaluationResult,
EvaluationSummary,
ProtectionResult,
ProtectionConfig,
ExtendedRuleCondition,
ParsedRule,
RulesConfig,
Action,
LogicOperator,
} from './types';
import { Action as ActionEnum, DEFAULT_RULES_CONFIG, DeletionAction } from './types';
import { evaluateConditions, evaluateNode, extendMediaItem } from './conditions';
import type { EvaluationContext } from './conditions';
import { upgradeToV2 } from './migration';
import logger from '../utils/logger';
export { evaluateNode, upgradeToV2 };
export type { EvaluationContext };
/**
* Evaluate a rule's stored `conditions` JSON against a single media item.
*
* Parses JSON, upgrades v1 → v2, then walks the tree. This is the canonical
* entry point for all rule evaluation in Prunerr v1.3+.
*/
export function evaluateRuleConditions(
rawConditionsJson: string,
item: MediaItem,
ctx: EvaluationContext = {}
): boolean {
let parsed: unknown;
try {
parsed = JSON.parse(rawConditionsJson);
} catch (error) {
logger.error('Failed to parse rule conditions JSON:', error);
return false;
}
const v2 = upgradeToV2(parsed);
return evaluateNode(v2.root, item, ctx);
}
// ============================================================================
// Default Protection Configuration
// ============================================================================
const DEFAULT_PROTECTION_CONFIG: ProtectionConfig = {
protectRecentlyAdded: true,
recentlyAddedDays: 30,
protectRecentlyWatched: true,
recentlyWatchedDays: 7,
protectInProgress: true,
protectedGenres: [],
protectedTags: [],
protectedRatings: 8.0,
};
// ============================================================================
// Rules Engine Class
// ============================================================================
/**
* Rules evaluation engine for determining media item deletion eligibility
*/
export class RulesEngine {
private config: RulesConfig;
private protectionConfig: ProtectionConfig;
private mediaItemRepository: MediaItemRepository | null = null;
private rulesRepository: RulesRepository | null = null;
constructor(
config: Partial<RulesConfig> = {},
protectionConfig: Partial<ProtectionConfig> = {}
) {
this.config = { ...DEFAULT_RULES_CONFIG, ...config };
this.protectionConfig = { ...DEFAULT_PROTECTION_CONFIG, ...protectionConfig };
}
/**
* Set the media item repository for database access
*/
setMediaItemRepository(repository: MediaItemRepository): void {
this.mediaItemRepository = repository;
}
/**
* Set the rules repository for database access
*/
setRulesRepository(repository: RulesRepository): void {
this.rulesRepository = repository;
}
// ============================================================================
// Rule Parsing
// ============================================================================
/**
* Parse a rule from the database format to the evaluation format
*/
private parseRule(rule: Rule): ParsedRule {
let conditions: ExtendedRuleCondition[] = [];
let conditionLogic: LogicOperator = this.config.defaultConditionLogic;
let gracePeriodDays = this.config.defaultGracePeriodDays;
let deletionAction = this.config.defaultDeletionAction;
try {
// Parse conditions from JSON string
const parsed = JSON.parse(rule.conditions);
if (Array.isArray(parsed)) {
// Simple array of conditions
conditions = parsed as ExtendedRuleCondition[];
} else if (typeof parsed === 'object') {
// Object with conditions and metadata
conditions = parsed.conditions || [];
conditionLogic = parsed.logic || conditionLogic;
gracePeriodDays = parsed.gracePeriodDays ?? gracePeriodDays;
deletionAction = parsed.deletionAction || deletionAction;
}
} catch (error) {
logger.error(`Failed to parse conditions for rule "${rule.name}":`, error);
}
return {
...rule,
conditions,
conditionLogic,
gracePeriodDays,
deletionAction,
};
}
// ============================================================================
// Protection Rules
// ============================================================================
/**
* Apply protection rules to determine if an item should be protected from deletion
*/
applyProtectionRules(item: MediaItem): ProtectionResult {
const extendedItem = extendMediaItem(item);
// Check if item is already marked as protected
if (item.is_protected) {
return {
isProtected: true,
reason: item.protection_reason || 'Manually protected',
};
}
// Check recently added protection
if (this.protectionConfig.protectRecentlyAdded && extendedItem.daysSinceAdded !== undefined) {
if (extendedItem.daysSinceAdded < this.protectionConfig.recentlyAddedDays) {
return {
isProtected: true,
reason: `Recently added (${extendedItem.daysSinceAdded} days ago)`,
};
}
}
// Check recently watched protection
if (this.protectionConfig.protectRecentlyWatched && extendedItem.daysSinceLastWatched !== undefined) {
if (extendedItem.daysSinceLastWatched < this.protectionConfig.recentlyWatchedDays) {
return {
isProtected: true,
reason: `Recently watched (${extendedItem.daysSinceLastWatched} days ago)`,
};
}
}
// Check in-progress protection
if (this.protectionConfig.protectInProgress && extendedItem.inProgress) {
return {
isProtected: true,
reason: 'Currently in progress',
};
}
// Check protected genres
if (this.protectionConfig.protectedGenres.length > 0 && extendedItem.genres) {
const protectedGenre = this.protectionConfig.protectedGenres.find((genre) =>
extendedItem.genres?.map((g) => g.toLowerCase()).includes(genre.toLowerCase())
);
if (protectedGenre) {
return {
isProtected: true,
reason: `Protected genre: ${protectedGenre}`,
};
}
}
// Check protected tags
if (this.protectionConfig.protectedTags.length > 0 && extendedItem.tags) {
const protectedTag = this.protectionConfig.protectedTags.find((tag) =>
extendedItem.tags?.map((t) => t.toLowerCase()).includes(tag.toLowerCase())
);
if (protectedTag) {
return {
isProtected: true,
reason: `Protected tag: ${protectedTag}`,
};
}
}
// Check rating protection
if (extendedItem.rating !== undefined && extendedItem.rating >= this.protectionConfig.protectedRatings) {
return {
isProtected: true,
reason: `High rating: ${extendedItem.rating}`,
};
}
return { isProtected: false };
}
// ============================================================================
// Single Item Evaluation
// ============================================================================
/**
* Evaluate a single media item against a list of rules
*/
evaluateItem(item: MediaItem, rules: Rule[]): ItemEvaluationResult {
logger.debug(`Evaluating item "${item.title}" against ${rules.length} rules`);
// Check protection first
const protectionResult = this.applyProtectionRules(item);
if (protectionResult.isProtected) {
logger.debug(`Item "${item.title}" is protected: ${protectionResult.reason}`);
return {
matched: false,
action: ActionEnum.PROTECT,
};
}
// Evaluate against each enabled rule
for (const rule of rules) {
if (!rule.enabled) {
continue;
}
const parsedRule = this.parseRule(rule);
if (parsedRule.conditions.length === 0) {
logger.debug(`Rule "${rule.name}" has no conditions, skipping`);
continue;
}
// Use the v2 tree walker via the canonical entry point. This handles
// both v1 (legacy) and v2 condition payloads.
const matched = evaluateRuleConditions(rule.conditions, item);
if (matched) {
logger.info(`Item "${item.title}" matched rule "${rule.name}"`);
return {
matched: true,
rule,
action: this.mapRuleActionToAction(rule.action),
matchedConditions: parsedRule.conditions,
};
}
// Retain legacy flat evaluator call as a no-op reference to avoid
// regressions in consumers that still import evaluateConditions.
void evaluateConditions;
}
logger.debug(`Item "${item.title}" did not match any rules`);
return { matched: false };
}
/**
* Map rule action string to Action enum
*/
private mapRuleActionToAction(ruleAction: string): Action {
switch (ruleAction) {
case 'flag':
case 'delete':
return ActionEnum.MARK_FOR_DELETION;
case 'notify':
return ActionEnum.IGNORE;
default:
return ActionEnum.MARK_FOR_DELETION;
}
}
// ============================================================================
// Bulk Evaluation
// ============================================================================
/**
* Evaluate all media items against configured rules
*/
async evaluateAll(rules?: Rule[]): Promise<EvaluationSummary> {
const startedAt = new Date();
logger.info('Starting full evaluation of all media items');
// Get rules from repository if not provided
let rulesToUse = rules;
if (!rulesToUse && this.rulesRepository) {
rulesToUse = await this.rulesRepository.getEnabledRules();
}
if (!rulesToUse || rulesToUse.length === 0) {
logger.warn('No rules available for evaluation');
return {
itemsEvaluated: 0,
itemsFlagged: 0,
itemsProtected: 0,
itemsIgnored: 0,
results: [],
startedAt,
completedAt: new Date(),
durationMs: 0,
};
}
// Get media items from repository
if (!this.mediaItemRepository) {
throw new Error('Media item repository not configured');
}
const items = await this.mediaItemRepository.getItemsForEvaluation(
this.config.maxItemsPerRun
);
logger.info(`Evaluating ${items.length} items against ${rulesToUse.length} rules`);
const results: EvaluationResult[] = [];
let itemsFlagged = 0;
let itemsProtected = 0;
let itemsIgnored = 0;
for (const item of items) {
const protectionResult = this.applyProtectionRules(item);
const evaluationResult = this.evaluateItem(item, rulesToUse);
const result: EvaluationResult = {
item,
matched: evaluationResult.matched,
matchedRule: evaluationResult.rule,
action: evaluationResult.action,
isProtected: protectionResult.isProtected,
protectionReason: protectionResult.reason,
evaluatedAt: new Date(),
};
results.push(result);
if (protectionResult.isProtected) {
itemsProtected++;
} else if (evaluationResult.matched) {
itemsFlagged++;
} else {
itemsIgnored++;
}
}
const completedAt = new Date();
const durationMs = completedAt.getTime() - startedAt.getTime();
logger.info(
`Evaluation complete: ${items.length} items evaluated, ${itemsFlagged} flagged, ` +
`${itemsProtected} protected, ${itemsIgnored} ignored (${durationMs}ms)`
);
return {
itemsEvaluated: items.length,
itemsFlagged,
itemsProtected,
itemsIgnored,
results,
startedAt,
completedAt,
durationMs,
};
}
// ============================================================================
// Configuration Methods
// ============================================================================
/**
* Update the rules configuration
*/
updateConfig(config: Partial<RulesConfig>): void {
this.config = { ...this.config, ...config };
logger.info('Rules engine configuration updated');
}
/**
* Update the protection configuration
*/
updateProtectionConfig(config: Partial<ProtectionConfig>): void {
this.protectionConfig = { ...this.protectionConfig, ...config };
logger.info('Protection configuration updated');
}
/**
* Get current configuration
*/
getConfig(): RulesConfig {
return { ...this.config };
}
/**
* Get current protection configuration
*/
getProtectionConfig(): ProtectionConfig {
return { ...this.protectionConfig };
}
}
// ============================================================================
// Repository Interfaces
// ============================================================================
/**
* Interface for media item repository
*/
export interface MediaItemRepository {
getItemsForEvaluation(limit?: number): Promise<MediaItem[]>;
updateItemStatus(itemId: number, status: string): Promise<void>;
markForDeletion(itemId: number, deleteAfter: Date, ruleId?: number): Promise<void>;
}
/**
* Interface for rules repository
*/
export interface RulesRepository {
getEnabledRules(): Promise<Rule[]>;
getRuleById(id: number): Promise<Rule | null>;
}
// ============================================================================
// Singleton Instance
// ============================================================================
let rulesEngineInstance: RulesEngine | null = null;
/**
* Get the singleton rules engine instance
*/
export function getRulesEngine(): RulesEngine {
if (!rulesEngineInstance) {
rulesEngineInstance = new RulesEngine();
}
return rulesEngineInstance;
}
/**
* Create a new rules engine instance with custom configuration
*/
export function createRulesEngine(
config?: Partial<RulesConfig>,
protectionConfig?: Partial<ProtectionConfig>
): RulesEngine {
return new RulesEngine(config, protectionConfig);
}
export default RulesEngine;