server/src/rules/forecast.ts
Rama main · réplica de lectura del repositorio original.
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<string, unknown>;
/** 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<ForecastOptions, 'now' | 'horizonDays' | 'ctx' | 'inProgress'>
): number[] {
const { now, horizonDays, ctx } = opts;
const recentDays = opts.inProgress.recentDays;
const offsets = new Set<number>([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<MediaItem, 'file_size'>, deletionAction: string | null | undefined): number {
if (deletionAction === 'unmonitor_only') return 0;
return item.file_size || 0;
}