Ctrl K
ComunidadGuías…

server/src/rules/conditions.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import type { MediaItem } from '../types';
import type {
  ConditionLeaf,
  ConditionNode,
  ExtendedRuleCondition,
  ExtendedMediaItem,
  ConditionType,
} from './types';
import logger from '../utils/logger';
import { IN_PROGRESS_DEFAULT_RECENT_DAYS, percentWatched, usersInProgress, watchProgress } from './watchProgress';
import { DISK_PRESSURE_FIELD, evaluateDiskPressure, type DiskPressureState } from './diskPressure';

// ============================================================================
// Evaluation Context (for JOIN-requiring evaluators)
// ============================================================================

/**
 * Shape of the collections repository used by evaluators. Kept narrow so
 * evaluators can be tested with a tiny mock.
 */
export interface CollectionsRepoLike {
  findAll(): Array<{ id: number; is_protected: number | boolean }>;
  findProtectedContainingItem(mediaItemId: number): Array<{ id: number }>;
  getMediaItemIds(collectionId: number): number[];
}

/**
 * Pre-built lookup: ratingKey → username → most-recent watched date.
 * The evaluation context builds this once per run for performance.
 */
export type WatchLookup = Map<string, Map<string, Date>>;

export interface EvaluationContext {
  collectionsRepo?: CollectionsRepoLike;
  /**
   * Pre-fetched watch history keyed by plex rating key and username.
   * Evaluators only read this — they never query the DB themselves.
   */
  watchLookup?: WatchLookup;
  /**
   * The same history keyed by lower-cased show title. History is recorded per
   * episode, so a show's own rating key finds nothing; its viewers are found
   * through the episodes' show title instead.
   */
  showWatchLookup?: WatchLookup;
  /** How recently an episode must have been watched for a show to be in progress. */
  inProgressRecentDays?: number;
  /** Set only while the disk-pressure monitor (or the rule preview) runs. */
  diskPressure?: DiskPressureState;
  now?: Date;
  /** Runtime cache for collection membership lookups — avoids repeated queries. */
  _collectionCache?: Map<string, Set<number>>;
}

// ============================================================================
// Helper Functions
// ============================================================================

function daysBetween(date1: Date, date2: Date): number {
  const oneDay = 24 * 60 * 60 * 1000;
  return Math.floor(Math.abs((date1.getTime() - date2.getTime()) / oneDay));
}

function parseResolution(resolution: string | null | undefined): number | undefined {
  if (!resolution) return undefined;
  const lower = resolution.toLowerCase();
  if (lower.includes('4k') || lower.includes('2160')) return 2160;
  if (lower.includes('1080')) return 1080;
  if (lower.includes('720')) return 720;
  if (lower.includes('480')) return 480;
  if (lower.includes('sd') || lower.includes('576')) return 576;
  const match = resolution.match(/(\d+)/);
  if (match && match[1]) return parseInt(match[1], 10);
  return undefined;
}

function bytesToGB(bytes: number | null | undefined): number | undefined {
  if (bytes === null || bytes === undefined) return undefined;
  return bytes / (1024 * 1024 * 1024);
}

export function extendMediaItem(item: MediaItem): ExtendedMediaItem {
  const now = new Date();
  return {
    ...item,
    daysSinceLastWatched: item.last_watched_at
      ? daysBetween(now, new Date(item.last_watched_at))
      : undefined,
    daysSinceAdded: item.added_at
      ? daysBetween(now, new Date(item.added_at))
      : undefined,
    fileSizeGB: bytesToGB(item.file_size),
    resolutionValue: parseResolution(item.resolution),
  };
}

// ============================================================================
// Field Value Resolution
// ============================================================================

type FieldValue = unknown;

/**
 * Resolve a field name to a value on the media item. Supports both direct
 * columns and computed fields. `now` is the moment day counts are measured
 * from: today, or a future date when forecasting.
 */
export function resolveFieldValue(item: MediaItem, field: string, now: Date = new Date()): FieldValue {
  switch (field) {
    case 'days_since_added': {
      if (!item.added_at) return null;
      return Math.floor(
        (now.getTime() - new Date(item.added_at).getTime()) / (1000 * 60 * 60 * 24)
      );
    }
    case 'days_since_watched': {
      if (!item.last_watched_at) return null;
      return Math.floor(
        (now.getTime() - new Date(item.last_watched_at).getTime()) / (1000 * 60 * 60 * 24)
      );
    }
    case 'size_gb':
      return item.file_size ? item.file_size / (1024 * 1024 * 1024) : null;
    case 'resolution_number':
      return parseResolution(item.resolution) ?? 0;
    case 'never_watched':
      return item.play_count === 0;
    case 'percent_watched':
      return percentWatched(item);
    case 'watched_by_count': {
      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: {
      const record = item as unknown as Record<string, unknown>;
      if (field in record) return record[field];
      return null;
    }
  }
}

// ============================================================================
// Operator Evaluators (work on resolved field value + condition value)
// ============================================================================

function toNumber(v: unknown): number | null {
  if (typeof v === 'number' && !isNaN(v)) return v;
  if (typeof v === 'string' && v.trim() !== '') {
    const n = Number(v);
    return isNaN(n) ? null : n;
  }
  return null;
}

function toStringLower(v: unknown): string | null {
  if (v === null || v === undefined) return null;
  // NFC normalization keeps accented chars in their composed form, so a value
  // typed in the UI matches stored data even if the source used combining marks.
  return String(v).normalize('NFC').toLowerCase();
}

function toArray(v: unknown): unknown[] {
  if (Array.isArray(v)) return v;
  if (typeof v === 'string') {
    // Parse JSON array, fallback to comma-split
    try {
      const parsed = JSON.parse(v);
      if (Array.isArray(parsed)) return parsed;
    } catch {
      // ignore
    }
    return v
      .split(',')
      .map((s) => s.trim())
      .filter(Boolean);
  }
  return [];
}

function lowerArray(arr: unknown[]): string[] {
  return arr.map((x) => String(x).normalize('NFC').toLowerCase());
}

function evalOperator(
  operator: string,
  fieldValue: unknown,
  conditionValue: unknown
): boolean {
  switch (operator) {
    // Equality / comparison
    case 'equals':
      return fieldValue === conditionValue;
    case 'not_equals':
      return fieldValue !== conditionValue;
    case 'greater_than': {
      const a = toNumber(fieldValue);
      const b = toNumber(conditionValue);
      return a !== null && b !== null && a > b;
    }
    case 'less_than': {
      const a = toNumber(fieldValue);
      const b = toNumber(conditionValue);
      return a !== null && b !== null && a < b;
    }
    case 'greater_than_or_equal': {
      const a = toNumber(fieldValue);
      const b = toNumber(conditionValue);
      return a !== null && b !== null && a >= b;
    }
    case 'less_than_or_equal': {
      const a = toNumber(fieldValue);
      const b = toNumber(conditionValue);
      return a !== null && b !== null && a <= b;
    }

    // String containment (single-value field, substring match)
    case 'contains': {
      const fv = toStringLower(fieldValue);
      const cv = toStringLower(conditionValue);
      return fv !== null && cv !== null && fv.includes(cv);
    }
    case 'not_contains': {
      // List value (e.g. tags): true when the field holds none of them. An
      // empty list is an unfinished condition and matches nothing.
      if (Array.isArray(conditionValue)) {
        const needles = lowerArray(conditionValue);
        if (needles.length === 0) return false;
        const haystack = lowerArray(
          Array.isArray(fieldValue) ? fieldValue : toArray(fieldValue ?? [])
        );
        return !needles.some((n) => haystack.includes(n));
      }
      if (fieldValue === null || fieldValue === undefined) return true;
      // Array field: check membership
      if (Array.isArray(fieldValue)) {
        const haystack = lowerArray(fieldValue);
        const needle = String(conditionValue).toLowerCase();
        return !haystack.includes(needle);
      }
      const fv = String(fieldValue).toLowerCase();
      const cv = String(conditionValue).toLowerCase();
      return !fv.includes(cv);
    }

    // Null checks
    case 'is_empty':
    case 'is_null':
      return (
        fieldValue === null ||
        fieldValue === undefined ||
        fieldValue === '' ||
        (Array.isArray(fieldValue) && fieldValue.length === 0)
      );
    case 'is_not_empty':
    case 'is_not_null':
      return !(
        fieldValue === null ||
        fieldValue === undefined ||
        fieldValue === '' ||
        (Array.isArray(fieldValue) && fieldValue.length === 0)
      );

    // Set membership
    case 'in': {
      const set = lowerArray(toArray(conditionValue));
      const fv = toStringLower(fieldValue);
      return fv !== null && set.includes(fv);
    }
    case 'not_in': {
      const set = lowerArray(toArray(conditionValue));
      const fv = toStringLower(fieldValue);
      return fv === null || !set.includes(fv);
    }

    // Range
    case 'between': {
      const range = toArray(conditionValue);
      if (range.length !== 2) return false;
      const min = toNumber(range[0]);
      const max = toNumber(range[1]);
      const v = toNumber(fieldValue);
      return (
        v !== null && min !== null && max !== null && v >= min && v <= max
      );
    }

    // Regex (already validated at parse time via safe-regex)
    case 'regex_match': {
      const fv = toStringLower(fieldValue);
      if (fv === null) return false;
      try {
        const re = new RegExp(String(conditionValue), 'i');
        return re.test(fv);
      } catch {
        return false;
      }
    }

    // Array field matching
    case 'matches_any':
    case 'contains_any': {
      const haystack = lowerArray(
        Array.isArray(fieldValue) ? fieldValue : toArray(fieldValue)
      );
      const needles = lowerArray(toArray(conditionValue));
      return needles.some((n) => haystack.includes(n));
    }
    case 'matches_all':
    case 'contains_all': {
      const haystack = lowerArray(
        Array.isArray(fieldValue) ? fieldValue : toArray(fieldValue)
      );
      const needles = lowerArray(toArray(conditionValue));
      // every() is true for an empty list, which would match every item.
      return needles.length > 0 && needles.every((n) => haystack.includes(n));
    }

    default:
      logger.warn(`Unknown operator: ${operator}`);
      return false;
  }
}

// ============================================================================
// Specialized Condition Evaluators (JOIN-requiring)
// ============================================================================

/**
 * Get or build a cached Set of media item IDs for a given cache key.
 * Avoids re-querying the same collection/protection data per item.
 */
function getCachedSet(
  ctx: EvaluationContext,
  key: string,
  build: () => number[]
): Set<number> {
  if (!ctx._collectionCache) ctx._collectionCache = new Map();
  let cached = ctx._collectionCache.get(key);
  if (!cached) {
    cached = new Set(build());
    ctx._collectionCache.set(key, cached);
  }
  return cached;
}

function evaluateCollectionMembership(
  item: MediaItem,
  operator: string,
  value: unknown,
  ctx: EvaluationContext
): boolean {
  if (!ctx.collectionsRepo) {
    logger.warn('collection_membership condition requires collectionsRepo in context');
    return false;
  }

  switch (operator) {
    case 'in_any_protected': {
      // Cache all protected item IDs across all protected collections
      const protectedIds = getCachedSet(ctx, '_all_protected', () => {
        const allCols = ctx.collectionsRepo!.findAll().filter((c: { is_protected: number | boolean }) => c.is_protected);
        return allCols.flatMap((c: { id: number }) => ctx.collectionsRepo!.getMediaItemIds(c.id));
      });
      return protectedIds.has(item.id);
    }
    case 'not_in_any_protected': {
      const protectedIds = getCachedSet(ctx, '_all_protected', () => {
        const allCols = ctx.collectionsRepo!.findAll().filter((c: { is_protected: number | boolean }) => c.is_protected);
        return allCols.flatMap((c: { id: number }) => ctx.collectionsRepo!.getMediaItemIds(c.id));
      });
      return !protectedIds.has(item.id);
    }
    case 'in_collection_id': {
      const collectionId = toNumber(value);
      if (collectionId === null) return false;
      const memberIds = getCachedSet(ctx, `col_${collectionId}`, () =>
        ctx.collectionsRepo!.getMediaItemIds(collectionId)
      );
      return memberIds.has(item.id);
    }
    default:
      logger.warn(`Unknown collection_membership operator: ${operator}`);
      return false;
  }
}

function normalizeUsername(v: unknown): string | null {
  if (v === null || v === undefined) return null;
  // NFC normalize before lowercasing so accented chars in NFD form (e.g. café
  // with a combining diacritic) match the same string in NFC form.
  return String(v).normalize('NFC').toLowerCase();
}

/**
 * Who has watched an item, and when each last did. For a show that is its
 * episodes' viewers, merged with anything recorded against the show itself.
 */
export function viewersOf(item: MediaItem, ctx: EvaluationContext): Map<string, Date> | undefined {
  const direct = item.plex_id ? ctx.watchLookup?.get(item.plex_id) : undefined;
  if (item.type !== 'show' || !item.title) return direct;

  const viaEpisodes = ctx.showWatchLookup?.get(item.title.toLowerCase());
  if (!viaEpisodes) return direct;
  if (!direct) return viaEpisodes;

  const merged = new Map(direct);
  for (const [user, date] of viaEpisodes) {
    const existing = merged.get(user);
    if (!existing || date > existing) merged.set(user, date);
  }
  return merged;
}

function evaluateWatchedBy(
  item: MediaItem,
  operator: string,
  value: unknown,
  ctx: EvaluationContext
): boolean {
  if (!item.plex_id) return false;
  const viewers = viewersOf(item, ctx);
  return matchUsernames(viewers ? Array.from(viewers.keys()) : [], operator, value, 'watched_by');
}

/**
 * String operators against a set of usernames (case- and Unicode-insensitive).
 * Null/empty checks test whether the set has anyone in it.
 */
function matchUsernames(names: string[], operator: string, value: unknown, field: string): boolean {
  if (operator === 'is_null' || operator === 'is_empty') return names.length === 0;
  if (operator === 'is_not_null' || operator === 'is_not_empty') return names.length > 0;
  if (names.length === 0) return false;

  const usernames = names.map(normalizeUsername).filter((u): u is string => u !== null);

  switch (operator) {
    case 'equals': {
      const needle = normalizeUsername(value);
      return needle !== null && usernames.includes(needle);
    }
    case 'not_equals': {
      const needle = normalizeUsername(value);
      return needle === null || !usernames.includes(needle);
    }
    case 'in': {
      const needles = toArray(value)
        .map(normalizeUsername)
        .filter((u): u is string => u !== null);
      return needles.some((n) => usernames.includes(n));
    }
    case 'not_in': {
      const needles = toArray(value)
        .map(normalizeUsername)
        .filter((u): u is string => u !== null);
      return !needles.some((n) => usernames.includes(n));
    }
    case 'contains': {
      const needle = normalizeUsername(value);
      return needle !== null && usernames.some((u) => u.includes(needle));
    }
    case 'not_contains': {
      const needle = normalizeUsername(value);
      return needle === null || !usernames.some((u) => u.includes(needle));
    }
    case 'starts_with': {
      const needle = normalizeUsername(value);
      return needle !== null && usernames.some((u) => u.startsWith(needle));
    }
    case 'ends_with': {
      const needle = normalizeUsername(value);
      return needle !== null && usernames.some((u) => u.endsWith(needle));
    }
    case 'regex_match': {
      try {
        const re = new RegExp(String(value), 'i');
        return usernames.some((u) => re.test(u));
      } catch {
        return false;
      }
    }
    default:
      logger.warn(`Unknown ${field} operator: ${operator}`);
      return false;
  }
}

function evaluateWatchedByUser(
  item: MediaItem,
  operator: string,
  params: Record<string, unknown> | undefined,
  ctx: EvaluationContext
): boolean {
  if (!item.plex_id) return false;
  const username = params && typeof params['username'] === 'string' ? String(params['username']) : '';
  if (!username) {
    logger.warn('watched_by_user condition requires params.username');
    return false;
  }

  const userMap = viewersOf(item, ctx);
  const watchedAt = userMap?.get(username);
  const now = ctx.now ?? new Date();
  const days = toNumber(params?.['days']);

  switch (operator) {
    case 'ever_watched':
      return watchedAt !== undefined;
    case 'never_watched':
      return watchedAt === undefined;
    case 'watched_since': {
      if (!watchedAt || days === null) return false;
      const cutoff = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
      return watchedAt >= cutoff;
    }
    case 'not_watched_since': {
      if (days === null) return false;
      const cutoff = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
      if (!watchedAt) return true; // never watched by that user → hasn't watched since
      return watchedAt < cutoff;
    }
    default:
      logger.warn(`Unknown watched_by_user operator: ${operator}`);
      return false;
  }
}

// ============================================================================
// Tree Walker
// ============================================================================

/**
 * Recursively evaluate a condition tree node against a media item.
 */
export function evaluateNode(
  node: ConditionNode,
  item: MediaItem,
  ctx: EvaluationContext = {}
): boolean {
  if (node.kind === 'condition') {
    return evaluateLeaf(node, item, ctx);
  }

  // group node
  const { logic, children } = node;
  if (children.length === 0) {
    // Empty groups: AND is vacuously true, OR is false, NOT(AND()) = false
    if (logic === 'OR') return false;
    return logic === 'AND';
  }

  if (logic === 'AND') {
    for (const child of children) {
      if (!evaluateNode(child, item, ctx)) return false;
    }
    return true;
  }
  if (logic === 'OR') {
    for (const child of children) {
      if (evaluateNode(child, item, ctx)) return true;
    }
    return false;
  }
  // NOT group: negation of AND(children) — equivalent to "any child is false".
  // For the single-child case (most common from the UI), this is !child.
  return !children.every((child) => evaluateNode(child, item, ctx));
}

function evaluateLeaf(
  leaf: ConditionLeaf,
  item: MediaItem,
  ctx: EvaluationContext
): boolean {
  // Specialized fields that need joins
  if (leaf.field === 'collection_membership') {
    return evaluateCollectionMembership(item, leaf.operator, leaf.value, ctx);
  }
  if (leaf.field === 'watched_by_user') {
    return evaluateWatchedByUser(item, leaf.operator, leaf.params, ctx);
  }
  if (leaf.field === 'watched_by') {
    return evaluateWatchedBy(item, leaf.operator, leaf.value, ctx);
  }
  if (leaf.field === 'in_progress_for') {
    // Who is part-way through this show and watched recently.
    const names = usersInProgress(item, ctx.inProgressRecentDays ?? IN_PROGRESS_DEFAULT_RECENT_DAYS, ctx.now);
    return matchUsernames(names, leaf.operator, leaf.value, 'in_progress_for');
  }
  if (leaf.field === DISK_PRESSURE_FIELD) {
    return evaluateDiskPressure(leaf.operator, leaf.value, item, ctx.diskPressure);
  }
  if (leaf.field === 'watch_progress') {
    const progress = watchProgress(item, ctx.inProgressRecentDays ?? IN_PROGRESS_DEFAULT_RECENT_DAYS, ctx.now);
    return evalOperator(leaf.operator, progress, leaf.value);
  }

  const fieldValue = resolveFieldValue(item, leaf.field, ctx.now);
  return evalOperator(leaf.operator, fieldValue, leaf.value);
}

// ============================================================================
// Legacy Evaluators (retained for backward compatibility)
// ============================================================================

export function evaluateNotWatchedDays(item: ExtendedMediaItem, value: number): boolean {
  if (!item.last_watched_at) return true;
  return (item.daysSinceLastWatched ?? 0) >= value;
}

export function evaluatePlayCountLessThan(item: ExtendedMediaItem, value: number): boolean {
  return item.play_count < value;
}

export function evaluateFileSizeGreaterThan(item: ExtendedMediaItem, value: number): boolean {
  if (item.fileSizeGB === null || item.fileSizeGB === undefined) return false;
  return item.fileSizeGB > value;
}

export function evaluateResolutionLessThan(item: ExtendedMediaItem, value: number): boolean {
  if (item.resolutionValue === null || item.resolutionValue === undefined) return false;
  return item.resolutionValue < value;
}

export function evaluateAddedDaysAgo(item: ExtendedMediaItem, value: number): boolean {
  if (!item.added_at) return false;
  return (item.daysSinceAdded ?? 0) >= value;
}

export function evaluateGenreIs(item: ExtendedMediaItem, value: string | string[]): boolean {
  if (!item.genres || item.genres.length === 0) return false;
  const targets = Array.isArray(value) ? value : [value];
  const itemGenres = item.genres.map((g) => g.toLowerCase());
  return targets.some((g) => itemGenres.includes(g.toLowerCase()));
}

export function evaluateGenreIsNot(item: ExtendedMediaItem, value: string | string[]): boolean {
  if (!item.genres || item.genres.length === 0) return true;
  const excluded = Array.isArray(value) ? value : [value];
  const itemGenres = item.genres.map((g) => g.toLowerCase());
  return !excluded.some((g) => itemGenres.includes(g.toLowerCase()));
}

export function evaluateYearBefore(item: ExtendedMediaItem, value: number): boolean {
  if (item.year === undefined || item.year === null) return false;
  return item.year < value;
}

export function evaluateYearAfter(item: ExtendedMediaItem, value: number): boolean {
  if (item.year === undefined || item.year === null) return false;
  return item.year > value;
}

export function evaluateRatingLessThan(item: ExtendedMediaItem, value: number): boolean {
  if (item.rating === undefined || item.rating === null) return false;
  return item.rating < value;
}

export function evaluateTypeIs(item: ExtendedMediaItem, value: string | string[]): boolean {
  const targets = Array.isArray(value) ? value : [value];
  return targets.some((t) => t.toLowerCase() === item.type.toLowerCase());
}

/**
 * Legacy single-condition evaluator (type-based). Retained for compat.
 */
export function evaluateCondition(item: MediaItem, condition: ExtendedRuleCondition): boolean {
  const extendedItem = extendMediaItem(item);
  try {
    switch (condition.type as ConditionType) {
      case 'not_watched_days':
        return evaluateNotWatchedDays(extendedItem, Number(condition.value));
      case 'play_count_less_than':
        return evaluatePlayCountLessThan(extendedItem, Number(condition.value));
      case 'file_size_greater_than':
        return evaluateFileSizeGreaterThan(extendedItem, Number(condition.value));
      case 'resolution_less_than':
        return evaluateResolutionLessThan(extendedItem, Number(condition.value));
      case 'added_days_ago':
        return evaluateAddedDaysAgo(extendedItem, Number(condition.value));
      case 'genre_is':
        return evaluateGenreIs(extendedItem, condition.value as string | string[]);
      case 'genre_is_not':
        return evaluateGenreIsNot(extendedItem, condition.value as string | string[]);
      case 'year_before':
        return evaluateYearBefore(extendedItem, Number(condition.value));
      case 'year_after':
        return evaluateYearAfter(extendedItem, Number(condition.value));
      case 'rating_less_than':
        return evaluateRatingLessThan(extendedItem, Number(condition.value));
      case 'type_is':
        return evaluateTypeIs(extendedItem, condition.value as string | string[]);
      default:
        logger.warn(`Unknown condition type: ${condition.type}`);
        return false;
    }
  } catch (error) {
    logger.error(`Error evaluating condition ${condition.type}:`, error);
    return false;
  }
}

/**
 * Legacy multi-condition evaluator.
 */
export function evaluateConditions(
  item: MediaItem,
  conditions: ExtendedRuleCondition[],
  logic: 'AND' | 'OR' = 'AND'
): { matched: boolean; matchedConditions: ExtendedRuleCondition[] } {
  if (conditions.length === 0) {
    return { matched: false, matchedConditions: [] };
  }
  const matchedConditions: ExtendedRuleCondition[] = [];
  for (const condition of conditions) {
    const result = evaluateCondition(item, condition);
    if (result) matchedConditions.push(condition);
    if (logic === 'AND' && !result) return { matched: false, matchedConditions };
    if (logic === 'OR' && result) return { matched: true, matchedConditions };
  }
  const matched = logic === 'AND';
  return { matched, matchedConditions: matched ? matchedConditions : [] };
}