diff --git a/README.md b/README.md index d95cd21..177c038 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Alerts are generated on every stats refresh and persisted in `data/alerts.json` ### Content alerts -These are keyed per piece of media, not per user. If multiple users requested the same item, they're grouped into a single alert. +These are keyed per piece of media, not per user. If multiple users requested the same item they're grouped into a single alert. --- @@ -72,26 +72,26 @@ These are keyed per piece of media, not per user. If multiple users requested th | Parameter | Default | Description | |---|---|---| -| `UNFULFILLED_MIN_AGE_DAYS` | `3` | Minimum days since approval before alerting. Prevents noise on brand-new requests. | +| `UNFULFILLED_MIN_AGE_HOURS` | `12` | Hours since approval before alerting. Prevents noise on brand-new requests. | -- Skipped entirely if Radarr reports `isAvailable: false` (unreleased) or Sonarr reports `status: "upcoming"`. -- **Auto-resolves** when the file appears (i.e. `sizeOnDisk > 0`). +- Skipped if Radarr reports `isAvailable: false` (unreleased) or Sonarr reports `status: "upcoming"`. +- **Auto-resolves** when the file appears. - Manual close cooldown: **3 days**. --- #### Incomplete Download -> An ended TV series has been partially downloaded (less than 90% of episodes on disk). +> An ended TV series is missing one or more episodes. | Parameter | Default | Description | |---|---|---| -| `UNFULFILLED_MIN_AGE_DAYS` | `3` | Same minimum age as above. | -| Completion threshold | `90%` | Series below this are flagged. Hardcoded. | +| `UNFULFILLED_MIN_AGE_HOURS` | `12` | Hours since approval before alerting. | +| Completion threshold | `100%` | Any missing episode on a finished series triggers this alert. | -- Only fires for series with `status: "ended"` in Sonarr. Continuing shows are excluded because missing episodes may simply not have aired yet. -- Percentage is calculated as `episodeFileCount / totalEpisodeCount`, not Sonarr's `percentOfEpisodes` (which counts against monitored episodes only and would be inaccurate here). -- **Auto-resolves** when completion reaches 90%+. +- Only fires for series with `status: "ended"` in Sonarr. Continuing shows are excluded because missing episodes may not have aired yet. +- Completion is calculated as `episodeFileCount / totalEpisodeCount` (not Sonarr's `percentOfEpisodes`, which measures against monitored episodes only). +- **Auto-resolves** when all episodes are on disk. - Manual close cooldown: **3 days**. --- @@ -102,10 +102,10 @@ These are keyed per piece of media, not per user. If multiple users requested th | Parameter | Default | Description | |---|---|---| -| `PENDING_MIN_AGE_DAYS` | `7` | Minimum days a request must be pending before alerting. | +| `PENDING_MIN_AGE_DAYS` | `2` | Days a request must be pending before alerting. | - One alert per request item, not per user. -- Skipped if the content is unreleased (same availability checks as above). +- Skipped if the content is unreleased. - **Auto-resolves** when the request is approved or declined. - Manual close cooldown: **3 days**. @@ -115,7 +115,7 @@ These are keyed per piece of media, not per user. If multiple users requested th These fire once per user. Ghost Requester takes priority over Low Watch Rate — a user will only ever have one behavior alert open at a time. Both require the user to be "established" (at least one request older than `USER_MIN_AGE_DAYS`) to avoid flagging new users. -> Requires Tautulli to be configured (except Frequent Declines). +> Requires Tautulli to be configured. | Parameter | Default | Description | |---|---|---| @@ -125,11 +125,13 @@ These fire once per user. Ghost Requester takes priority over Low Watch Rate — #### Ghost Requester -> A user has made several requests but has never watched anything on Plex. +> A user hasn't watched anything on Plex since before their last N approved requests were made. + +Rather than checking lifetime play counts, this looks at recency: if a user's last Plex activity predates all of their most recent N approved requests, they're not watching what they're requesting. | Parameter | Default | Description | |---|---|---| -| `MIN_REQUESTS_GHOST` | `5` | Minimum total requests before flagging. | +| `GHOST_RECENT_REQUESTS` | `5` | Number of recent approved requests to evaluate. Also the minimum required before the alert can fire. | - Manual close cooldown: **14 days**. @@ -148,20 +150,6 @@ These fire once per user. Ghost Requester takes priority over Low Watch Rate — --- -#### Frequent Declines - -> A user has had multiple requests declined in a rolling window. - -| Parameter | Default | Description | -|---|---|---| -| `MIN_DECLINES` | `3` | Minimum declined requests in the lookback window. | -| `DECLINE_LOOKBACK_DAYS` | `60` | Rolling window in days. | - -- Does not require Tautulli. -- Manual close cooldown: **14 days**. - ---- - ### System alerts #### No Tautulli Watch Data diff --git a/src/lib/aggregate.ts b/src/lib/aggregate.ts index 544595d..5dd57b6 100644 --- a/src/lib/aggregate.ts +++ b/src/lib/aggregate.ts @@ -58,11 +58,13 @@ export function computeStats( let plays: number | null = null; let watchHours: number | null = null; + let tautulliLastSeen: number | null = null; if (hasTautulli && tautulliMap) { const tu = lookupTautulliUser(tautulliMap, user.email, user.displayName); plays = tu?.plays ?? 0; watchHours = tu ? Math.round((tu.duration / 3600) * 10) / 10 : 0; + tautulliLastSeen = tu?.last_seen ?? null; } return { @@ -73,6 +75,7 @@ export function computeStats( totalBytes, plays, watchHours, + tautulliLastSeen, }; }); diff --git a/src/lib/alerts.ts b/src/lib/alerts.ts index c343ca9..c5455d3 100644 --- a/src/lib/alerts.ts +++ b/src/lib/alerts.ts @@ -2,30 +2,38 @@ import { UserStat, OverseerrRequest, MediaEntry, AlertCandidate } from "@/lib/ty // ─── Tunables ───────────────────────────────────────────────────────────────── -/** A movie/show must have been approved this many days ago before we alert on it */ -const UNFULFILLED_MIN_AGE_DAYS = 3; +/** A movie/show must have been approved at least this many hours before we alert on it */ +const UNFULFILLED_MIN_AGE_HOURS = 12; -/** A pending request must be this old before we alert on it */ -const PENDING_MIN_AGE_DAYS = 7; +/** A pending request must be this many days old before we alert on it */ +const PENDING_MIN_AGE_DAYS = 2; -/** User must have made their first request at least this long ago before ghost/watchrate alerts */ +/** User must have made their first request at least this long ago before behavior alerts */ const USER_MIN_AGE_DAYS = 14; -/** Minimum requests before ghost/watchrate alerts */ -const MIN_REQUESTS_GHOST = 5; -const MIN_REQUESTS_WATCHRATE = 10; +/** Ghost requester: how many of the user's most recent approved requests to evaluate */ +const GHOST_RECENT_REQUESTS = 5; /** Watch-rate threshold — below this fraction (plays/requests) triggers a warning */ const LOW_WATCH_RATE = 0.2; -/** Minimum declines in the lookback window to flag */ -const MIN_DECLINES = 3; -const DECLINE_LOOKBACK_DAYS = 60; +/** Minimum requests before a low watch rate alert fires */ +const MIN_REQUESTS_WATCHRATE = 10; // ─── Helpers ────────────────────────────────────────────────────────────────── +function hoursSince(iso: string): number { + return (Date.now() - new Date(iso).getTime()) / 3_600_000; +} + function daysSince(iso: string): number { - return (Date.now() - new Date(iso).getTime()) / 86_400_000; + return hoursSince(iso) / 24; +} + +function formatAge(hours: number): string { + if (hours < 24) return `${Math.floor(hours)} hours`; + const days = Math.floor(hours / 24); + return days === 1 ? "1 day" : `${days} days`; } // ─── Generator ─────────────────────────────────────────────────────────────── @@ -41,16 +49,14 @@ export function generateAlertCandidates( // ── CONTENT-CENTRIC: one alert per piece of media ────────────────────────── - // Track which content we've already flagged to avoid duplicate per-user alerts const flaggedMovies = new Set(); const flaggedShows = new Set(); - // Collect all unfulfilled content across all users - // Key: tmdbId/tvdbId → { entry, requestedBy: string[], oldestApproval: Date } interface UnfilledEntry { entry: MediaEntry; requestedBy: string[]; - oldestAge: number; // days since oldest qualifying request + oldestAgeHours: number; + partial?: boolean; } const unfilledMovies = new Map(); const unfilledShows = new Map(); @@ -59,102 +65,92 @@ export function generateAlertCandidates( const requests = allRequests.get(user.userId) ?? []; for (const req of requests) { - // Only look at approved requests old enough to have been expected to download if (req.status !== 2) continue; - const age = daysSince(req.createdAt); - if (age < UNFULFILLED_MIN_AGE_DAYS) continue; + const ageHours = hoursSince(req.createdAt); + if (ageHours < UNFULFILLED_MIN_AGE_HOURS) continue; if (req.type === "movie") { const entry = radarrMap.get(req.media.tmdbId); - // Skip if not yet released (Radarr's isAvailable = false) if (entry && !entry.available) continue; - const isUnfilled = !entry || entry.sizeOnDisk === 0; - if (!isUnfilled) continue; + if (entry && entry.sizeOnDisk > 0) continue; - const title = - entry?.title ?? req.media.title ?? `Movie #${req.media.tmdbId}`; + const title = entry?.title ?? req.media.title ?? `Movie #${req.media.tmdbId}`; const existing = unfilledMovies.get(req.media.tmdbId); if (existing) { if (!existing.requestedBy.includes(user.displayName)) existing.requestedBy.push(user.displayName); - existing.oldestAge = Math.max(existing.oldestAge, age); + existing.oldestAgeHours = Math.max(existing.oldestAgeHours, ageHours); } else { unfilledMovies.set(req.media.tmdbId, { entry: { title, sizeOnDisk: 0, available: true }, requestedBy: [user.displayName], - oldestAge: age, + oldestAgeHours: ageHours, }); } + } else if (req.type === "tv" && req.media.tvdbId) { const entry = sonarrMap.get(req.media.tvdbId); - // Skip if series hasn't started airing yet (Sonarr status = "upcoming") if (entry && !entry.available) continue; const isNothingDownloaded = !entry || entry.sizeOnDisk === 0; - // Partial: ended series with < 90% of episodes on disk + // Partial: ended series missing any episodes const isPartiallyDownloaded = entry !== undefined && entry.sizeOnDisk > 0 && entry.seriesStatus === "ended" && - entry.percentOfEpisodes !== undefined && - entry.percentOfEpisodes < 90; - const isUnfilled = isNothingDownloaded || isPartiallyDownloaded; - if (!isUnfilled) continue; + entry.episodeFileCount !== undefined && + entry.totalEpisodeCount !== undefined && + entry.episodeFileCount < entry.totalEpisodeCount; - const title = - entry?.title ?? req.media.title ?? `Show #${req.media.tvdbId}`; + if (!isNothingDownloaded && !isPartiallyDownloaded) continue; + + const title = entry?.title ?? req.media.title ?? `Show #${req.media.tvdbId}`; const partial = !isNothingDownloaded && isPartiallyDownloaded; const existing = unfilledShows.get(req.media.tvdbId); if (existing) { if (!existing.requestedBy.includes(user.displayName)) existing.requestedBy.push(user.displayName); - existing.oldestAge = Math.max(existing.oldestAge, age); - // Upgrade to partial flag if we now know it's partial - if (partial) (existing as UnfilledEntry & { partial?: boolean }).partial = true; + existing.oldestAgeHours = Math.max(existing.oldestAgeHours, ageHours); + if (partial) existing.partial = true; } else { - const record: UnfilledEntry & { partial?: boolean } = { + unfilledShows.set(req.media.tvdbId, { entry: { title, sizeOnDisk: entry?.sizeOnDisk ?? 0, available: true }, requestedBy: [user.displayName], - oldestAge: age, - }; - if (partial) record.partial = true; - unfilledShows.set(req.media.tvdbId, record); + oldestAgeHours: ageHours, + partial, + }); } } } } - for (const [tmdbId, { entry, requestedBy, oldestAge }] of unfilledMovies) { + for (const [tmdbId, { entry, requestedBy, oldestAgeHours }] of unfilledMovies) { if (flaggedMovies.has(tmdbId)) continue; flaggedMovies.add(tmdbId); - const daysStr = Math.floor(oldestAge) === 1 ? "1 day" : `${Math.floor(oldestAge)} days`; const byStr = requestedBy.slice(0, 3).join(", ") + (requestedBy.length > 3 ? ` +${requestedBy.length - 3}` : ""); candidates.push({ key: `unfulfilled:movie:${tmdbId}`, category: "unfulfilled", severity: "warning", title: `Not Downloaded: ${entry.title}`, - description: `Approved ${daysStr} ago but no file found in Radarr. Requested by ${byStr}.`, + description: `Approved ${formatAge(oldestAgeHours)} ago but no file found in Radarr. Requested by ${byStr}.`, mediaId: tmdbId, mediaType: "movie", mediaTitle: entry.title, }); } - for (const [tvdbId, data] of unfilledShows) { + for (const [tvdbId, { entry, requestedBy, oldestAgeHours, partial }] of unfilledShows) { if (flaggedShows.has(tvdbId)) continue; flaggedShows.add(tvdbId); - const { entry, requestedBy, oldestAge } = data; - const partial = (data as UnfilledEntry & { partial?: boolean }).partial ?? false; const sonarrEntry = sonarrMap.get(tvdbId); - const daysStr = Math.floor(oldestAge) === 1 ? "1 day" : `${Math.floor(oldestAge)} days`; const byStr = requestedBy.slice(0, 3).join(", ") + (requestedBy.length > 3 ? ` +${requestedBy.length - 3}` : ""); const pct = partial && sonarrEntry?.episodeFileCount !== undefined && sonarrEntry.totalEpisodeCount ? Math.round((sonarrEntry.episodeFileCount / sonarrEntry.totalEpisodeCount) * 100) : null; - const description = partial && pct !== null - ? `Only ${pct}% of episodes downloaded (${sonarrEntry!.episodeFileCount}/${sonarrEntry!.totalEpisodeCount}). Approved ${daysStr} ago. Requested by ${byStr}.` - : `Approved ${daysStr} ago but no files found in Sonarr. Requested by ${byStr}.`; + const description = pct !== null + ? `Only ${pct}% of episodes downloaded (${sonarrEntry!.episodeFileCount}/${sonarrEntry!.totalEpisodeCount}). Approved ${formatAge(oldestAgeHours)} ago. Requested by ${byStr}.` + : `Approved ${formatAge(oldestAgeHours)} ago but no files found in Sonarr. Requested by ${byStr}.`; candidates.push({ key: `unfulfilled:tv:${tvdbId}`, category: "unfulfilled", @@ -168,7 +164,7 @@ export function generateAlertCandidates( } // ── CONTENT-CENTRIC: stale pending requests ─────────────────────────────── - // One alert per pending request item (not per user) + const flaggedPending = new Set(); for (const user of userStats) { @@ -177,18 +173,13 @@ export function generateAlertCandidates( if (req.status !== 1) continue; if (daysSince(req.createdAt) < PENDING_MIN_AGE_DAYS) continue; - const age = Math.floor(daysSince(req.createdAt)); - const ageStr = age === 1 ? "1 day" : `${age} days`; + const ageStr = formatAge(hoursSince(req.createdAt)); if (req.type === "movie" && !flaggedPending.has(req.id)) { - // Skip if movie isn't released yet const movieEntry = radarrMap.get(req.media.tmdbId); if (movieEntry && !movieEntry.available) continue; flaggedPending.add(req.id); - const title = - radarrMap.get(req.media.tmdbId)?.title ?? - req.media.title ?? - `Movie #${req.media.tmdbId}`; + const title = movieEntry?.title ?? req.media.title ?? `Movie #${req.media.tmdbId}`; candidates.push({ key: `pending:req:${req.id}`, category: "pending", @@ -202,12 +193,10 @@ export function generateAlertCandidates( userName: user.displayName, }); } else if (req.type === "tv" && req.media.tvdbId && !flaggedPending.has(req.id)) { - // Skip if show hasn't started airing yet const showEntry = sonarrMap.get(req.media.tvdbId); if (showEntry && !showEntry.available) continue; flaggedPending.add(req.id); - const title = - showEntry?.title ?? req.media.title ?? `Show #${req.media.tvdbId}`; + const title = showEntry?.title ?? req.media.title ?? `Show #${req.media.tvdbId}`; candidates.push({ key: `pending:req:${req.id}`, category: "pending", @@ -224,36 +213,43 @@ export function generateAlertCandidates( } } - // ── USER-BEHAVIOR: one category per user, most severe wins ─────────────── - // Ghost Requester takes priority over Low Watch Rate for the same user. - // Only generate these alerts if the user is "established" (old enough account). + // ── USER-BEHAVIOR ───────────────────────────────────────────────────────── for (const user of userStats) { const requests = allRequests.get(user.userId) ?? []; if (requests.length === 0) continue; - // Check if user is established (has at least one request old enough) const oldestRequestAge = Math.max(...requests.map((r) => daysSince(r.createdAt))); - const isEstablished = oldestRequestAge >= USER_MIN_AGE_DAYS; - if (!isEstablished) continue; + if (oldestRequestAge < USER_MIN_AGE_DAYS) continue; // ── Ghost Requester ─────────────────────────────────────────────────── - if ( - hasTautulli && - user.plays === 0 && - user.requestCount >= MIN_REQUESTS_GHOST - ) { - candidates.push({ - key: `ghost:${user.userId}`, - category: "ghost", - severity: "warning", - title: `Ghost Requester`, - description: `${user.displayName} has ${user.requestCount} requests but has never watched anything on Plex.`, - userId: user.userId, - userName: user.displayName, - }); - // Ghost takes priority — skip watch-rate check for this user - continue; + // Fires if the user hasn't watched anything since before their last + // GHOST_RECENT_REQUESTS approved requests were made. + if (hasTautulli) { + const approved = requests + .filter((r) => r.status === 2) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + if (approved.length >= GHOST_RECENT_REQUESTS) { + const nthRequest = approved[GHOST_RECENT_REQUESTS - 1]; + const nthDate = new Date(nthRequest.createdAt).getTime(); + // tautulliLastSeen is unix seconds; null means never seen + const lastSeenMs = user.tautulliLastSeen ? user.tautulliLastSeen * 1000 : null; + const hasNotWatchedSinceRequesting = lastSeenMs === null || lastSeenMs < nthDate; + + if (hasNotWatchedSinceRequesting) { + candidates.push({ + key: `ghost:${user.userId}`, + category: "ghost", + severity: "warning", + title: `Ghost Requester`, + description: `${user.displayName} has made ${approved.length} requests but hasn't watched anything since before their last ${GHOST_RECENT_REQUESTS} were approved.`, + userId: user.userId, + userName: user.displayName, + }); + continue; // ghost takes priority over low watch rate + } + } } // ── Low Watch Rate ──────────────────────────────────────────────────── @@ -275,25 +271,8 @@ export function generateAlertCandidates( userId: user.userId, userName: user.displayName, }); - continue; // don't also check declines for same priority slot } } - - // ── Declined Streak ─────────────────────────────────────────────────── - const recentDeclines = requests.filter( - (r) => r.status === 3 && daysSince(r.createdAt) <= DECLINE_LOOKBACK_DAYS - ); - if (recentDeclines.length >= MIN_DECLINES) { - candidates.push({ - key: `declined:${user.userId}`, - category: "declined", - severity: "info", - title: `Frequent Declines`, - description: `${user.displayName} has had ${recentDeclines.length} requests declined in the last ${DECLINE_LOOKBACK_DAYS} days.`, - userId: user.userId, - userName: user.displayName, - }); - } } // ── SYSTEM: Tautulli configured but no matches ──────────────────────────── diff --git a/src/lib/types.ts b/src/lib/types.ts index 4544da7..c852be9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -73,6 +73,7 @@ export interface UserStat { // Tautulli (null when not configured) plays: number | null; watchHours: number | null; + tautulliLastSeen: number | null; // unix timestamp (seconds), null if no Tautulli data // Per-metric ranks (1 = top user for that metric, null = Tautulli not available) storageRank: number; requestRank: number;