import type { MediaItem, Rule } from '../types'; import type { ConditionLeaf, ConditionNode } from './types'; import { evaluateNode, resolveFieldValue, viewersOf, type EvaluationContext } from './conditions'; import { upgradeToV2 } from './migration'; import { ruleScopeMatches } from './scope'; import { isProtectedInProgress, type InProgressConfig } from './inProgress'; import { parseEpisodeProgress } from './watchProgress'; import logger from '../utils/logger'; /** * Forecast: when each item in the library will first be matched by a rule, * if nothing about it changes but the date. * * Rules only move with time through a few conditions — days since added or * watched, when a person last watched, and when a show someone stopped * part-way through counts as stalled (which also lifts in-progress * protection). An item's match can only change on the days those thresholds * are crossed, so instead of re-running every rule for every day, each item * is checked on today and on each of its crossing days, in order. The first * day a rule matches is its eligibility date. * * It mirrors the scheduled scan: monitored items only, protected and * excluded items skipped, rules tried by priority with the first match * winning, and in-progress protection judged on the day itself. */ const DAY_MS = 24 * 60 * 60 * 1000; /** Furthest ahead the forecast looks: five years, for custom ranges. */ export const FORECAST_MAX_DAYS = 1825; /** Conditions whose answer changes when someone watches, requests or rates. */ const CONDITIONAL_FIELDS = new Set([ 'play_count', 'never_watched', 'days_since_watched', 'last_watched_at', 'watched_by', 'watched_by_user', 'watched_by_count', 'watch_progress', 'in_progress_for', 'watched_episode_count', 'percent_watched', 'collection_membership', 'requested_by', 'rating_imdb', 'rating_tmdb', 'rating_rt', ]); export type ForecastCertainty = 'predictable' | 'conditional'; export interface ForecastReason { field: string; operator: string; value: unknown; params?: Record; /** The item's value for the field on the eligibility day, when it's a plain field. */ actual: unknown; /** Sits under a NONE group: the rule matches because this is false. */ negated?: boolean; } export interface ForecastRuleInput { rule: Rule; root: ConditionNode; } export interface ForecastMatch { /** Whole days from now until the first match; 0 = the next scan. */ dayOffset: number; rule: Rule; certainty: ForecastCertainty; reasons: ForecastReason[]; } export interface ForecastOptions { now: Date; horizonDays: number; ctx: EvaluationContext; inProgress: InProgressConfig; } /** Parse each rule's stored conditions once. Rules that can't be read are dropped. */ export function prepareRules(rules: Rule[]): ForecastRuleInput[] { const out: ForecastRuleInput[] = []; for (const rule of rules) { try { out.push({ rule, root: upgradeToV2(JSON.parse(rule.conditions)).root }); } catch (error) { logger.warn(`Forecast skipped rule "${rule.name}": its conditions could not be read`, { message: (error as Error).message, }); } } return out; } export function leavesOf(node: ConditionNode): ConditionLeaf[] { if (node.kind === 'condition') return [node]; return node.children.flatMap(leavesOf); } function numbersIn(value: unknown): number[] { const list = Array.isArray(value) ? value : [value]; return list .map((v) => (typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN)) .filter((n) => Number.isFinite(n)); } /** Days from now until `at + days`, as candidate offsets around the crossing. */ function crossingOffsets(at: Date | null, days: number, now: Date): number[] { if (!at || Number.isNaN(at.getTime())) return []; const crossing = Math.ceil((at.getTime() + days * DAY_MS - now.getTime()) / DAY_MS); return [crossing - 1, crossing, crossing + 1]; } function dateOrNull(iso: string | null | undefined): Date | null { if (!iso) return null; const d = new Date(iso); return Number.isNaN(d.getTime()) ? null : d; } /** * The days an item's rules (and its in-progress protection) could change * their answer, within the horizon. Always includes today. */ export function candidateOffsets( item: MediaItem, rules: ForecastRuleInput[], opts: Pick ): number[] { const { now, horizonDays, ctx } = opts; const recentDays = opts.inProgress.recentDays; const offsets = new Set([0]); const add = (list: number[]) => list.forEach((d) => offsets.add(d)); const added = dateOrNull(item.added_at); const watched = dateOrNull(item.last_watched_at); let progressAdded = false; const addProgressCrossings = () => { if (progressAdded) return; progressAdded = true; // A show stops being in progress `recentDays` after each viewer's last play. const perPerson = parseEpisodeProgress(item); if (perPerson) { for (const p of Object.values(perPerson)) add(crossingOffsets(dateOrNull(p.lastWatched), recentDays, now)); } add(crossingOffsets(watched, recentDays, now)); }; if (opts.inProgress.protect && item.type === 'show') addProgressCrossings(); for (const { root } of rules) { for (const leaf of leavesOf(root)) { switch (leaf.field) { case 'days_since_added': for (const n of numbersIn(leaf.value)) add(crossingOffsets(added, n, now)); break; case 'days_since_watched': for (const n of numbersIn(leaf.value)) add(crossingOffsets(watched, n, now)); break; case 'watched_by_user': { const days = numbersIn(leaf.params?.['days'])[0]; const username = typeof leaf.params?.['username'] === 'string' ? (leaf.params['username'] as string) : ''; if (days === undefined || !username) break; add(crossingOffsets(viewersOf(item, ctx)?.get(username) ?? null, days, now)); break; } case 'watch_progress': case 'in_progress_for': addProgressCrossings(); break; } } } return [...offsets].filter((d) => d >= 0 && d <= horizonDays).sort((a, b) => a - b); } interface MatchedLeaf { leaf: ConditionLeaf; negated: boolean; } /** * The conditions that make a matching rule match: every child of an AND, the * first child of an ANY that holds, and the false children of a NONE (shown * negated). Conditions in branches that didn't decide the match are left out, * so the "why" never quotes something that isn't true. */ export function matchedLeaves(node: ConditionNode, item: MediaItem, ctx: EvaluationContext): MatchedLeaf[] { if (node.kind === 'condition') return [{ leaf: node, negated: false }]; if (node.logic === 'AND') return node.children.flatMap((c) => matchedLeaves(c, item, ctx)); if (node.logic === 'OR') { const hit = node.children.find((c) => evaluateNode(c, item, ctx)); return hit ? matchedLeaves(hit, item, ctx) : []; } return node.children .filter((c) => !evaluateNode(c, item, ctx)) .flatMap(leavesOf) .map((leaf) => ({ leaf, negated: true })); } function certaintyOf(matched: MatchedLeaf[], delayedByInProgress: boolean): ForecastCertainty { if (delayedByInProgress) return 'conditional'; return matched.some(({ leaf }) => CONDITIONAL_FIELDS.has(leaf.field)) ? 'conditional' : 'predictable'; } const SPECIAL_FIELDS = new Set(['watched_by', 'watched_by_user', 'collection_membership', 'in_progress_for', 'watch_progress']); function reasonsFor(matched: MatchedLeaf[], item: MediaItem, at: Date): ForecastReason[] { return matched.slice(0, 6).map(({ leaf, negated }) => ({ field: leaf.field, operator: leaf.operator, value: leaf.value, ...(leaf.params ? { params: leaf.params } : {}), actual: SPECIAL_FIELDS.has(leaf.field) ? null : resolveFieldValue(item, leaf.field, at), ...(negated ? { negated: true } : {}), })); } /** * The first day within the horizon a rule matches this item, and which rule * — or null when none does. Assumes the item isn't already queued, protected * or excluded. */ export function forecastItem(item: MediaItem, rules: ForecastRuleInput[], opts: ForecastOptions): ForecastMatch | null { const offsets = candidateOffsets(item, rules, opts); let blockedByInProgress = false; for (const dayOffset of offsets) { const at = new Date(opts.now.getTime() + dayOffset * DAY_MS); if (isProtectedInProgress(item, opts.inProgress, at)) { blockedByInProgress = true; continue; } const ctx: EvaluationContext = { ...opts.ctx, now: at }; for (const { rule, root } of rules) { if (!ruleScopeMatches(rule, item)) continue; if (!evaluateNode(root, item, ctx)) continue; // The scan applies the first matching rule only, whatever it does. A // notify rule leaves the item monitored, though, so a later scan can // still delete it once the notify rule stops matching. if (rule.action === 'notify') break; const matched = matchedLeaves(root, item, ctx); return { dayOffset, rule, certainty: certaintyOf(matched, blockedByInProgress), reasons: reasonsFor(matched, item, at), }; } } return null; } /** Bytes a rule's deletion action frees: none when it only unmonitors. */ export function bytesFreed(item: Pick, deletionAction: string | null | undefined): number { if (deletionAction === 'unmonitor_only') return 0; return item.file_size || 0; }