server/src/rules/watchProgress.ts
Rama main · réplica de lectura del repositorio original.
import type { MediaItem } from '../types';
/**
* How far through a TV show its viewers are, judged per person.
*
* A show is "in progress" when any one person has watched some but not all of
* its episodes and watched one recently. Someone who started long ago and
* stopped makes it "stalled" instead, so an abandoned show doesn't stay
* protected forever. Judging per person matters: when one person has finished
* and another is half-way, everyone's episodes together cover the whole show,
* but the second person is still watching.
*
* When the provider can't say who watched (Plex's own count, for one), the
* combined count stands in as if it were one person.
*/
export const IN_PROGRESS_DEFAULT_RECENT_DAYS = 60;
export type WatchProgress = 'not_started' | 'in_progress' | 'stalled' | 'finished';
export interface PersonProgress {
/** Distinct episodes they finished. */
watched: number;
/** Their latest play (ISO), finished or not. */
lastWatched: string | null;
}
type ProgressFields = Pick<MediaItem, 'type' | 'episode_count' | 'watched_episode_count'> &
Partial<Pick<MediaItem, 'episode_progress' | 'last_watched_at'>>;
/** Each person's progress through a show, or null when it isn't recorded. */
export function parseEpisodeProgress(item: Pick<ProgressFields, 'episode_progress'>): Record<string, PersonProgress> | null {
const raw = item.episode_progress;
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const out: Record<string, PersonProgress> = {};
for (const [user, value] of Object.entries(parsed as Record<string, unknown>)) {
const v = value as Partial<PersonProgress> | null;
if (!v || typeof v.watched !== 'number') continue;
out[user] = { watched: v.watched, lastWatched: typeof v.lastWatched === 'string' ? v.lastWatched : null };
}
return Object.keys(out).length > 0 ? out : null;
} catch {
return null;
}
}
function isRecent(iso: string | null | undefined, recentDays: number, now: Date): boolean {
if (!iso) return false;
const at = new Date(iso).getTime();
if (Number.isNaN(at)) return false;
return now.getTime() - at <= recentDays * 24 * 60 * 60 * 1000;
}
/** One person's (or the combined) progress through a show of `total` episodes. */
export function progressOf(
person: PersonProgress,
total: number,
recentDays: number,
now: Date = new Date()
): WatchProgress {
if (person.watched <= 0) return 'not_started';
if (person.watched >= total) return 'finished';
return isRecent(person.lastWatched, recentDays, now) ? 'in_progress' : 'stalled';
}
/** The people progress is judged on: each viewer, or everyone as one. */
function people(item: ProgressFields): PersonProgress[] | null {
const perPerson = parseEpisodeProgress(item);
if (perPerson) return Object.values(perPerson);
const watched = item.watched_episode_count;
if (watched === null || watched === undefined) return null;
return [{ watched, lastWatched: item.last_watched_at ?? null }];
}
/** Share of a show's episodes anyone has watched, 0–100; null when unknown. */
export function percentWatched(item: ProgressFields): number | null {
if (item.type !== 'show') return null;
const total = item.episode_count;
const watched = item.watched_episode_count;
if (!total || total <= 0 || watched === null || watched === undefined) return null;
return Math.min(100, Math.round((watched / total) * 100));
}
/** Who is part-way through a show and watched recently. */
export function usersInProgress(item: ProgressFields, recentDays: number, now: Date = new Date()): string[] {
if (item.type !== 'show' || !item.episode_count || item.episode_count <= 0) return [];
const perPerson = parseEpisodeProgress(item);
if (!perPerson) return [];
const total = item.episode_count;
return Object.entries(perPerson)
.filter(([, p]) => progressOf(p, total, recentDays, now) === 'in_progress')
.map(([user]) => user);
}
export function isShowInProgress(item: ProgressFields, recentDays: number, now: Date = new Date()): boolean {
return watchProgress(item, recentDays, now) === 'in_progress';
}
/**
* A show's watch progress, or null for movies and shows whose episode counts
* aren't known. Anyone in progress makes the show in progress; otherwise
* anyone part-way makes it stalled — so "finished" means nobody stopped short.
*/
export function watchProgress(item: ProgressFields, recentDays: number, now: Date = new Date()): WatchProgress | null {
if (item.type !== 'show') return null;
const total = item.episode_count;
if (!total || total <= 0) return null;
const viewers = people(item);
if (!viewers) return null;
const states = viewers.map((p) => progressOf(p, total, recentDays, now));
if (states.includes('in_progress')) return 'in_progress';
if (states.includes('stalled')) return 'stalled';
if (states.includes('finished')) return 'finished';
return 'not_started';
}