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:
46
README.md
46
README.md
@@ -62,7 +62,7 @@ Alerts are generated on every stats refresh and persisted in `data/alerts.json`
|
|||||||
|
|
||||||
### Content alerts
|
### 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 |
|
| 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"`.
|
- Skipped if Radarr reports `isAvailable: false` (unreleased) or Sonarr reports `status: "upcoming"`.
|
||||||
- **Auto-resolves** when the file appears (i.e. `sizeOnDisk > 0`).
|
- **Auto-resolves** when the file appears.
|
||||||
- Manual close cooldown: **3 days**.
|
- Manual close cooldown: **3 days**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Incomplete Download
|
#### 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 |
|
| Parameter | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `UNFULFILLED_MIN_AGE_DAYS` | `3` | Same minimum age as above. |
|
| `UNFULFILLED_MIN_AGE_HOURS` | `12` | Hours since approval before alerting. |
|
||||||
| Completion threshold | `90%` | Series below this are flagged. Hardcoded. |
|
| 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.
|
- Only fires for series with `status: "ended"` in Sonarr. Continuing shows are excluded because missing episodes may 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).
|
- Completion is calculated as `episodeFileCount / totalEpisodeCount` (not Sonarr's `percentOfEpisodes`, which measures against monitored episodes only).
|
||||||
- **Auto-resolves** when completion reaches 90%+.
|
- **Auto-resolves** when all episodes are on disk.
|
||||||
- Manual close cooldown: **3 days**.
|
- 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 |
|
| 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.
|
- 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.
|
- **Auto-resolves** when the request is approved or declined.
|
||||||
- Manual close cooldown: **3 days**.
|
- 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.
|
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 |
|
| Parameter | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -125,11 +125,13 @@ These fire once per user. Ghost Requester takes priority over Low Watch Rate —
|
|||||||
|
|
||||||
#### Ghost Requester
|
#### 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 |
|
| 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**.
|
- 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
|
### System alerts
|
||||||
|
|
||||||
#### No Tautulli Watch Data
|
#### No Tautulli Watch Data
|
||||||
|
|||||||
@@ -58,11 +58,13 @@ export function computeStats(
|
|||||||
|
|
||||||
let plays: number | null = null;
|
let plays: number | null = null;
|
||||||
let watchHours: number | null = null;
|
let watchHours: number | null = null;
|
||||||
|
let tautulliLastSeen: number | null = null;
|
||||||
|
|
||||||
if (hasTautulli && tautulliMap) {
|
if (hasTautulli && tautulliMap) {
|
||||||
const tu = lookupTautulliUser(tautulliMap, user.email, user.displayName);
|
const tu = lookupTautulliUser(tautulliMap, user.email, user.displayName);
|
||||||
plays = tu?.plays ?? 0;
|
plays = tu?.plays ?? 0;
|
||||||
watchHours = tu ? Math.round((tu.duration / 3600) * 10) / 10 : 0;
|
watchHours = tu ? Math.round((tu.duration / 3600) * 10) / 10 : 0;
|
||||||
|
tautulliLastSeen = tu?.last_seen ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -73,6 +75,7 @@ export function computeStats(
|
|||||||
totalBytes,
|
totalBytes,
|
||||||
plays,
|
plays,
|
||||||
watchHours,
|
watchHours,
|
||||||
|
tautulliLastSeen,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,30 +2,38 @@ import { UserStat, OverseerrRequest, MediaEntry, AlertCandidate } from "@/lib/ty
|
|||||||
|
|
||||||
// ─── Tunables ─────────────────────────────────────────────────────────────────
|
// ─── Tunables ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** A movie/show must have been approved this many days ago before we alert on it */
|
/** A movie/show must have been approved at least this many hours before we alert on it */
|
||||||
const UNFULFILLED_MIN_AGE_DAYS = 3;
|
const UNFULFILLED_MIN_AGE_HOURS = 12;
|
||||||
|
|
||||||
/** A pending request must be this old before we alert on it */
|
/** A pending request must be this many days old before we alert on it */
|
||||||
const PENDING_MIN_AGE_DAYS = 7;
|
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;
|
const USER_MIN_AGE_DAYS = 14;
|
||||||
|
|
||||||
/** Minimum requests before ghost/watchrate alerts */
|
/** Ghost requester: how many of the user's most recent approved requests to evaluate */
|
||||||
const MIN_REQUESTS_GHOST = 5;
|
const GHOST_RECENT_REQUESTS = 5;
|
||||||
const MIN_REQUESTS_WATCHRATE = 10;
|
|
||||||
|
|
||||||
/** Watch-rate threshold — below this fraction (plays/requests) triggers a warning */
|
/** Watch-rate threshold — below this fraction (plays/requests) triggers a warning */
|
||||||
const LOW_WATCH_RATE = 0.2;
|
const LOW_WATCH_RATE = 0.2;
|
||||||
|
|
||||||
/** Minimum declines in the lookback window to flag */
|
/** Minimum requests before a low watch rate alert fires */
|
||||||
const MIN_DECLINES = 3;
|
const MIN_REQUESTS_WATCHRATE = 10;
|
||||||
const DECLINE_LOOKBACK_DAYS = 60;
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function hoursSince(iso: string): number {
|
||||||
|
return (Date.now() - new Date(iso).getTime()) / 3_600_000;
|
||||||
|
}
|
||||||
|
|
||||||
function daysSince(iso: string): number {
|
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 ───────────────────────────────────────────────────────────────
|
// ─── Generator ───────────────────────────────────────────────────────────────
|
||||||
@@ -41,16 +49,14 @@ export function generateAlertCandidates(
|
|||||||
|
|
||||||
// ── CONTENT-CENTRIC: one alert per piece of media ──────────────────────────
|
// ── 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 flaggedMovies = new Set<number>();
|
||||||
const flaggedShows = 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 {
|
interface UnfilledEntry {
|
||||||
entry: MediaEntry;
|
entry: MediaEntry;
|
||||||
requestedBy: string[];
|
requestedBy: string[];
|
||||||
oldestAge: number; // days since oldest qualifying request
|
oldestAgeHours: number;
|
||||||
|
partial?: boolean;
|
||||||
}
|
}
|
||||||
const unfilledMovies = new Map<number, UnfilledEntry>();
|
const unfilledMovies = new Map<number, UnfilledEntry>();
|
||||||
const unfilledShows = new Map<number, UnfilledEntry>();
|
const unfilledShows = new Map<number, UnfilledEntry>();
|
||||||
@@ -59,102 +65,92 @@ export function generateAlertCandidates(
|
|||||||
const requests = allRequests.get(user.userId) ?? [];
|
const requests = allRequests.get(user.userId) ?? [];
|
||||||
|
|
||||||
for (const req of requests) {
|
for (const req of requests) {
|
||||||
// Only look at approved requests old enough to have been expected to download
|
|
||||||
if (req.status !== 2) continue;
|
if (req.status !== 2) continue;
|
||||||
const age = daysSince(req.createdAt);
|
const ageHours = hoursSince(req.createdAt);
|
||||||
if (age < UNFULFILLED_MIN_AGE_DAYS) continue;
|
if (ageHours < UNFULFILLED_MIN_AGE_HOURS) continue;
|
||||||
|
|
||||||
if (req.type === "movie") {
|
if (req.type === "movie") {
|
||||||
const entry = radarrMap.get(req.media.tmdbId);
|
const entry = radarrMap.get(req.media.tmdbId);
|
||||||
// Skip if not yet released (Radarr's isAvailable = false)
|
|
||||||
if (entry && !entry.available) continue;
|
if (entry && !entry.available) continue;
|
||||||
const isUnfilled = !entry || entry.sizeOnDisk === 0;
|
if (entry && entry.sizeOnDisk > 0) continue;
|
||||||
if (!isUnfilled) continue;
|
|
||||||
|
|
||||||
const title =
|
const title = entry?.title ?? req.media.title ?? `Movie #${req.media.tmdbId}`;
|
||||||
entry?.title ?? req.media.title ?? `Movie #${req.media.tmdbId}`;
|
|
||||||
const existing = unfilledMovies.get(req.media.tmdbId);
|
const existing = unfilledMovies.get(req.media.tmdbId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!existing.requestedBy.includes(user.displayName))
|
if (!existing.requestedBy.includes(user.displayName))
|
||||||
existing.requestedBy.push(user.displayName);
|
existing.requestedBy.push(user.displayName);
|
||||||
existing.oldestAge = Math.max(existing.oldestAge, age);
|
existing.oldestAgeHours = Math.max(existing.oldestAgeHours, ageHours);
|
||||||
} else {
|
} else {
|
||||||
unfilledMovies.set(req.media.tmdbId, {
|
unfilledMovies.set(req.media.tmdbId, {
|
||||||
entry: { title, sizeOnDisk: 0, available: true },
|
entry: { title, sizeOnDisk: 0, available: true },
|
||||||
requestedBy: [user.displayName],
|
requestedBy: [user.displayName],
|
||||||
oldestAge: age,
|
oldestAgeHours: ageHours,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (req.type === "tv" && req.media.tvdbId) {
|
} else if (req.type === "tv" && req.media.tvdbId) {
|
||||||
const entry = sonarrMap.get(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;
|
if (entry && !entry.available) continue;
|
||||||
|
|
||||||
const isNothingDownloaded = !entry || entry.sizeOnDisk === 0;
|
const isNothingDownloaded = !entry || entry.sizeOnDisk === 0;
|
||||||
// Partial: ended series with < 90% of episodes on disk
|
// Partial: ended series missing any episodes
|
||||||
const isPartiallyDownloaded =
|
const isPartiallyDownloaded =
|
||||||
entry !== undefined &&
|
entry !== undefined &&
|
||||||
entry.sizeOnDisk > 0 &&
|
entry.sizeOnDisk > 0 &&
|
||||||
entry.seriesStatus === "ended" &&
|
entry.seriesStatus === "ended" &&
|
||||||
entry.percentOfEpisodes !== undefined &&
|
entry.episodeFileCount !== undefined &&
|
||||||
entry.percentOfEpisodes < 90;
|
entry.totalEpisodeCount !== undefined &&
|
||||||
const isUnfilled = isNothingDownloaded || isPartiallyDownloaded;
|
entry.episodeFileCount < entry.totalEpisodeCount;
|
||||||
if (!isUnfilled) continue;
|
|
||||||
|
|
||||||
const title =
|
if (!isNothingDownloaded && !isPartiallyDownloaded) continue;
|
||||||
entry?.title ?? req.media.title ?? `Show #${req.media.tvdbId}`;
|
|
||||||
|
const title = entry?.title ?? req.media.title ?? `Show #${req.media.tvdbId}`;
|
||||||
const partial = !isNothingDownloaded && isPartiallyDownloaded;
|
const partial = !isNothingDownloaded && isPartiallyDownloaded;
|
||||||
const existing = unfilledShows.get(req.media.tvdbId);
|
const existing = unfilledShows.get(req.media.tvdbId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!existing.requestedBy.includes(user.displayName))
|
if (!existing.requestedBy.includes(user.displayName))
|
||||||
existing.requestedBy.push(user.displayName);
|
existing.requestedBy.push(user.displayName);
|
||||||
existing.oldestAge = Math.max(existing.oldestAge, age);
|
existing.oldestAgeHours = Math.max(existing.oldestAgeHours, ageHours);
|
||||||
// Upgrade to partial flag if we now know it's partial
|
if (partial) existing.partial = true;
|
||||||
if (partial) (existing as UnfilledEntry & { partial?: boolean }).partial = true;
|
|
||||||
} else {
|
} else {
|
||||||
const record: UnfilledEntry & { partial?: boolean } = {
|
unfilledShows.set(req.media.tvdbId, {
|
||||||
entry: { title, sizeOnDisk: entry?.sizeOnDisk ?? 0, available: true },
|
entry: { title, sizeOnDisk: entry?.sizeOnDisk ?? 0, available: true },
|
||||||
requestedBy: [user.displayName],
|
requestedBy: [user.displayName],
|
||||||
oldestAge: age,
|
oldestAgeHours: ageHours,
|
||||||
};
|
partial,
|
||||||
if (partial) record.partial = true;
|
});
|
||||||
unfilledShows.set(req.media.tvdbId, record);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [tmdbId, { entry, requestedBy, oldestAge }] of unfilledMovies) {
|
for (const [tmdbId, { entry, requestedBy, oldestAgeHours }] of unfilledMovies) {
|
||||||
if (flaggedMovies.has(tmdbId)) continue;
|
if (flaggedMovies.has(tmdbId)) continue;
|
||||||
flaggedMovies.add(tmdbId);
|
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}` : "");
|
const byStr = requestedBy.slice(0, 3).join(", ") + (requestedBy.length > 3 ? ` +${requestedBy.length - 3}` : "");
|
||||||
candidates.push({
|
candidates.push({
|
||||||
key: `unfulfilled:movie:${tmdbId}`,
|
key: `unfulfilled:movie:${tmdbId}`,
|
||||||
category: "unfulfilled",
|
category: "unfulfilled",
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
title: `Not Downloaded: ${entry.title}`,
|
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,
|
mediaId: tmdbId,
|
||||||
mediaType: "movie",
|
mediaType: "movie",
|
||||||
mediaTitle: entry.title,
|
mediaTitle: entry.title,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [tvdbId, data] of unfilledShows) {
|
for (const [tvdbId, { entry, requestedBy, oldestAgeHours, partial }] of unfilledShows) {
|
||||||
if (flaggedShows.has(tvdbId)) continue;
|
if (flaggedShows.has(tvdbId)) continue;
|
||||||
flaggedShows.add(tvdbId);
|
flaggedShows.add(tvdbId);
|
||||||
const { entry, requestedBy, oldestAge } = data;
|
|
||||||
const partial = (data as UnfilledEntry & { partial?: boolean }).partial ?? false;
|
|
||||||
const sonarrEntry = sonarrMap.get(tvdbId);
|
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 byStr = requestedBy.slice(0, 3).join(", ") + (requestedBy.length > 3 ? ` +${requestedBy.length - 3}` : "");
|
||||||
const pct = partial && sonarrEntry?.episodeFileCount !== undefined && sonarrEntry.totalEpisodeCount
|
const pct = partial && sonarrEntry?.episodeFileCount !== undefined && sonarrEntry.totalEpisodeCount
|
||||||
? Math.round((sonarrEntry.episodeFileCount / sonarrEntry.totalEpisodeCount) * 100)
|
? Math.round((sonarrEntry.episodeFileCount / sonarrEntry.totalEpisodeCount) * 100)
|
||||||
: null;
|
: null;
|
||||||
const description = partial && pct !== null
|
const description = pct !== null
|
||||||
? `Only ${pct}% of episodes downloaded (${sonarrEntry!.episodeFileCount}/${sonarrEntry!.totalEpisodeCount}). Approved ${daysStr} ago. Requested by ${byStr}.`
|
? `Only ${pct}% of episodes downloaded (${sonarrEntry!.episodeFileCount}/${sonarrEntry!.totalEpisodeCount}). Approved ${formatAge(oldestAgeHours)} ago. Requested by ${byStr}.`
|
||||||
: `Approved ${daysStr} ago but no files found in Sonarr. Requested by ${byStr}.`;
|
: `Approved ${formatAge(oldestAgeHours)} ago but no files found in Sonarr. Requested by ${byStr}.`;
|
||||||
candidates.push({
|
candidates.push({
|
||||||
key: `unfulfilled:tv:${tvdbId}`,
|
key: `unfulfilled:tv:${tvdbId}`,
|
||||||
category: "unfulfilled",
|
category: "unfulfilled",
|
||||||
@@ -168,7 +164,7 @@ export function generateAlertCandidates(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── CONTENT-CENTRIC: stale pending requests ───────────────────────────────
|
// ── CONTENT-CENTRIC: stale pending requests ───────────────────────────────
|
||||||
// One alert per pending request item (not per user)
|
|
||||||
const flaggedPending = new Set<number>();
|
const flaggedPending = new Set<number>();
|
||||||
|
|
||||||
for (const user of userStats) {
|
for (const user of userStats) {
|
||||||
@@ -177,18 +173,13 @@ export function generateAlertCandidates(
|
|||||||
if (req.status !== 1) continue;
|
if (req.status !== 1) continue;
|
||||||
if (daysSince(req.createdAt) < PENDING_MIN_AGE_DAYS) continue;
|
if (daysSince(req.createdAt) < PENDING_MIN_AGE_DAYS) continue;
|
||||||
|
|
||||||
const age = Math.floor(daysSince(req.createdAt));
|
const ageStr = formatAge(hoursSince(req.createdAt));
|
||||||
const ageStr = age === 1 ? "1 day" : `${age} days`;
|
|
||||||
|
|
||||||
if (req.type === "movie" && !flaggedPending.has(req.id)) {
|
if (req.type === "movie" && !flaggedPending.has(req.id)) {
|
||||||
// Skip if movie isn't released yet
|
|
||||||
const movieEntry = radarrMap.get(req.media.tmdbId);
|
const movieEntry = radarrMap.get(req.media.tmdbId);
|
||||||
if (movieEntry && !movieEntry.available) continue;
|
if (movieEntry && !movieEntry.available) continue;
|
||||||
flaggedPending.add(req.id);
|
flaggedPending.add(req.id);
|
||||||
const title =
|
const title = movieEntry?.title ?? req.media.title ?? `Movie #${req.media.tmdbId}`;
|
||||||
radarrMap.get(req.media.tmdbId)?.title ??
|
|
||||||
req.media.title ??
|
|
||||||
`Movie #${req.media.tmdbId}`;
|
|
||||||
candidates.push({
|
candidates.push({
|
||||||
key: `pending:req:${req.id}`,
|
key: `pending:req:${req.id}`,
|
||||||
category: "pending",
|
category: "pending",
|
||||||
@@ -202,12 +193,10 @@ export function generateAlertCandidates(
|
|||||||
userName: user.displayName,
|
userName: user.displayName,
|
||||||
});
|
});
|
||||||
} else if (req.type === "tv" && req.media.tvdbId && !flaggedPending.has(req.id)) {
|
} 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);
|
const showEntry = sonarrMap.get(req.media.tvdbId);
|
||||||
if (showEntry && !showEntry.available) continue;
|
if (showEntry && !showEntry.available) continue;
|
||||||
flaggedPending.add(req.id);
|
flaggedPending.add(req.id);
|
||||||
const title =
|
const title = showEntry?.title ?? req.media.title ?? `Show #${req.media.tvdbId}`;
|
||||||
showEntry?.title ?? req.media.title ?? `Show #${req.media.tvdbId}`;
|
|
||||||
candidates.push({
|
candidates.push({
|
||||||
key: `pending:req:${req.id}`,
|
key: `pending:req:${req.id}`,
|
||||||
category: "pending",
|
category: "pending",
|
||||||
@@ -224,36 +213,43 @@ export function generateAlertCandidates(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── USER-BEHAVIOR: one category per user, most severe wins ───────────────
|
// ── USER-BEHAVIOR ─────────────────────────────────────────────────────────
|
||||||
// Ghost Requester takes priority over Low Watch Rate for the same user.
|
|
||||||
// Only generate these alerts if the user is "established" (old enough account).
|
|
||||||
|
|
||||||
for (const user of userStats) {
|
for (const user of userStats) {
|
||||||
const requests = allRequests.get(user.userId) ?? [];
|
const requests = allRequests.get(user.userId) ?? [];
|
||||||
if (requests.length === 0) continue;
|
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 oldestRequestAge = Math.max(...requests.map((r) => daysSince(r.createdAt)));
|
||||||
const isEstablished = oldestRequestAge >= USER_MIN_AGE_DAYS;
|
if (oldestRequestAge < USER_MIN_AGE_DAYS) continue;
|
||||||
if (!isEstablished) continue;
|
|
||||||
|
|
||||||
// ── Ghost Requester ───────────────────────────────────────────────────
|
// ── Ghost Requester ───────────────────────────────────────────────────
|
||||||
if (
|
// Fires if the user hasn't watched anything since before their last
|
||||||
hasTautulli &&
|
// GHOST_RECENT_REQUESTS approved requests were made.
|
||||||
user.plays === 0 &&
|
if (hasTautulli) {
|
||||||
user.requestCount >= MIN_REQUESTS_GHOST
|
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({
|
candidates.push({
|
||||||
key: `ghost:${user.userId}`,
|
key: `ghost:${user.userId}`,
|
||||||
category: "ghost",
|
category: "ghost",
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
title: `Ghost Requester`,
|
title: `Ghost Requester`,
|
||||||
description: `${user.displayName} has ${user.requestCount} requests but has never watched anything on Plex.`,
|
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,
|
userId: user.userId,
|
||||||
userName: user.displayName,
|
userName: user.displayName,
|
||||||
});
|
});
|
||||||
// Ghost takes priority — skip watch-rate check for this user
|
continue; // ghost takes priority over low watch rate
|
||||||
continue;
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Low Watch Rate ────────────────────────────────────────────────────
|
// ── Low Watch Rate ────────────────────────────────────────────────────
|
||||||
@@ -275,25 +271,8 @@ export function generateAlertCandidates(
|
|||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
userName: user.displayName,
|
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 ────────────────────────────
|
// ── SYSTEM: Tautulli configured but no matches ────────────────────────────
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export interface UserStat {
|
|||||||
// Tautulli (null when not configured)
|
// Tautulli (null when not configured)
|
||||||
plays: number | null;
|
plays: number | null;
|
||||||
watchHours: 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)
|
// Per-metric ranks (1 = top user for that metric, null = Tautulli not available)
|
||||||
storageRank: number;
|
storageRank: number;
|
||||||
requestRank: number;
|
requestRank: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user