Add settings UI, Discord notifications, and alert detail improvements

- Settings modal (gear icon) lets you configure all service URLs and API
  keys from the dashboard; values persist to data/settings.json with
  process.env as fallback so existing .env.local setups keep working
- Per-service Test button hits each service's status endpoint and reports
  the version on success
- Discord webhook support: structured embeds per alert category (requesters,
  approval age, episode progress, watch-rate stats) sent on new/reopened
  alerts only — already-open alerts don't re-notify
- Alert detail page restructured: prose descriptions replaced with labelled
  fields, episode progress bar for partial TV, watch-rate stat block,
  View in Radarr/Sonarr/Seerr action buttons, requester names link to
  Overseerr profiles, timestamps moved inline with status
- Tab state is pure client state (no ?tab= in URL); router.back() used
  on alert detail for clean browser history

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 14:57:07 -04:00
parent 2374bad7ba
commit 641a7fd096
20 changed files with 2191 additions and 302 deletions
+5 -1
View File
@@ -9,6 +9,7 @@ import {
import { lookupTautulliUser } from "@/lib/tautulli";
import { generateAlertCandidates } from "@/lib/alerts";
import { upsertAlerts } from "@/lib/db";
import { sendDiscordNotifications } from "@/lib/discord";
export function bytesToGB(bytes: number): number {
return Math.round((bytes / 1024 / 1024 / 1024) * 10) / 10;
@@ -124,7 +125,10 @@ export function computeStats(
sonarrMap,
hasTautulli
);
const openAlertCount = upsertAlerts(candidates);
const { openCount: openAlertCount, newAlerts } = upsertAlerts(candidates);
if (newAlerts.length > 0) {
sendDiscordNotifications(newAlerts).catch(() => {});
}
const totalRequests = userStats.reduce((s, u) => s + u.requestCount, 0);
const totalStorageGB = bytesToGB(
+32 -8
View File
@@ -1,4 +1,5 @@
import { UserStat, OverseerrRequest, MediaEntry, AlertCandidate } from "@/lib/types";
import { getSettings } from "@/lib/settings";
// ─── Tunables ─────────────────────────────────────────────────────────────────
@@ -46,6 +47,7 @@ export function generateAlertCandidates(
hasTautulli: boolean
): AlertCandidate[] {
const candidates: AlertCandidate[] = [];
const { radarr: radarrSettings, sonarr: sonarrSettings, seerr: seerrSettings } = getSettings();
// ── CONTENT-CENTRIC: one alert per piece of media ──────────────────────────
@@ -55,6 +57,8 @@ export function generateAlertCandidates(
interface UnfilledEntry {
entry: MediaEntry;
requestedBy: string[];
requestedByIds: number[];
tmdbId?: number; // TV shows only — needed to build the Seerr URL (movies use map key)
oldestAgeHours: number;
partial?: boolean;
}
@@ -77,13 +81,16 @@ export function generateAlertCandidates(
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))
if (!existing.requestedBy.includes(user.displayName)) {
existing.requestedBy.push(user.displayName);
existing.requestedByIds.push(user.userId);
}
existing.oldestAgeHours = Math.max(existing.oldestAgeHours, ageHours);
} else {
unfilledMovies.set(req.media.tmdbId, {
entry: { title, sizeOnDisk: 0, available: true },
requestedBy: [user.displayName],
requestedByIds: [user.userId],
oldestAgeHours: ageHours,
});
}
@@ -108,14 +115,18 @@ export function generateAlertCandidates(
const partial = !isNothingDownloaded && isPartiallyDownloaded;
const existing = unfilledShows.get(req.media.tvdbId);
if (existing) {
if (!existing.requestedBy.includes(user.displayName))
if (!existing.requestedBy.includes(user.displayName)) {
existing.requestedBy.push(user.displayName);
existing.requestedByIds.push(user.userId);
}
existing.oldestAgeHours = Math.max(existing.oldestAgeHours, ageHours);
if (partial) existing.partial = true;
} else {
unfilledShows.set(req.media.tvdbId, {
entry: { title, sizeOnDisk: entry?.sizeOnDisk ?? 0, available: true },
requestedBy: [user.displayName],
requestedByIds: [user.userId],
tmdbId: req.media.tmdbId,
oldestAgeHours: ageHours,
partial,
});
@@ -124,13 +135,15 @@ export function generateAlertCandidates(
}
}
for (const [tmdbId, { entry, requestedBy, oldestAgeHours }] of unfilledMovies) {
for (const [tmdbId, { entry, requestedBy, requestedByIds, oldestAgeHours }] of unfilledMovies) {
if (flaggedMovies.has(tmdbId)) continue;
flaggedMovies.add(tmdbId);
const byStr = requestedBy.slice(0, 3).join(", ") + (requestedBy.length > 3 ? ` +${requestedBy.length - 3}` : "");
const mediaUrl = entry.titleSlug && process.env.RADARR_URL
? `${process.env.RADARR_URL}/movie/${entry.titleSlug}`
const radarrEntry = radarrMap.get(tmdbId);
const mediaUrl = radarrEntry?.titleSlug && radarrSettings.url
? `${radarrSettings.url}/movie/${radarrEntry.titleSlug}`
: undefined;
const seerrMediaUrl = seerrSettings.url ? `${seerrSettings.url}/movie/${tmdbId}` : undefined;
candidates.push({
key: `unfulfilled:movie:${tmdbId}`,
category: "unfulfilled",
@@ -141,10 +154,12 @@ export function generateAlertCandidates(
mediaType: "movie",
mediaTitle: entry.title,
mediaUrl,
seerrMediaUrl,
requesterIds: requestedByIds,
});
}
for (const [tvdbId, { entry, requestedBy, oldestAgeHours, partial }] of unfilledShows) {
for (const [tvdbId, { entry, requestedBy, requestedByIds, tmdbId: showTmdbId, oldestAgeHours, partial }] of unfilledShows) {
if (flaggedShows.has(tvdbId)) continue;
flaggedShows.add(tvdbId);
const sonarrEntry = sonarrMap.get(tvdbId);
@@ -155,8 +170,11 @@ export function generateAlertCandidates(
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}.`;
const mediaUrl = sonarrEntry?.titleSlug && process.env.SONARR_URL
? `${process.env.SONARR_URL}/series/${sonarrEntry.titleSlug}`
const mediaUrl = sonarrEntry?.titleSlug && sonarrSettings.url
? `${sonarrSettings.url}/series/${sonarrEntry.titleSlug}`
: undefined;
const seerrMediaUrl = showTmdbId && seerrSettings.url
? `${seerrSettings.url}/tv/${showTmdbId}`
: undefined;
candidates.push({
key: `unfulfilled:tv:${tvdbId}`,
@@ -168,6 +186,8 @@ export function generateAlertCandidates(
mediaType: "tv",
mediaTitle: entry.title,
mediaUrl,
seerrMediaUrl,
requesterIds: requestedByIds,
});
}
@@ -199,6 +219,8 @@ export function generateAlertCandidates(
mediaTitle: title,
userId: user.userId,
userName: user.displayName,
requesterIds: [user.userId],
seerrMediaUrl: seerrSettings.url ? `${seerrSettings.url}/movie/${req.media.tmdbId}` : undefined,
});
} else if (req.type === "tv" && req.media.tvdbId && !flaggedPending.has(req.id)) {
const showEntry = sonarrMap.get(req.media.tvdbId);
@@ -216,6 +238,8 @@ export function generateAlertCandidates(
mediaTitle: title,
userId: user.userId,
userName: user.displayName,
requesterIds: [user.userId],
seerrMediaUrl: seerrSettings.url ? `${seerrSettings.url}/tv/${req.media.tmdbId}` : undefined,
});
}
}
+405 -192
View File
@@ -1,9 +1,13 @@
/**
* Lightweight JSON file store for alert persistence.
* Lives at data/alerts.json (gitignored, created on first run).
* SQLite-backed alert store using better-sqlite3.
* Lives at data/alerts.db (gitignored).
*
* Uses a global singleton so Next.js hot-reload doesn't open multiple
* connections. WAL mode is enabled for concurrent read performance.
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
import Database from "better-sqlite3";
import { mkdirSync, existsSync, readFileSync, renameSync } from "fs";
import { join } from "path";
import {
AlertCandidate,
@@ -14,7 +18,9 @@ import {
} from "./types";
const DATA_DIR = join(process.cwd(), "data");
const DB_PATH = join(DATA_DIR, "alerts.json");
const DB_PATH = join(DATA_DIR, "alerts.db");
const LEGACY_JSON_PATH = join(DATA_DIR, "alerts.json");
const LEGACY_MIGRATED_PATH = join(DATA_DIR, "alerts.json.migrated");
// Cooldown days applied on MANUAL close.
// 0 = no cooldown: content alerts reopen immediately on the next refresh if
@@ -30,196 +36,387 @@ const COOLDOWN: Record<string, number> = {
};
const DEFAULT_COOLDOWN = 0;
interface Store {
nextId: number;
nextCommentId: number;
alerts: Record<string, StoredAlert>; // keyed by alert.key
// ── Singleton ──────────────────────────────────────────────────────────────────
declare global {
// eslint-disable-next-line no-var
var __alertsDb: Database.Database | undefined;
}
interface StoredAlert {
id: number;
key: string;
category: string;
severity: string;
title: string;
description: string;
userId?: number;
userName?: string;
mediaId?: number;
mediaType?: string;
mediaTitle?: string;
mediaUrl?: string;
status: AlertStatus;
closeReason: AlertCloseReason | null;
suppressedUntil: string | null;
firstSeen: string;
lastSeen: string;
closedAt: string | null;
comments: Array<{ id: number; body: string; createdAt: string; author: "user" | "system" }>;
}
function getDb(): Database.Database {
if (global.__alertsDb) return global.__alertsDb;
function load(): Store {
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
if (!existsSync(DB_PATH)) {
const empty: Store = { nextId: 1, nextCommentId: 1, alerts: {} };
writeFileSync(DB_PATH, JSON.stringify(empty, null, 2));
return empty;
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
initSchema(db);
maybeMigrateJson(db);
global.__alertsDb = db;
return db;
}
// ── Schema ─────────────────────────────────────────────────────────────────────
function initSchema(db: Database.Database) {
db.exec(`
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
category TEXT NOT NULL,
severity TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
userId INTEGER,
userName TEXT,
mediaId INTEGER,
mediaType TEXT,
mediaTitle TEXT,
mediaUrl TEXT,
status TEXT NOT NULL DEFAULT 'open',
closeReason TEXT,
suppressedUntil TEXT,
firstSeen TEXT NOT NULL,
lastSeen TEXT NOT NULL,
closedAt TEXT,
requesterIds TEXT,
seerrMediaUrl TEXT
);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alertId INTEGER NOT NULL REFERENCES alerts(id) ON DELETE CASCADE,
body TEXT NOT NULL,
author TEXT NOT NULL DEFAULT 'user',
createdAt TEXT NOT NULL
);
`);
// Additive migrations for existing databases
try { db.exec("ALTER TABLE alerts ADD COLUMN requesterIds TEXT"); } catch {}
try { db.exec("ALTER TABLE alerts ADD COLUMN seerrMediaUrl TEXT"); } catch {}
}
// ── Legacy JSON migration ──────────────────────────────────────────────────────
function maybeMigrateJson(db: Database.Database) {
if (!existsSync(LEGACY_JSON_PATH)) return;
// Only migrate if the alerts table is empty
const count = (db.prepare("SELECT COUNT(*) as n FROM alerts").get() as { n: number }).n;
if (count > 0) {
// Table already has data — just remove the legacy file
renameSync(LEGACY_JSON_PATH, LEGACY_MIGRATED_PATH);
return;
}
try {
interface LegacyComment { id: number; body: string; author: "user" | "system"; createdAt: string; }
interface LegacyAlert {
id: number; key: string; category: string; severity: string;
title: string; description: string;
userId?: number; userName?: string;
mediaId?: number; mediaType?: string; mediaTitle?: string; mediaUrl?: string;
status: string; closeReason: string | null; suppressedUntil: string | null;
firstSeen: string; lastSeen: string; closedAt: string | null;
comments: LegacyComment[];
}
interface LegacyStore { alerts: Record<string, LegacyAlert>; }
const raw = readFileSync(LEGACY_JSON_PATH, "utf-8");
const store: LegacyStore = JSON.parse(raw);
const insertAlert = db.prepare(`
INSERT INTO alerts
(key, category, severity, title, description, userId, userName,
mediaId, mediaType, mediaTitle, mediaUrl, status, closeReason,
suppressedUntil, firstSeen, lastSeen, closedAt)
VALUES
(@key, @category, @severity, @title, @description, @userId, @userName,
@mediaId, @mediaType, @mediaTitle, @mediaUrl, @status, @closeReason,
@suppressedUntil, @firstSeen, @lastSeen, @closedAt)
`);
const insertComment = db.prepare(`
INSERT INTO comments (alertId, body, author, createdAt)
VALUES (@alertId, @body, @author, @createdAt)
`);
const migrate = db.transaction(() => {
for (const a of Object.values(store.alerts)) {
const info = insertAlert.run({
key: a.key, category: a.category, severity: a.severity,
title: a.title, description: a.description,
userId: a.userId ?? null, userName: a.userName ?? null,
mediaId: a.mediaId ?? null, mediaType: a.mediaType ?? null,
mediaTitle: a.mediaTitle ?? null, mediaUrl: a.mediaUrl ?? null,
status: a.status, closeReason: a.closeReason ?? null,
suppressedUntil: a.suppressedUntil ?? null,
firstSeen: a.firstSeen, lastSeen: a.lastSeen, closedAt: a.closedAt ?? null,
});
const alertId = info.lastInsertRowid as number;
for (const c of a.comments ?? []) {
insertComment.run({ alertId, body: c.body, author: c.author, createdAt: c.createdAt });
}
}
});
migrate();
renameSync(LEGACY_JSON_PATH, LEGACY_MIGRATED_PATH);
console.log("[db] Migrated alerts.json → SQLite");
} catch (err) {
console.error("[db] Migration failed:", err);
}
return JSON.parse(readFileSync(DB_PATH, "utf-8")) as Store;
}
function save(store: Store) {
writeFileSync(DB_PATH, JSON.stringify(store, null, 2));
// ── Row → Alert ────────────────────────────────────────────────────────────────
interface AlertRow {
id: number; key: string; category: string; severity: string;
title: string; description: string;
userId: number | null; userName: string | null;
mediaId: number | null; mediaType: string | null;
mediaTitle: string | null; mediaUrl: string | null;
status: string; closeReason: string | null; suppressedUntil: string | null;
firstSeen: string; lastSeen: string; closedAt: string | null;
requesterIds: string | null; // JSON-encoded number[]
seerrMediaUrl: string | null;
}
function toAlert(s: StoredAlert): Alert {
interface CommentRow {
id: number; alertId: number; body: string; author: string; createdAt: string;
}
function rowToAlert(row: AlertRow, comments: CommentRow[]): Alert {
return {
id: s.id,
key: s.key,
category: s.category,
severity: s.severity as Alert["severity"],
title: s.title,
description: s.description,
userId: s.userId,
userName: s.userName,
mediaId: s.mediaId,
mediaType: s.mediaType as Alert["mediaType"],
mediaTitle: s.mediaTitle,
mediaUrl: s.mediaUrl,
status: s.status,
closeReason: s.closeReason ?? null,
suppressedUntil: s.suppressedUntil,
firstSeen: s.firstSeen,
lastSeen: s.lastSeen,
closedAt: s.closedAt,
comments: s.comments,
id: row.id,
key: row.key,
category: row.category,
severity: row.severity as Alert["severity"],
title: row.title,
description: row.description,
userId: row.userId ?? undefined,
userName: row.userName ?? undefined,
mediaId: row.mediaId ?? undefined,
mediaType: row.mediaType as Alert["mediaType"],
mediaTitle: row.mediaTitle ?? undefined,
mediaUrl: row.mediaUrl ?? undefined,
requesterIds: row.requesterIds ? (JSON.parse(row.requesterIds) as number[]) : undefined,
seerrMediaUrl: row.seerrMediaUrl ?? undefined,
status: row.status as AlertStatus,
closeReason: row.closeReason as AlertCloseReason | null,
suppressedUntil: row.suppressedUntil,
firstSeen: row.firstSeen,
lastSeen: row.lastSeen,
closedAt: row.closedAt,
comments: comments.map((c) => ({
id: c.id,
body: c.body,
author: c.author as "user" | "system",
createdAt: c.createdAt,
})),
};
}
function getCommentsForAlert(db: Database.Database, alertId: number): CommentRow[] {
return db.prepare(
"SELECT * FROM comments WHERE alertId = ? ORDER BY createdAt ASC, id ASC"
).all(alertId) as CommentRow[];
}
// ── Exported API ───────────────────────────────────────────────────────────────
export interface UpsertResult {
openCount: number;
/** Candidates that were newly created or reopened this run — used for notifications. */
newAlerts: AlertCandidate[];
}
/**
* Merge generated candidates into the store, then auto-resolve any open alerts
* whose condition is no longer present (key not in this run's candidate set).
*
* Auto-resolved alerts:
* - Are marked closed with closeReason = "resolved"
* - Have NO suppressedUntil — they can reopen immediately if the condition returns
*
* Manually closed alerts:
* - Have suppressedUntil set (cooldown per category)
* - Won't be re-opened by upsertAlerts until that cooldown expires
*
* Returns the count of open alerts after the merge.
* Returns the count of open alerts after the merge and the list of newly
* created or reopened alert candidates (for Discord notifications etc.).
*/
export function upsertAlerts(candidates: AlertCandidate[]): number {
const store = load();
export function upsertAlerts(candidates: AlertCandidate[]): UpsertResult {
const db = getDb();
const now = new Date();
const nowISO = now.toISOString();
const candidateKeys = new Set(candidates.map((c) => c.key));
// ── Step 1: upsert candidates ─────────────────────────────────────────────
for (const c of candidates) {
const existing = store.alerts[c.key];
const getByKey = db.prepare<[string], AlertRow>(
"SELECT * FROM alerts WHERE key = ?"
);
const updateAlert = db.prepare(`
UPDATE alerts SET
status = @status, closeReason = @closeReason, closedAt = @closedAt,
suppressedUntil = @suppressedUntil, lastSeen = @lastSeen,
title = @title, description = @description,
userName = COALESCE(@userName, userName),
mediaTitle = COALESCE(@mediaTitle, mediaTitle),
mediaUrl = COALESCE(@mediaUrl, mediaUrl),
requesterIds = COALESCE(@requesterIds, requesterIds),
seerrMediaUrl = COALESCE(@seerrMediaUrl, seerrMediaUrl)
WHERE key = @key
`);
const insertAlert = db.prepare(`
INSERT INTO alerts
(key, category, severity, title, description, userId, userName,
mediaId, mediaType, mediaTitle, mediaUrl, status, closeReason,
suppressedUntil, firstSeen, lastSeen, closedAt, requesterIds, seerrMediaUrl)
VALUES
(@key, @category, @severity, @title, @description, @userId, @userName,
@mediaId, @mediaType, @mediaTitle, @mediaUrl, 'open', NULL, NULL,
@firstSeen, @lastSeen, NULL, @requesterIds, @seerrMediaUrl)
`);
const insertComment = db.prepare(`
INSERT INTO comments (alertId, body, author, createdAt)
VALUES (@alertId, @body, @author, @createdAt)
`);
const getOpenAlerts = db.prepare<[], AlertRow>(
"SELECT * FROM alerts WHERE status = 'open'"
);
if (existing) {
const isSuppressed =
existing.status === "closed" &&
existing.suppressedUntil !== null &&
new Date(existing.suppressedUntil) > now;
const newAlerts: AlertCandidate[] = [];
if (isSuppressed) continue;
db.transaction(() => {
// ── Step 1: upsert candidates ───────────────────────────────────────────
for (const c of candidates) {
const existing = getByKey.get(c.key);
// Re-open if previously closed (manually or resolved) and not suppressed.
// Preserve firstSeen and comments — this is the same incident continuing.
if (existing.status === "closed") {
existing.status = "open";
existing.closeReason = null;
existing.closedAt = null;
existing.suppressedUntil = null;
existing.comments.push({
id: store.nextCommentId++,
body: "Alert reopened — condition is still active.",
createdAt: nowISO,
author: "system",
if (existing) {
const isSuppressed =
existing.status === "closed" &&
existing.suppressedUntil !== null &&
new Date(existing.suppressedUntil) > now;
if (isSuppressed) continue;
const requesterIds = c.requesterIds?.length ? JSON.stringify(c.requesterIds) : null;
const seerrMediaUrl = c.seerrMediaUrl ?? null;
if (existing.status === "closed") {
// Reopen — notify
updateAlert.run({
key: c.key,
status: "open",
closeReason: null,
closedAt: null,
suppressedUntil: null,
lastSeen: nowISO,
title: c.title,
description: c.description,
userName: c.userName ?? null,
mediaTitle: c.mediaTitle ?? null,
mediaUrl: c.mediaUrl ?? null,
requesterIds,
seerrMediaUrl,
});
insertComment.run({
alertId: existing.id,
body: "Alert reopened — condition is still active.",
author: "system",
createdAt: nowISO,
});
newAlerts.push(c);
} else {
// Refresh content — already open, no notification
updateAlert.run({
key: c.key,
status: "open",
closeReason: null,
closedAt: null,
suppressedUntil: null,
lastSeen: nowISO,
title: c.title,
description: c.description,
userName: c.userName ?? null,
mediaTitle: c.mediaTitle ?? null,
mediaUrl: c.mediaUrl ?? null,
requesterIds,
seerrMediaUrl,
});
}
} else {
// New alert — notify
insertAlert.run({
key: c.key,
category: c.category,
severity: c.severity,
title: c.title,
description: c.description,
userId: c.userId ?? null,
userName: c.userName ?? null,
mediaId: c.mediaId ?? null,
mediaType: c.mediaType ?? null,
mediaTitle: c.mediaTitle ?? null,
mediaUrl: c.mediaUrl ?? null,
firstSeen: nowISO,
lastSeen: nowISO,
requesterIds: c.requesterIds?.length ? JSON.stringify(c.requesterIds) : null,
seerrMediaUrl: c.seerrMediaUrl ?? null,
});
newAlerts.push(c);
}
// Refresh content and lastSeen
existing.lastSeen = nowISO;
existing.title = c.title;
existing.description = c.description;
if (c.userName) existing.userName = c.userName;
if (c.mediaTitle) existing.mediaTitle = c.mediaTitle;
if (c.mediaUrl) existing.mediaUrl = c.mediaUrl;
} else {
store.alerts[c.key] = {
id: store.nextId++,
key: c.key,
category: c.category,
severity: c.severity,
title: c.title,
description: c.description,
userId: c.userId,
userName: c.userName,
mediaId: c.mediaId,
mediaType: c.mediaType,
mediaTitle: c.mediaTitle,
mediaUrl: c.mediaUrl,
status: "open",
closeReason: null,
suppressedUntil: null,
firstSeen: nowISO,
lastSeen: nowISO,
closedAt: null,
comments: [],
};
}
}
// ── Step 2: auto-resolve alerts whose condition is gone ───────────────────
for (const alert of Object.values(store.alerts)) {
if (alert.status !== "open") continue;
if (candidateKeys.has(alert.key)) continue;
// ── Step 2: auto-resolve alerts whose condition is gone ─────────────────
const openAlerts = getOpenAlerts.all();
for (const a of openAlerts) {
if (candidateKeys.has(a.key)) continue;
db.prepare(`
UPDATE alerts SET status = 'closed', closeReason = 'resolved',
closedAt = ?, suppressedUntil = NULL WHERE id = ?
`).run(nowISO, a.id);
insertComment.run({
alertId: a.id,
body: "Condition resolved — alert closed automatically.",
author: "system",
createdAt: nowISO,
});
}
})();
// Condition no longer exists — resolve it automatically, no cooldown
alert.status = "closed";
alert.closeReason = "resolved";
alert.closedAt = nowISO;
alert.suppressedUntil = null;
alert.comments.push({
id: store.nextCommentId++,
body: "Condition resolved — alert closed automatically.",
createdAt: nowISO,
author: "system",
});
}
save(store);
return Object.values(store.alerts).filter((a) => a.status === "open").length;
const { n } = db.prepare(
"SELECT COUNT(*) as n FROM alerts WHERE status = 'open'"
).get() as { n: number };
return { openCount: n, newAlerts };
}
export function getAllAlerts(): Alert[] {
const store = load();
return Object.values(store.alerts)
.sort((a, b) => {
if (a.status !== b.status) return a.status === "open" ? -1 : 1;
return new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime();
})
.map(toAlert);
const db = getDb();
const rows = db.prepare<[], AlertRow>(`
SELECT * FROM alerts
ORDER BY
CASE status WHEN 'open' THEN 0 ELSE 1 END ASC,
lastSeen DESC
`).all();
return rows.map((row) => rowToAlert(row, getCommentsForAlert(db, row.id)));
}
export function getAlertById(id: number): Alert | null {
const store = load();
const found = Object.values(store.alerts).find((a) => a.id === id);
return found ? toAlert(found) : null;
const db = getDb();
const row = db.prepare<[number], AlertRow>(
"SELECT * FROM alerts WHERE id = ?"
).get(id);
if (!row) return null;
return rowToAlert(row, getCommentsForAlert(db, id));
}
export function closeAlert(id: number): Alert | null {
const store = load();
const alert = Object.values(store.alerts).find((a) => a.id === id);
if (!alert) return null;
const db = getDb();
const row = db.prepare<[number], AlertRow>(
"SELECT * FROM alerts WHERE id = ?"
).get(id);
if (!row) return null;
const cooldownDays = COOLDOWN[alert.category] ?? DEFAULT_COOLDOWN;
const cooldownDays = COOLDOWN[row.category] ?? DEFAULT_COOLDOWN;
let suppressedUntil: string | null = null;
if (cooldownDays > 0) {
const d = new Date();
@@ -227,50 +424,66 @@ export function closeAlert(id: number): Alert | null {
suppressedUntil = d.toISOString();
}
const now = new Date().toISOString();
alert.status = "closed";
alert.closeReason = "manual";
alert.closedAt = now;
alert.suppressedUntil = suppressedUntil;
alert.comments.push({
id: store.nextCommentId++,
body: "Manually closed.",
createdAt: now,
author: "system",
});
save(store);
return toAlert(alert);
const nowISO = new Date().toISOString();
db.transaction(() => {
db.prepare(`
UPDATE alerts SET status = 'closed', closeReason = 'manual',
closedAt = ?, suppressedUntil = ? WHERE id = ?
`).run(nowISO, suppressedUntil, id);
db.prepare(`
INSERT INTO comments (alertId, body, author, createdAt)
VALUES (?, ?, 'system', ?)
`).run(id, "Manually closed.", nowISO);
})();
return getAlertById(id);
}
export function reopenAlert(id: number): Alert | null {
const store = load();
const alert = Object.values(store.alerts).find((a) => a.id === id);
if (!alert) return null;
alert.status = "open";
alert.closeReason = null;
alert.closedAt = null;
alert.suppressedUntil = null;
alert.comments.push({
id: store.nextCommentId++,
body: "Manually reopened.",
createdAt: new Date().toISOString(),
author: "system",
});
save(store);
return toAlert(alert);
const db = getDb();
const row = db.prepare<[number], AlertRow>(
"SELECT * FROM alerts WHERE id = ?"
).get(id);
if (!row) return null;
const nowISO = new Date().toISOString();
db.transaction(() => {
db.prepare(`
UPDATE alerts SET status = 'open', closeReason = NULL,
closedAt = NULL, suppressedUntil = NULL WHERE id = ?
`).run(id);
db.prepare(`
INSERT INTO comments (alertId, body, author, createdAt)
VALUES (?, 'Manually reopened.', 'system', ?)
`).run(id, nowISO);
})();
return getAlertById(id);
}
export function addComment(alertId: number, body: string, author: "user" | "system" = "user"): AlertComment | null {
const store = load();
const alert = Object.values(store.alerts).find((a) => a.id === alertId);
if (!alert) return null;
const comment: AlertComment = {
id: store.nextCommentId++,
export function addComment(
alertId: number,
body: string,
author: "user" | "system" = "user"
): AlertComment | null {
const db = getDb();
const exists = db.prepare<[number], { id: number }>(
"SELECT id FROM alerts WHERE id = ?"
).get(alertId);
if (!exists) return null;
const nowISO = new Date().toISOString();
const info = db.prepare(`
INSERT INTO comments (alertId, body, author, createdAt)
VALUES (?, ?, ?, ?)
`).run(alertId, body, author, nowISO);
return {
id: info.lastInsertRowid as number,
body,
createdAt: new Date().toISOString(),
author,
createdAt: nowISO,
};
alert.comments.push(comment);
save(store);
return comment;
}
+208
View File
@@ -0,0 +1,208 @@
/**
* Discord webhook notifications.
* Fired on newly opened or reopened alerts. Batches up to 10 embeds per
* message to stay within Discord's limits.
*/
import { AlertCandidate } from "@/lib/types";
import { getSettings } from "@/lib/settings";
// Discord embed colors per severity
const SEVERITY_COLOR: Record<string, number> = {
danger: 0xef4444, // red-500
warning: 0xeab308, // yellow-500
info: 0x3b82f6, // blue-500
};
const SEVERITY_LABEL: Record<string, string> = {
danger: "Critical",
warning: "Warning",
info: "Info",
};
type EmbedField = { name: string; value: string; inline: boolean };
interface DiscordEmbed {
title: string;
description?: string;
color: number;
url?: string;
fields: EmbedField[];
footer: { text: string };
timestamp: string;
}
// ── Description parsers (mirror AlertDetail.tsx regex patterns) ───────────────
function parseUnfulfilledComplete(desc: string) {
const m = desc.match(/^Approved (.+?) ago but (.+?)\. Requested by (.+?)\.?$/);
if (!m) return null;
return { age: m[1], detail: m[2], requesters: m[3] };
}
function parseUnfulfilledPartial(desc: string) {
// "Only X% of episodes downloaded (A/B). Approved N ago. Requested by Y."
const m = desc.match(/^Only .+?\. Approved (.+?) ago\. Requested by (.+?)\.?$/);
if (!m) return null;
const eps = desc.match(/\((\d+)\/(\d+)\)/);
return {
age: m[1],
requesters: m[2],
downloaded: eps ? parseInt(eps[1]) : null,
total: eps ? parseInt(eps[2]) : null,
};
}
function parsePending(desc: string) {
const m = desc.match(/^Awaiting approval for (.+?)\. Requested by (.+?)\.?$/);
if (!m) return null;
return { age: m[1], requesters: m[2] };
}
function parseWatchrate(desc: string) {
const pctM = desc.match(/~(\d+)%/);
const playsM = desc.match(/\((\d+) plays/);
const reqM = desc.match(/plays, (\d+) requests\)/);
if (!pctM || !playsM || !reqM) return null;
return { pct: pctM[1], plays: playsM[1], requests: reqM[1] };
}
// ── Embed builder ─────────────────────────────────────────────────────────────
function buildEmbed(alert: AlertCandidate): DiscordEmbed {
const color = SEVERITY_COLOR[alert.severity] ?? SEVERITY_COLOR.info;
const footer = { text: `OverSnitch · ${SEVERITY_LABEL[alert.severity] ?? alert.severity}` };
const timestamp = new Date().toISOString();
// Title links to Seerr media page if available, otherwise *arr
const url = alert.seerrMediaUrl ?? alert.mediaUrl ?? undefined;
const fields: EmbedField[] = [];
// ── unfulfilled ────────────────────────────────────────────────────────────
if (alert.category === "unfulfilled") {
const partial = parseUnfulfilledPartial(alert.description);
if (partial) {
fields.push({ name: "Requested by", value: partial.requesters, inline: true });
fields.push({ name: "Approved", value: `${partial.age} ago`, inline: true });
if (partial.downloaded !== null && partial.total !== null) {
const pct = Math.round((partial.downloaded / partial.total) * 100);
fields.push({
name: "Downloaded",
value: `${partial.downloaded} / ${partial.total} episodes (${pct}%)`,
inline: false,
});
}
return { title: alert.title, color, url, fields, footer, timestamp };
}
const complete = parseUnfulfilledComplete(alert.description);
if (complete) {
fields.push({ name: "Requested by", value: complete.requesters, inline: true });
fields.push({ name: "Approved", value: `${complete.age} ago`, inline: true });
// Capitalise "no file found in Radarr" → "No file found in Radarr"
const detail = complete.detail.charAt(0).toUpperCase() + complete.detail.slice(1);
fields.push({ name: "Status", value: detail, inline: false });
return { title: alert.title, color, url, fields, footer, timestamp };
}
}
// ── pending ────────────────────────────────────────────────────────────────
if (alert.category === "pending") {
const p = parsePending(alert.description);
if (p) {
fields.push({ name: "Requested by", value: p.requesters, inline: true });
fields.push({ name: "Waiting", value: p.age, inline: true });
return { title: alert.title, color, url, fields, footer, timestamp };
}
}
// ── ghost ──────────────────────────────────────────────────────────────────
if (alert.category === "ghost" && alert.userName) {
fields.push({ name: "User", value: alert.userName, inline: false });
// Trim the redundant name prefix from the description
// "Peri Wright has made 8 requests but hasn't watched…"
// → "Has made 8 requests but hasn't watched…"
const desc = alert.description.replace(
new RegExp(`^${alert.userName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s+`),
""
);
const sentence = desc.charAt(0).toUpperCase() + desc.slice(1);
return { title: alert.title, description: sentence, color, url, fields, footer, timestamp };
}
// ── watchrate ──────────────────────────────────────────────────────────────
if (alert.category === "watchrate") {
const w = parseWatchrate(alert.description);
if (w && alert.userName) {
fields.push({ name: "User", value: alert.userName, inline: true });
fields.push({ name: "Watch rate", value: `~${w.pct}%`, inline: true });
fields.push({ name: "Plays", value: w.plays, inline: true });
fields.push({ name: "Requests", value: w.requests, inline: true });
return { title: alert.title, color, url, fields, footer, timestamp };
}
}
// ── fallback: plain description ────────────────────────────────────────────
return {
title: alert.title,
description: alert.description,
color,
url,
fields,
footer,
timestamp,
};
}
// ── Transport ─────────────────────────────────────────────────────────────────
async function postToWebhook(webhookUrl: string, embeds: DiscordEmbed[]): Promise<void> {
const res = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "OverSnitch", embeds }),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Discord webhook error ${res.status}: ${text}`);
}
}
/**
* Sends one Discord message per batch of up to 10 alerts.
* Silently no-ops if no webhook URL is configured.
* Errors are logged but never thrown.
*/
export async function sendDiscordNotifications(alerts: AlertCandidate[]): Promise<void> {
if (alerts.length === 0) return;
const { discord } = getSettings();
if (!discord.webhookUrl) return;
const embeds = alerts.map(buildEmbed);
// Discord allows up to 10 embeds per message
for (let i = 0; i < embeds.length; i += 10) {
try {
await postToWebhook(discord.webhookUrl, embeds.slice(i, i + 10));
} catch (err) {
console.error("[discord] Failed to send notification:", err);
}
}
}
/**
* Sends a single test embed. Used by the settings test endpoint.
*/
export async function sendDiscordTestNotification(webhookUrl: string): Promise<void> {
await postToWebhook(webhookUrl, [
{
title: "OverSnitch — Test Notification",
description: "Your Discord webhook is configured correctly. You'll receive alerts here when new issues are detected.",
color: SEVERITY_COLOR.info,
fields: [],
footer: { text: "OverSnitch" },
timestamp: new Date().toISOString(),
},
]);
}
+7 -4
View File
@@ -1,15 +1,17 @@
import { OverseerrUser, OverseerrRequest } from "@/lib/types";
import { getSettings } from "@/lib/settings";
const TAKE = 100;
export async function fetchAllUsers(): Promise<OverseerrUser[]> {
const { seerr } = getSettings();
const all: OverseerrUser[] = [];
let skip = 0;
while (true) {
const res = await fetch(
`${process.env.SEERR_URL}/api/v1/user?take=${TAKE}&skip=${skip}&sort=created`,
{ headers: { "X-Api-Key": process.env.SEERR_API! } }
`${seerr.url}/api/v1/user?take=${TAKE}&skip=${skip}&sort=created`,
{ headers: { "X-Api-Key": seerr.apiKey } }
);
if (!res.ok) {
@@ -27,13 +29,14 @@ export async function fetchAllUsers(): Promise<OverseerrUser[]> {
}
export async function fetchUserRequests(userId: number): Promise<OverseerrRequest[]> {
const { seerr } = getSettings();
const all: OverseerrRequest[] = [];
let skip = 0;
while (true) {
const res = await fetch(
`${process.env.SEERR_URL}/api/v1/request?requestedBy=${userId}&take=${TAKE}&skip=${skip}`,
{ headers: { "X-Api-Key": process.env.SEERR_API! } }
`${seerr.url}/api/v1/request?requestedBy=${userId}&take=${TAKE}&skip=${skip}`,
{ headers: { "X-Api-Key": seerr.apiKey } }
);
if (!res.ok) {
+4 -2
View File
@@ -1,8 +1,10 @@
import { RadarrMovie, MediaEntry } from "@/lib/types";
import { getSettings } from "@/lib/settings";
export async function buildRadarrMap(): Promise<Map<number, MediaEntry>> {
const res = await fetch(`${process.env.RADARR_URL}/api/v3/movie`, {
headers: { "X-Api-Key": process.env.RADARR_API! },
const { radarr } = getSettings();
const res = await fetch(`${radarr.url}/api/v3/movie`, {
headers: { "X-Api-Key": radarr.apiKey },
});
if (!res.ok) {
+89
View File
@@ -0,0 +1,89 @@
/**
* Persistent settings store.
*
* Settings are read from data/settings.json when present, with process.env
* values used as fallbacks. This means existing .env.local setups keep working
* with no changes; the UI just provides an alternative way to configure them.
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
import { join } from "path";
const DATA_DIR = join(process.cwd(), "data");
const SETTINGS_PATH = join(DATA_DIR, "settings.json");
export interface ServiceConfig {
url: string;
apiKey: string;
}
export interface DiscordConfig {
webhookUrl: string;
}
export interface AppSettings {
radarr: ServiceConfig;
sonarr: ServiceConfig;
seerr: ServiceConfig;
tautulli: ServiceConfig;
discord: DiscordConfig;
}
interface StoredSettings {
radarr?: Partial<ServiceConfig>;
sonarr?: Partial<ServiceConfig>;
seerr?: Partial<ServiceConfig>;
tautulli?: Partial<ServiceConfig>;
discord?: Partial<DiscordConfig>;
}
function readFile(): StoredSettings {
try {
return JSON.parse(readFileSync(SETTINGS_PATH, "utf-8")) as StoredSettings;
} catch {
return {};
}
}
/** Returns the merged settings (file values override env vars). */
export function getSettings(): AppSettings {
const f = readFile();
return {
radarr: {
url: f.radarr?.url ?? process.env.RADARR_URL ?? "",
apiKey: f.radarr?.apiKey ?? process.env.RADARR_API ?? "",
},
sonarr: {
url: f.sonarr?.url ?? process.env.SONARR_URL ?? "",
apiKey: f.sonarr?.apiKey ?? process.env.SONARR_API ?? "",
},
seerr: {
url: f.seerr?.url ?? process.env.SEERR_URL ?? "",
apiKey: f.seerr?.apiKey ?? process.env.SEERR_API ?? "",
},
tautulli: {
url: f.tautulli?.url ?? process.env.TAUTULLI_URL ?? "",
apiKey: f.tautulli?.apiKey ?? process.env.TAUTULLI_API ?? "",
},
discord: {
webhookUrl: f.discord?.webhookUrl ?? process.env.DISCORD_WEBHOOK ?? "",
},
};
}
/** Saves the provided settings to disk and returns the merged result. */
export function saveSettings(settings: AppSettings): AppSettings {
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
// Strip trailing slashes from URLs for consistency
const clean: StoredSettings = {
radarr: { url: settings.radarr.url.replace(/\/+$/, ""), apiKey: settings.radarr.apiKey },
sonarr: { url: settings.sonarr.url.replace(/\/+$/, ""), apiKey: settings.sonarr.apiKey },
seerr: { url: settings.seerr.url.replace(/\/+$/, ""), apiKey: settings.seerr.apiKey },
tautulli: { url: settings.tautulli.url.replace(/\/+$/, ""), apiKey: settings.tautulli.apiKey },
discord: { webhookUrl: settings.discord.webhookUrl.trim() },
};
writeFileSync(SETTINGS_PATH, JSON.stringify(clean, null, 2), "utf-8");
return getSettings();
}
+4 -2
View File
@@ -1,8 +1,10 @@
import { SonarrSeries, MediaEntry } from "@/lib/types";
import { getSettings } from "@/lib/settings";
export async function buildSonarrMap(): Promise<Map<number, MediaEntry>> {
const res = await fetch(`${process.env.SONARR_URL}/api/v3/series`, {
headers: { "X-Api-Key": process.env.SONARR_API! },
const { sonarr } = getSettings();
const res = await fetch(`${sonarr.url}/api/v3/series`, {
headers: { "X-Api-Key": sonarr.apiKey },
});
if (!res.ok) {
+4 -2
View File
@@ -1,4 +1,5 @@
import { TautulliUser } from "@/lib/types";
import { getSettings } from "@/lib/settings";
interface TautulliRow {
friendly_name: string;
@@ -22,8 +23,9 @@ interface TautulliResponse {
* Returns null if TAUTULLI_URL/TAUTULLI_API are not set.
*/
export async function buildTautulliMap(): Promise<Map<string, TautulliUser> | null> {
const url = process.env.TAUTULLI_URL;
const key = process.env.TAUTULLI_API;
const { tautulli } = getSettings();
const url = tautulli.url;
const key = tautulli.apiKey;
if (!url || !key) return null;
+4 -1
View File
@@ -115,7 +115,10 @@ export interface AlertCandidate {
mediaId?: number;
mediaType?: "movie" | "tv";
mediaTitle?: string;
mediaUrl?: string; // direct link to the item in Radarr/Sonarr
mediaUrl?: string; // direct link to the item in Radarr/Sonarr
seerrMediaUrl?: string; // link to the item's page in Overseerr/Jellyseerr
// Ordered list of Overseerr user IDs who triggered this alert (content alerts)
requesterIds?: number[];
}
export interface AlertComment {