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:
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user