Revise alert parameters and ghost requester logic

- UNFULFILLED_MIN_AGE_DAYS → UNFULFILLED_MIN_AGE_HOURS (default 12h)
  so new requests don't sit a full 3 days before alerting
- Incomplete Download threshold: 90% → 100% (any missing episode fires)
- PENDING_MIN_AGE_DAYS: 7 → 2
- Ghost Requester reworked: instead of checking lifetime plays = 0,
  now checks whether the user's last Tautulli activity predates their
  last N (default 5) approved requests — catches people who request
  but don't watch their recent content
- Removed Frequent Declines alert
- Add tautulliLastSeen to UserStat to support the ghost rework
- Update README to reflect all changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 11:34:55 -04:00
parent a8a03b59d5
commit 8fe61cdeb8
4 changed files with 102 additions and 131 deletions

View File

@@ -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

View File

@@ -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,
};
});

View File

@@ -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<number>();
const flaggedShows = new Set<number>();
// 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<number, UnfilledEntry>();
const unfilledShows = new Map<number, UnfilledEntry>();
@@ -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<number>();
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 ────────────────────────────

View File

@@ -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;