Rework alert cooldown model

Content alerts (unfulfilled, pending, tautulli-no-matches) now have
zero cooldown on manual close — they reopen immediately on the next
refresh if the condition still exists. Closing is an acknowledgment
of the current state, not a suppression of future alerts.

User-behavior alerts (ghost, watchrate) keep a cooldown (7 days) so
a single manual close isn't immediately undone by the next refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 11:38:05 -04:00
parent 8fe61cdeb8
commit 6fa246d3c4
2 changed files with 28 additions and 23 deletions

View File

@@ -76,7 +76,7 @@ These are keyed per piece of media, not per user. If multiple users requested th
- Skipped 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. - **Auto-resolves** when the file appears.
- Manual close cooldown: **3 days**. - Manual close: no cooldown — reopens on the next refresh if the file still isn't there.
--- ---
@@ -92,7 +92,7 @@ These are keyed per piece of media, not per user. If multiple users requested th
- Only fires for series with `status: "ended"` in Sonarr. Continuing shows are excluded because missing episodes may 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.
- Completion is calculated as `episodeFileCount / totalEpisodeCount` (not Sonarr's `percentOfEpisodes`, which measures against monitored episodes only). - Completion is calculated as `episodeFileCount / totalEpisodeCount` (not Sonarr's `percentOfEpisodes`, which measures against monitored episodes only).
- **Auto-resolves** when all episodes are on disk. - **Auto-resolves** when all episodes are on disk.
- Manual close cooldown: **3 days**. - Manual close: no cooldown — reopens on the next refresh if episodes are still missing.
--- ---
@@ -107,7 +107,7 @@ These are keyed per piece of media, not per user. If multiple users requested th
- One alert per request item, not per user. - One alert per request item, not per user.
- Skipped if the content is unreleased. - 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: no cooldown — reopens on the next refresh if still pending.
--- ---
@@ -133,7 +133,7 @@ Rather than checking lifetime play counts, this looks at recency: if a user's la
|---|---|---| |---|---|---|
| `GHOST_RECENT_REQUESTS` | `5` | Number of recent approved requests to evaluate. Also the minimum required before the alert can fire. | | `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: **7 days**.
--- ---
@@ -146,7 +146,7 @@ Rather than checking lifetime play counts, this looks at recency: if a user's la
| `MIN_REQUESTS_WATCHRATE` | `10` | Minimum requests before the ratio is considered meaningful. | | `MIN_REQUESTS_WATCHRATE` | `10` | Minimum requests before the ratio is considered meaningful. |
| `LOW_WATCH_RATE` | `0.2` | Ratio of `plays / requests` below which an alert fires (default: under 20%). | | `LOW_WATCH_RATE` | `0.2` | Ratio of `plays / requests` below which an alert fires (default: under 20%). |
- Manual close cooldown: **14 days**. - Manual close cooldown: **7 days**.
--- ---
@@ -158,7 +158,7 @@ Rather than checking lifetime play counts, this looks at recency: if a user's la
This usually means emails don't align between the two services. Check that users have the same email address in both Overseerr and Tautulli (or that display names match as a fallback). This usually means emails don't align between the two services. Check that users have the same email address in both Overseerr and Tautulli (or that display names match as a fallback).
- Manual close cooldown: **1 day**. - Manual close: no cooldown.
--- ---
@@ -183,6 +183,7 @@ clears closed │
immediately re-open immediately re-open
``` ```
- **Auto-resolved** alerts have no cooldown and will reopen immediately if the condition returns. - **Auto-resolved** alerts reopen immediately if the condition returns.
- **Manually closed** alerts suppress re-opening for a category-specific number of days (see cooldowns above). - **Content alerts** (unfulfilled, pending) have no cooldown on manual close — they reopen on the next refresh if the condition still exists. Closing is an acknowledgment, not a suppression.
- Reopening a manually closed alert via the UI clears the cooldown immediately. - **User-behavior alerts** (ghost, watchrate) suppress re-opening for 7 days after a manual close.
- Reopening a manually closed alert via the UI always clears the cooldown immediately.

View File

@@ -16,19 +16,19 @@ import {
const DATA_DIR = join(process.cwd(), "data"); const DATA_DIR = join(process.cwd(), "data");
const DB_PATH = join(DATA_DIR, "alerts.json"); const DB_PATH = join(DATA_DIR, "alerts.json");
// Cooldown days applied on MANUAL close — suppresses re-opening for this long. // Cooldown days applied on MANUAL close.
// Auto-resolved alerts have no cooldown and can reopen immediately if the // 0 = no cooldown: content alerts reopen immediately on the next refresh if
// condition returns. // the condition still exists. Closing is an acknowledgment, not a suppression.
// >0 = user-behavior alerts: suppress re-opening for this many days so a single
// acknowledgment isn't immediately undone by the next refresh.
const COOLDOWN: Record<string, number> = { const COOLDOWN: Record<string, number> = {
unfulfilled: 3, unfulfilled: 0,
pending: 3, pending: 0,
ghost: 14, ghost: 7,
watchrate: 14, watchrate: 7,
declined: 14, "tautulli-no-matches": 0,
"tautulli-no-matches": 1,
"dark-library": 30,
}; };
const DEFAULT_COOLDOWN = 7; const DEFAULT_COOLDOWN = 0;
interface Store { interface Store {
nextId: number; nextId: number;
@@ -205,13 +205,17 @@ export function closeAlert(id: number): Alert | null {
if (!alert) return null; if (!alert) return null;
const cooldownDays = COOLDOWN[alert.category] ?? DEFAULT_COOLDOWN; const cooldownDays = COOLDOWN[alert.category] ?? DEFAULT_COOLDOWN;
const suppressUntil = new Date(); let suppressedUntil: string | null = null;
suppressUntil.setDate(suppressUntil.getDate() + cooldownDays); if (cooldownDays > 0) {
const d = new Date();
d.setDate(d.getDate() + cooldownDays);
suppressedUntil = d.toISOString();
}
alert.status = "closed"; alert.status = "closed";
alert.closeReason = "manual"; alert.closeReason = "manual";
alert.closedAt = new Date().toISOString(); alert.closedAt = new Date().toISOString();
alert.suppressedUntil = suppressUntil.toISOString(); alert.suppressedUntil = suppressedUntil;
save(store); save(store);
return toAlert(alert); return toAlert(alert);
} }