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:
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef, useEffect, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, AlertSeverity, AlertComment } from "@/lib/types";
|
||||
|
||||
const severityAccent: Record<AlertSeverity, string> = {
|
||||
@@ -45,7 +46,7 @@ function shortDate(iso: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Meta chip ─────────────────────────────────────────────────────────────────
|
||||
// ── Chip ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Chip({ label, dim }: { label: string; dim?: boolean }) {
|
||||
return (
|
||||
@@ -59,6 +60,170 @@ function Chip({ label, dim }: { label: string; dim?: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── External link icon ────────────────────────────────────────────────────────
|
||||
|
||||
function ExternalIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3 w-3 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Episode progress bar ──────────────────────────────────────────────────────
|
||||
|
||||
function EpisodeBar({ downloaded, total }: { downloaded: number; total: number }) {
|
||||
const pct = Math.round((downloaded / total) * 100);
|
||||
return (
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs font-medium text-slate-400">Episodes downloaded</span>
|
||||
<span className="text-xs tabular-nums text-slate-500">{downloaded} / {total} ({pct}%)</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-slate-700 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-yellow-500/80 transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Structured description for content alerts ────────────────────────────────
|
||||
|
||||
interface DescRow { label: string; value?: string; chips?: ReactNode }
|
||||
|
||||
function DescriptionTable({ rows }: { rows: DescRow[] }) {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{rows.map(({ label, value, chips }) => (
|
||||
<div key={label} className="flex gap-3 text-sm">
|
||||
<span className="w-28 shrink-0 text-slate-600">{label}</span>
|
||||
{chips ?? <span className="text-slate-300">{value}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ContentDescriptionProps {
|
||||
description: string;
|
||||
category: string;
|
||||
requesterIds?: number[];
|
||||
seerrUrl?: string;
|
||||
}
|
||||
|
||||
function RequesterChips({
|
||||
names,
|
||||
requesterIds,
|
||||
seerrUrl,
|
||||
}: {
|
||||
names: string[];
|
||||
requesterIds?: number[];
|
||||
seerrUrl?: string;
|
||||
}) {
|
||||
return (
|
||||
<span>
|
||||
{names.map((name, i) => {
|
||||
const uid = requesterIds?.[i];
|
||||
const href = uid && seerrUrl ? `${seerrUrl}/users/${uid}` : null;
|
||||
return (
|
||||
<span key={i}>
|
||||
{i > 0 && <span className="text-slate-600">, </span>}
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-slate-300">{name}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentDescription({ description, category, requesterIds, seerrUrl }: ContentDescriptionProps) {
|
||||
if (category === "unfulfilled") {
|
||||
// Partial TV: "Only X% of episodes downloaded (A/B). Approved N ago. Requested by Y."
|
||||
const partial = description.match(/^Only .+?\. Approved (.+?) ago\. Requested by (.+?)\.?$/);
|
||||
if (partial) {
|
||||
const [, age, reqStr] = partial;
|
||||
const names = reqStr.split(", ").filter(Boolean);
|
||||
return (
|
||||
<DescriptionTable rows={[
|
||||
{ label: "Requested by", chips: <RequesterChips names={names} requesterIds={requesterIds} seerrUrl={seerrUrl} /> },
|
||||
{ label: "Approved", value: `${age} ago` },
|
||||
]} />
|
||||
);
|
||||
}
|
||||
// Complete miss: "Approved N ago but no file found in Radarr/Sonarr. Requested by Y."
|
||||
const complete = description.match(/^Approved (.+?) ago but (.+?)\. Requested by (.+?)\.?$/);
|
||||
if (complete) {
|
||||
const [, age, detail, reqStr] = complete;
|
||||
const names = reqStr.split(", ").filter(Boolean);
|
||||
return (
|
||||
<DescriptionTable rows={[
|
||||
{ label: "Requested by", chips: <RequesterChips names={names} requesterIds={requesterIds} seerrUrl={seerrUrl} /> },
|
||||
{ label: "Approved", value: `${age} ago` },
|
||||
{ label: "Details", value: detail.charAt(0).toUpperCase() + detail.slice(1) },
|
||||
]} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (category === "pending") {
|
||||
// "Awaiting approval for N days. Requested by Y."
|
||||
const m = description.match(/^Awaiting approval for (.+?)\. Requested by (.+?)\.?$/);
|
||||
if (m) {
|
||||
const [, age, reqStr] = m;
|
||||
const names = reqStr.split(", ").filter(Boolean);
|
||||
return (
|
||||
<DescriptionTable rows={[
|
||||
{ label: "Requested by", chips: <RequesterChips names={names} requesterIds={requesterIds} seerrUrl={seerrUrl} /> },
|
||||
{ label: "Waiting", value: age },
|
||||
]} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: plain prose (ghost, watchrate handled separately, tautulli-no-matches)
|
||||
return <p className="text-sm text-slate-400 leading-relaxed">{description}</p>;
|
||||
}
|
||||
|
||||
// ── Watch rate stat block ─────────────────────────────────────────────────────
|
||||
|
||||
function WatchrateBlock({ plays, requests, pct }: { plays: number; requests: number; pct: number }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-700/60 bg-slate-900/50 px-4 py-3">
|
||||
<div className="flex items-end gap-4 flex-wrap">
|
||||
<div>
|
||||
<div className="text-xl font-bold tabular-nums text-white">{plays.toLocaleString()}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">plays</div>
|
||||
</div>
|
||||
<div className="text-slate-700 pb-4 text-base">/</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold tabular-nums text-white">{requests}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">requests</div>
|
||||
</div>
|
||||
<div className="text-slate-700 pb-4 text-base">=</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold tabular-nums text-blue-400">{pct}%</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">watch rate</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-600">Alert threshold: <20%</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Comment row ───────────────────────────────────────────────────────────────
|
||||
|
||||
function CommentRow({ comment }: { comment: AlertComment }) {
|
||||
@@ -70,7 +235,7 @@ function CommentRow({ comment }: { comment: AlertComment }) {
|
||||
<div className="h-px flex-1 bg-slate-800" />
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-600 shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-3 w-3">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.43l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
<span className="italic">{comment.body}</span>
|
||||
@@ -102,9 +267,33 @@ function CommentRow({ comment }: { comment: AlertComment }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers: parse structured data from description prose ─────────────────────
|
||||
|
||||
function parseEpisodeCounts(desc: string): { downloaded: number; total: number } | null {
|
||||
const m = desc.match(/\((\d+)\/(\d+)\)/);
|
||||
if (!m) return null;
|
||||
return { downloaded: parseInt(m[1]), total: parseInt(m[2]) };
|
||||
}
|
||||
|
||||
function parseWatchrateStats(desc: string): { plays: number; requests: number; pct: number } | null {
|
||||
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: parseInt(pctM[1]), plays: parseInt(playsM[1]), requests: parseInt(reqM[1]) };
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
|
||||
interface Props {
|
||||
initialAlert: Alert;
|
||||
radarrUrl?: string;
|
||||
sonarrUrl?: string;
|
||||
seerrUrl?: string;
|
||||
}
|
||||
|
||||
export default function AlertDetail({ initialAlert, radarrUrl, sonarrUrl, seerrUrl }: Props) {
|
||||
const router = useRouter();
|
||||
const [alert, setAlert] = useState<Alert>(initialAlert);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [commentText, setCommentText] = useState("");
|
||||
@@ -130,8 +319,6 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
|
||||
const updated: Alert = await res.json();
|
||||
setAlert(updated);
|
||||
|
||||
// Keep the cached dashboard count in sync so the badge and summary
|
||||
// card reflect the change immediately when navigating back.
|
||||
try {
|
||||
const raw = localStorage.getItem("oversnitch_stats");
|
||||
if (raw) {
|
||||
@@ -172,21 +359,47 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
|
||||
|
||||
const isOpen = alert.status === "open";
|
||||
const isResolved = alert.closeReason === "resolved";
|
||||
const statusTime = isOpen ? alert.firstSeen : (alert.closedAt ?? alert.firstSeen);
|
||||
|
||||
// ── Derived data ────────────────────────────────────────────────────────────
|
||||
|
||||
// Search fallback when media exists in description but not yet in *arr
|
||||
const searchUrl =
|
||||
!alert.mediaUrl && alert.mediaTitle
|
||||
? alert.mediaType === "movie" && radarrUrl
|
||||
? `${radarrUrl}/add/new?term=${encodeURIComponent(alert.mediaTitle)}`
|
||||
: alert.mediaType === "tv" && sonarrUrl
|
||||
? `${sonarrUrl}/add/new?term=${encodeURIComponent(alert.mediaTitle)}`
|
||||
: null
|
||||
: null;
|
||||
|
||||
// User dashboard link for behavior alerts
|
||||
const userLink =
|
||||
(alert.category === "ghost" || alert.category === "watchrate") && alert.userId
|
||||
? `/?tab=leaderboard`
|
||||
: null;
|
||||
|
||||
// Category-specific parsed data
|
||||
const episodeCounts =
|
||||
alert.category === "unfulfilled" && alert.mediaType === "tv"
|
||||
? parseEpisodeCounts(alert.description)
|
||||
: null;
|
||||
|
||||
const watchrateStats =
|
||||
alert.category === "watchrate" ? parseWatchrateStats(alert.description) : null;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-8 space-y-6">
|
||||
|
||||
{/* Back */}
|
||||
<Link
|
||||
href="/?tab=alerts"
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
All Alerts
|
||||
</Link>
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-800 bg-red-950/30 px-4 py-3 text-red-300 text-sm">
|
||||
@@ -196,10 +409,11 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
|
||||
|
||||
{/* ── Alert overview ──────────────────────────────────────────────── */}
|
||||
<div className={`rounded-xl bg-slate-800/40 border border-slate-700/60 border-l-4 overflow-hidden ${severityAccent[alert.severity]}`}>
|
||||
<div className="px-6 py-6 space-y-5">
|
||||
<div className="px-6 py-6 space-y-4">
|
||||
|
||||
{/* Top row: status + action */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
{/* Top row: severity + status | action buttons */}
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
{/* Left: severity + status + timestamps */}
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<span className={`text-xs font-bold uppercase tracking-widest ${severityText[alert.severity]}`}>
|
||||
{severityLabel[alert.severity]}
|
||||
@@ -212,57 +426,123 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
|
||||
{isOpen ? "Open" : isResolved ? "Auto-resolved" : "Closed"}
|
||||
</span>
|
||||
<span className="text-slate-700">·</span>
|
||||
<span className="text-xs text-slate-600">{timeAgo(statusTime)}</span>
|
||||
<span className="text-xs text-slate-500">Opened {timeAgo(alert.firstSeen)}</span>
|
||||
<span className="text-slate-700">·</span>
|
||||
<span className="text-xs text-slate-500">Checked {timeAgo(alert.lastSeen)}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={toggleStatus}
|
||||
disabled={actionLoading}
|
||||
className={`rounded-lg px-4 py-1.5 text-xs font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||
isOpen
|
||||
? "bg-slate-700 hover:bg-slate-600 text-white"
|
||||
: "bg-green-900/50 hover:bg-green-800/50 text-green-300 border border-green-800"
|
||||
}`}
|
||||
>
|
||||
{actionLoading ? "…" : isOpen ? "Close" : "Reopen"}
|
||||
</button>
|
||||
</div>
|
||||
{/* Right: action buttons */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Search fallback (not yet in *arr) */}
|
||||
{searchUrl && (
|
||||
<a
|
||||
href={searchUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 px-3 py-1.5 text-xs font-semibold text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{alert.mediaType === "movie" ? "Search in Radarr" : "Search in Sonarr"}
|
||||
<ExternalIcon />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Title + description */}
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold text-white leading-snug">{alert.title}</h1>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">{alert.description}</p>
|
||||
</div>
|
||||
{/* Primary: view in Radarr/Sonarr */}
|
||||
{alert.mediaUrl && (
|
||||
<a
|
||||
href={alert.mediaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-600 bg-slate-700/60 hover:bg-slate-700 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
{alert.mediaType === "movie" ? "View in Radarr" : "View in Sonarr"}
|
||||
<ExternalIcon />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Metadata row */}
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-slate-700/30">
|
||||
{/* View in Seerr */}
|
||||
{alert.seerrMediaUrl && (
|
||||
<a
|
||||
href={alert.seerrMediaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-600 bg-slate-700/60 hover:bg-slate-700 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
View in Seerr
|
||||
<ExternalIcon />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
<Chip label={`Opened ${fullDate(alert.firstSeen)}`} dim />
|
||||
{!isOpen && alert.closedAt && (
|
||||
<Chip label={`${isResolved ? "Resolved" : "Closed"} ${fullDate(alert.closedAt)}`} dim />
|
||||
)}
|
||||
|
||||
{/* User (for user-behavior alerts) */}
|
||||
{alert.userName && !alert.mediaTitle && (
|
||||
<Chip label={alert.userName} />
|
||||
)}
|
||||
|
||||
{/* View in Radarr/Sonarr */}
|
||||
{alert.mediaUrl && (
|
||||
<a
|
||||
href={alert.mediaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-slate-600 bg-slate-700/60 hover:bg-slate-700 px-2.5 py-1 text-xs font-medium text-slate-300 hover:text-white transition-colors"
|
||||
{/* Close / Reopen */}
|
||||
<button
|
||||
onClick={toggleStatus}
|
||||
disabled={actionLoading}
|
||||
className={`rounded-lg px-4 py-1.5 text-xs font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||
isOpen
|
||||
? "bg-slate-700 hover:bg-slate-600 text-white"
|
||||
: "bg-green-900/50 hover:bg-green-800/50 text-green-300 border border-green-800"
|
||||
}`}
|
||||
>
|
||||
{alert.mediaType === "movie" ? "View in Radarr" : "View in Sonarr"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3 w-3">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
</a>
|
||||
{actionLoading ? "…" : isOpen ? "Close" : "Reopen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-2xl font-bold text-white leading-snug">{alert.title}</h1>
|
||||
|
||||
{/* Category body */}
|
||||
<div className="space-y-3">
|
||||
|
||||
{/* Description — structured for content alerts, prose for others */}
|
||||
{alert.category !== "watchrate" && (
|
||||
<ContentDescription
|
||||
description={alert.description}
|
||||
category={alert.category}
|
||||
requesterIds={alert.requesterIds}
|
||||
seerrUrl={seerrUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Watchrate structured stat block */}
|
||||
{watchrateStats && (
|
||||
<WatchrateBlock
|
||||
plays={watchrateStats.plays}
|
||||
requests={watchrateStats.requests}
|
||||
pct={watchrateStats.pct}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Episode progress bar for partial TV downloads */}
|
||||
{episodeCounts && (
|
||||
<EpisodeBar
|
||||
downloaded={episodeCounts.downloaded}
|
||||
total={episodeCounts.total}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* User chip linking to dashboard for behavior alerts */}
|
||||
{userLink && alert.userName && (
|
||||
<div className="flex items-center gap-2 pt-0.5">
|
||||
<span className="text-xs text-slate-600">User</span>
|
||||
<Link
|
||||
href={userLink}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-slate-700 bg-slate-800 hover:bg-slate-700 px-2.5 py-1 text-xs font-medium text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
{alert.userName}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3 w-3">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata footer — closed/resolved date only */}
|
||||
{!isOpen && alert.closedAt && (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-slate-700/30">
|
||||
<Chip label={`${isResolved ? "Resolved" : "Closed"} ${fullDate(alert.closedAt)}`} dim />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -296,7 +576,8 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
|
||||
rows={3}
|
||||
className="w-full rounded-lg bg-slate-800/40 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-700 px-4 py-3 focus:outline-none resize-none transition-colors"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-700">⌘↵ to submit</span>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={commentLoading || !commentText.trim()}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getAlertById } from "@/lib/db";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { notFound } from "next/navigation";
|
||||
import AlertDetail from "./AlertDetail";
|
||||
|
||||
@@ -10,5 +11,13 @@ export default async function AlertPage({
|
||||
const { id } = await params;
|
||||
const alert = getAlertById(Number(id));
|
||||
if (!alert) notFound();
|
||||
return <AlertDetail initialAlert={alert} />;
|
||||
const { radarr, sonarr, seerr } = getSettings();
|
||||
return (
|
||||
<AlertDetail
|
||||
initialAlert={alert}
|
||||
radarrUrl={radarr.url || undefined}
|
||||
sonarrUrl={sonarr.url || undefined}
|
||||
seerrUrl={seerr.url || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getSettings, saveSettings, AppSettings } from "@/lib/settings";
|
||||
|
||||
export async function GET() {
|
||||
return Response.json(getSettings());
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as AppSettings;
|
||||
const saved = saveSettings(body);
|
||||
return Response.json(saved);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return Response.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* POST /api/settings/test
|
||||
* Tests connectivity to a service using the provided URL + API key.
|
||||
* Does NOT save anything — purely a connectivity check.
|
||||
*/
|
||||
|
||||
import { sendDiscordTestNotification } from "@/lib/discord";
|
||||
|
||||
interface TestBody {
|
||||
service: "radarr" | "sonarr" | "seerr" | "tautulli" | "discord";
|
||||
url: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const TIMEOUT_MS = 10_000;
|
||||
|
||||
async function testRadarr(url: string, apiKey: string): Promise<TestResult> {
|
||||
const res = await fetch(`${url}/api/v3/system/status`, {
|
||||
headers: { "X-Api-Key": apiKey },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return { ok: false, message: `HTTP ${res.status} ${res.statusText}` };
|
||||
const data = await res.json() as { version?: string };
|
||||
return { ok: true, message: `Connected${data.version ? ` (v${data.version})` : ""}` };
|
||||
}
|
||||
|
||||
async function testSonarr(url: string, apiKey: string): Promise<TestResult> {
|
||||
const res = await fetch(`${url}/api/v3/system/status`, {
|
||||
headers: { "X-Api-Key": apiKey },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return { ok: false, message: `HTTP ${res.status} ${res.statusText}` };
|
||||
const data = await res.json() as { version?: string };
|
||||
return { ok: true, message: `Connected${data.version ? ` (v${data.version})` : ""}` };
|
||||
}
|
||||
|
||||
async function testSeerr(url: string, apiKey: string): Promise<TestResult> {
|
||||
const res = await fetch(`${url}/api/v1/status`, {
|
||||
headers: { "X-Api-Key": apiKey },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return { ok: false, message: `HTTP ${res.status} ${res.statusText}` };
|
||||
const data = await res.json() as { version?: string };
|
||||
return { ok: true, message: `Connected${data.version ? ` (v${data.version})` : ""}` };
|
||||
}
|
||||
|
||||
async function testTautulli(url: string, apiKey: string): Promise<TestResult> {
|
||||
const res = await fetch(
|
||||
`${url}/api/v2?apikey=${encodeURIComponent(apiKey)}&cmd=get_server_info`,
|
||||
{ signal: AbortSignal.timeout(TIMEOUT_MS) }
|
||||
);
|
||||
if (!res.ok) return { ok: false, message: `HTTP ${res.status} ${res.statusText}` };
|
||||
const data = await res.json() as { response?: { result?: string; data?: { pms_version?: string } } };
|
||||
if (data.response?.result !== "success") {
|
||||
return { ok: false, message: "Tautulli returned a non-success result" };
|
||||
}
|
||||
const ver = data.response.data?.pms_version;
|
||||
return { ok: true, message: `Connected${ver ? ` (Plex ${ver})` : ""}` };
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { service, url, apiKey } = await req.json() as TestBody;
|
||||
|
||||
// Discord only needs the webhook URL (passed as `url`)
|
||||
if (service === "discord") {
|
||||
if (!url) {
|
||||
return Response.json({ ok: false, message: "Webhook URL is required" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
return Response.json({ ok: false, message: "Invalid URL" }, { status: 400 });
|
||||
}
|
||||
await sendDiscordTestNotification(url.trim());
|
||||
return Response.json({ ok: true, message: "Test message sent — check your channel" });
|
||||
}
|
||||
|
||||
if (!url || !apiKey) {
|
||||
return Response.json({ ok: false, message: "URL and API key are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Basic URL validation
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
return Response.json({ ok: false, message: "URL must use http or https" }, { status: 400 });
|
||||
}
|
||||
} catch {
|
||||
return Response.json({ ok: false, message: "Invalid URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
const trimmed = url.replace(/\/+$/, "");
|
||||
|
||||
let result: TestResult;
|
||||
switch (service) {
|
||||
case "radarr": result = await testRadarr(trimmed, apiKey); break;
|
||||
case "sonarr": result = await testSonarr(trimmed, apiKey); break;
|
||||
case "seerr": result = await testSeerr(trimmed, apiKey); break;
|
||||
case "tautulli": result = await testTautulli(trimmed, apiKey); break;
|
||||
default:
|
||||
return Response.json({ ok: false, message: "Unknown service" }, { status: 400 });
|
||||
}
|
||||
|
||||
return Response.json(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Provide friendlier messages for common failures
|
||||
const friendly = message.includes("fetch failed") || message.includes("ECONNREFUSED")
|
||||
? "Connection refused — check the URL and that the service is running"
|
||||
: message.includes("TimeoutError") || message.includes("abort")
|
||||
? "Connection timed out"
|
||||
: message;
|
||||
return Response.json({ ok: false, message: friendly });
|
||||
}
|
||||
}
|
||||
+26
-22
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef, Suspense } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { DashboardStats } from "@/lib/types";
|
||||
import SummaryCards from "@/components/SummaryCards";
|
||||
import LeaderboardTable from "@/components/LeaderboardTable";
|
||||
import AlertsPanel from "@/components/AlertsPanel";
|
||||
import RefreshButton from "@/components/RefreshButton";
|
||||
import SettingsModal from "@/components/SettingsModal";
|
||||
|
||||
type Tab = "leaderboard" | "alerts";
|
||||
const LS_KEY = "oversnitch_stats";
|
||||
@@ -21,15 +21,13 @@ function timeAgo(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
function DashboardContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const tab = (searchParams.get("tab") ?? "leaderboard") as Tab;
|
||||
|
||||
export default function Page() {
|
||||
const [tab, setTab] = useState<Tab>("leaderboard");
|
||||
const [data, setData] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const didInit = useRef(false);
|
||||
|
||||
const load = useCallback(async (force = false) => {
|
||||
@@ -67,18 +65,13 @@ function DashboardContent() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
function setTab(t: Tab) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("tab", t);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
}
|
||||
|
||||
const hasTautulli = data?.summary.totalWatchHours !== null;
|
||||
const openAlertCount = data?.summary.openAlertCount ?? 0;
|
||||
const generatedAt = data?.generatedAt ?? null;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-4 py-8 space-y-6">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
@@ -89,7 +82,20 @@ function DashboardContent() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
||||
<RefreshButton onRefresh={() => load(true)} loading={refreshing || loading} />
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="rounded-lg border border-slate-700/60 bg-slate-800/40 hover:bg-slate-800 p-2 text-slate-500 hover:text-slate-300 transition-colors"
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.43l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<RefreshButton onRefresh={() => load(true)} loading={refreshing || loading} />
|
||||
</div>
|
||||
{generatedAt && (
|
||||
<span className="text-xs text-slate-600">
|
||||
{refreshing
|
||||
@@ -175,14 +181,12 @@ function DashboardContent() {
|
||||
{tab === "alerts" && <AlertsPanel />}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onSaved={() => load(true)}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense>
|
||||
<DashboardContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { AppSettings, ServiceConfig, DiscordConfig } from "@/lib/settings";
|
||||
|
||||
// ── Icons ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function EyeIcon({ open }: { open: boolean }) {
|
||||
return open ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Per-service section ───────────────────────────────────────────────────────
|
||||
|
||||
type ServiceKey = "radarr" | "sonarr" | "seerr" | "tautulli";
|
||||
|
||||
interface SectionProps {
|
||||
id: ServiceKey;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
optional?: boolean;
|
||||
config: ServiceConfig;
|
||||
onChange: (patch: Partial<ServiceConfig>) => void;
|
||||
}
|
||||
|
||||
function ServiceSection({ id, label, placeholder, optional, config, onChange }: SectionProps) {
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ service: id, url: config.url.trim(), apiKey: config.apiKey.trim() }),
|
||||
});
|
||||
const data = await res.json() as { ok: boolean; message: string };
|
||||
setTestResult(data);
|
||||
} catch {
|
||||
setTestResult({ ok: false, message: "Network error" });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear test result when inputs change
|
||||
function handleUrlChange(v: string) {
|
||||
setTestResult(null);
|
||||
onChange({ url: v });
|
||||
}
|
||||
function handleKeyChange(v: string) {
|
||||
setTestResult(null);
|
||||
onChange({ apiKey: v });
|
||||
}
|
||||
|
||||
const canTest = config.url.trim().length > 0 && config.apiKey.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-200">{label}</h3>
|
||||
{optional && (
|
||||
<span className="text-xs text-slate-600 font-normal">optional</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* URL */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.url}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">API Key</label>
|
||||
<div className="flex flex-1 gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showKey ? "text" : "password"}
|
||||
value={config.apiKey}
|
||||
onChange={(e) => handleKeyChange(e.target.value)}
|
||||
placeholder="••••••••••••••••••••••••••••••••"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-700 px-3 py-2 pr-9 focus:outline-none transition-colors font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey((s) => !s)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-600 hover:text-slate-400 transition-colors"
|
||||
tabIndex={-1}
|
||||
aria-label={showKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
<EyeIcon open={showKey} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={!canTest || testing}
|
||||
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
|
||||
>
|
||||
{testing ? "Testing…" : "Test"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
|
||||
{testResult.ok ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Discord section (webhook URL only, no API key) ────────────────────────────
|
||||
|
||||
interface DiscordSectionProps {
|
||||
config: DiscordConfig;
|
||||
onChange: (patch: Partial<DiscordConfig>) => void;
|
||||
}
|
||||
|
||||
function DiscordSection({ config, onChange }: DiscordSectionProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ service: "discord", url: config.webhookUrl.trim(), apiKey: "" }),
|
||||
});
|
||||
const data = await res.json() as { ok: boolean; message: string };
|
||||
setTestResult(data);
|
||||
} catch {
|
||||
setTestResult({ ok: false, message: "Network error" });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(v: string) {
|
||||
setTestResult(null);
|
||||
onChange({ webhookUrl: v });
|
||||
}
|
||||
|
||||
const canTest = config.webhookUrl.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-200">Discord</h3>
|
||||
<span className="text-xs text-slate-600 font-normal">optional</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">Webhook</label>
|
||||
<div className="flex flex-1 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={config.webhookUrl}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="https://discord.com/api/webhooks/…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={!canTest || testing}
|
||||
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
|
||||
>
|
||||
{testing ? "Sending…" : "Test"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
|
||||
{testResult.ok ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Modal ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
const EMPTY_CONFIG: ServiceConfig = { url: "", apiKey: "" };
|
||||
const EMPTY_SETTINGS: AppSettings = {
|
||||
radarr: EMPTY_CONFIG,
|
||||
sonarr: EMPTY_CONFIG,
|
||||
seerr: EMPTY_CONFIG,
|
||||
tautulli: EMPTY_CONFIG,
|
||||
discord: { webhookUrl: "" },
|
||||
};
|
||||
|
||||
export default function SettingsModal({ open, onClose, onSaved }: Props) {
|
||||
const [settings, setSettings] = useState<AppSettings>(EMPTY_SETTINGS);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveResult, setSaveResult] = useState<"saved" | "error" | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load current settings when modal opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSaveResult(null);
|
||||
setLoading(true);
|
||||
fetch("/api/settings")
|
||||
.then((r) => r.json())
|
||||
.then((data: AppSettings) => setSettings(data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [open]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Close on backdrop click
|
||||
function handleBackdrop(e: React.MouseEvent) {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function patch(service: "radarr" | "sonarr" | "seerr" | "tautulli", partial: Partial<ServiceConfig>) {
|
||||
setSaveResult(null);
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
[service]: { ...prev[service], ...partial },
|
||||
}));
|
||||
}
|
||||
|
||||
function patchDiscord(partial: Partial<DiscordConfig>) {
|
||||
setSaveResult(null);
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
discord: { ...prev.discord, ...partial },
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setSaveResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setSaveResult("saved");
|
||||
onSaved?.();
|
||||
} catch {
|
||||
setSaveResult("error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleBackdrop}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="relative w-full max-w-lg rounded-2xl bg-slate-900 border border-slate-700/60 shadow-2xl flex flex-col max-h-[90vh]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-slate-800 shrink-0">
|
||||
<h2 className="text-base font-semibold text-white">Settings</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-500 hover:text-slate-300 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1 px-6 py-5">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<svg className="animate-spin h-5 w-5 text-slate-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<ServiceSection
|
||||
id="radarr"
|
||||
label="Radarr"
|
||||
placeholder="http://radarr:7878"
|
||||
config={settings.radarr}
|
||||
onChange={(p) => patch("radarr", p)}
|
||||
/>
|
||||
<ServiceSection
|
||||
id="sonarr"
|
||||
label="Sonarr"
|
||||
placeholder="http://sonarr:8989"
|
||||
config={settings.sonarr}
|
||||
onChange={(p) => patch("sonarr", p)}
|
||||
/>
|
||||
<ServiceSection
|
||||
id="seerr"
|
||||
label="Overseerr / Jellyseerr"
|
||||
placeholder="http://overseerr:5055"
|
||||
config={settings.seerr}
|
||||
onChange={(p) => patch("seerr", p)}
|
||||
/>
|
||||
<ServiceSection
|
||||
id="tautulli"
|
||||
label="Tautulli"
|
||||
placeholder="http://tautulli:8181"
|
||||
optional
|
||||
config={settings.tautulli}
|
||||
onChange={(p) => patch("tautulli", p)}
|
||||
/>
|
||||
<DiscordSection
|
||||
config={settings.discord}
|
||||
onChange={(p) => patchDiscord(p)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-slate-800 shrink-0 gap-3">
|
||||
<div className="text-xs">
|
||||
{saveResult === "saved" && (
|
||||
<span className="text-green-400">Saved — click Refresh to reload data</span>
|
||||
)}
|
||||
{saveResult === "error" && (
|
||||
<span className="text-red-400">Save failed — check the console</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-slate-700 bg-slate-800/60 hover:bg-slate-700 px-4 py-2 text-sm font-medium text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading}
|
||||
className="rounded-lg bg-yellow-500 hover:bg-yellow-400 disabled:opacity-40 disabled:cursor-not-allowed px-4 py-2 text-sm font-semibold text-black transition-colors"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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