Compare commits

..

10 Commits

Author SHA1 Message Date
b2c1642065 Add per-user detail pages with activity chart and request history
Each user in the leaderboard links to a profile page showing stat cards,
a line chart (requests / storage / watch hours, 1W–1Y timeframes, raw or
normalized, plus a Storage Load mode), and a full request history sorted
newest-first. Includes Overseerr media status codes (1–5), Tautulli watch
history aggregation, and a server-side raw cache so the user API route can
enrich requests without re-fetching everything.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 17:26:47 -04:00
641a7fd096 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>
2026-04-12 14:57:07 -04:00
2374bad7ba Sync open alert count in localStorage on status toggle
After closing or reopening an alert, patch the cached dashboard stats
in localStorage so the summary card and tab badge reflect the new
count immediately when navigating back, without waiting for a refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 12:04:21 -04:00
d0bd17ed7e Add system comments on close events
Manual close: "Manually closed."
Auto-resolve: "Condition resolved — alert closed automatically."
(reopen comments were already in place)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 11:59:31 -04:00
4b2c82cf90 Add Radarr/Sonarr links and richer metadata to alert detail
- Thread titleSlug through RadarrMovie/SonarrSeries → MediaEntry →
  AlertCandidate, building a direct mediaUrl server-side at alert
  generation time (RADARR_URL/movie/slug, SONARR_URL/series/slug)
- Alert detail page: single-column full-width layout, metadata chips
  row showing opened/closed dates, media type, and a "View in Radarr/
  Sonarr" external link button when available
- Comments section sits below the full-width overview card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 11:50:23 -04:00
c86b8ff33a Rework alert comments: authors, system events, wider layout
- Add author field ("user" | "system") to AlertComment
- System comments are automatically added when an alert is reopened
  by the engine ("Alert reopened — condition is still active.") or
  manually via the UI ("Manually reopened.")
- Alert detail page redesigned with a two-column layout (3/5 detail,
  2/5 comments) at lg breakpoint
- System comments render as centered event dividers with a gear icon;
  user comments render as avatar + bubble

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 11:44:51 -04:00
bf83c1a779 Preserve alert history when reopening
When a closed alert reopens (condition still present, no active
cooldown), keep firstSeen and comments intact. The alert is the same
incident continuing — closing and reopening should not erase history.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 11:40:42 -04:00
6fa246d3c4 Rework alert cooldown model
Content alerts (unfulfilled, pending, tautulli-no-matches) now have
zero cooldown on manual close — they reopen immediately on the next
refresh if the condition still exists. Closing is an acknowledgment
of the current state, not a suppression of future alerts.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 11:38:05 -04:00
8fe61cdeb8 Revise alert parameters and ghost requester logic
- UNFULFILLED_MIN_AGE_DAYS → UNFULFILLED_MIN_AGE_HOURS (default 12h)
  so new requests don't sit a full 3 days before alerting
- Incomplete Download threshold: 90% → 100% (any missing episode fires)
- PENDING_MIN_AGE_DAYS: 7 → 2
- Ghost Requester reworked: instead of checking lifetime plays = 0,
  now checks whether the user's last Tautulli activity predates their
  last N (default 5) approved requests — catches people who request
  but don't watch their recent content
- Removed Frequent Declines alert
- Add tautulliLastSeen to UserStat to support the ghost rework
- Update README to reflect all changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 11:34:55 -04:00
a8a03b59d5 Add project README
Documents setup, environment variables, all alert types with their
tunable parameters and cooldowns, and the alert lifecycle diagram.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 11:16:49 -04:00
27 changed files with 3985 additions and 459 deletions

7
.gitignore vendored
View File

@@ -42,3 +42,10 @@ next-env.d.ts
# alert persistence
/data/alerts.json
/data/alerts.json.migrated
/data/alerts.db
/data/alerts.db-shm
/data/alerts.db-wal
# settings (API keys — never commit)
/data/settings.json

227
README.md
View File

@@ -1,36 +1,221 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# OverSnitch
## Getting Started
A self-hosted dashboard for monitoring Overseerr/Jellyseerr users — who's requesting, how much storage they're consuming, how often they actually watch what they request, and whether anything needs your attention.
First, run the development server:
Built with Next.js 16, TypeScript, and Tailwind CSS.
---
## Features
- **Leaderboard** — per-user request count, total storage, average GB per request, and optional Tautulli watch stats (plays, watch hours), each ranked against the full userbase
- **User detail pages** — click any user in the leaderboard to see their full profile: stat cards, an activity chart with 1W/1M/3M/1Y timeframes, and a complete request history sorted newest-first
- **Activity chart** — two modes: *Metrics* (requests, storage GB, watch hours as separate toggleable lines, with a Raw/Relative normalization toggle) and *Storage Load* (GB requested ÷ watch hours per bucket, with an all-time average reference line)
- **Alerting** — automatic alerts for stalled downloads, neglected requesters, and abusive patterns, with open/close state, notes, and auto-resolve when conditions clear
- **Discord notifications** — posts a structured embed to a webhook whenever a new alert opens or a resolved one returns
- **Settings UI** — configure all service URLs and API keys from the dashboard; no need to touch `.env.local` after initial setup
- **SWR caching** — stats are cached server-side for 5 minutes and seeded from localStorage on the client, so the dashboard is instant on return visits
---
## Setup
### 1. Clone and install
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
git clone https://gitea.thewrightserver.net/josh/OverSnitch.git
cd OverSnitch
npm install
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
### 2. Configure
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
**Option A — Settings UI (recommended)**
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
Start the app and click the gear icon in the top-right corner. Enter your service URLs and API keys, hit **Test** to verify each connection, then **Save**.
## Learn More
```bash
npm run dev # or: npm run build && npm start
```
To learn more about Next.js, take a look at the following resources:
Settings are written to `data/settings.json` (gitignored).
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
**Option B — Environment variables**
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
Create `.env.local` in the project root. Values here are used as fallbacks when `data/settings.json` doesn't exist or doesn't contain an override.
## Deploy on Vercel
```env
# Required
SEERR_URL=http://overseerr:5055
SEERR_API=your_overseerr_api_key
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
RADARR_URL=http://radarr:7878
RADARR_API=your_radarr_api_key
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
SONARR_URL=http://sonarr:8989
SONARR_API=your_sonarr_api_key
# Optional — enables watch time stats and ghost/watch-rate alerts
TAUTULLI_URL=http://tautulli:8181
TAUTULLI_API=your_tautulli_api_key
# Optional — Discord webhook for new-alert notifications
DISCORD_WEBHOOK=https://discord.com/api/webhooks/...
# Optional — if your services use self-signed certs
# NODE_TLS_REJECT_UNAUTHORIZED=0
```
---
## Discord Notifications
When configured, OverSnitch posts a structured embed to your Discord channel whenever an alert is newly opened or reopens after being resolved. Already-open alerts refreshing their data do not re-notify.
Each embed is formatted by category:
| Category | Fields shown |
|---|---|
| Not Downloaded | Requested by · Approved N ago · Status |
| Incomplete Download | Requested by · Approved N ago · Downloaded X/Y episodes |
| Pending Approval | Requested by · Waiting N days |
| Ghost Requester | User · description |
| Low Watch Rate | User · Watch rate · Plays · Requests |
Configure the webhook URL in the Settings UI or via `DISCORD_WEBHOOK` in `.env.local`. Use the **Test** button to send a sample embed before saving.
---
## Alerts
Alerts are generated on every stats refresh and persisted in `data/alerts.db` (SQLite, gitignored). They have two states — **Open** and **Closed** — and can be manually closed with a per-category cooldown, or auto-resolved when the underlying condition clears.
The alert detail page shows structured metadata (requesters, age, episode progress bars, watch-rate stats), direct links to the item in Radarr/Sonarr, a link to the Overseerr/Jellyseerr media page, and a comment thread for notes.
### Content alerts
These are keyed per piece of media, not per user. If multiple users requested the same item they're grouped into a single alert.
---
#### Not Downloaded
> A movie or TV show was approved but no file exists in Radarr/Sonarr.
| Parameter | Default | Description |
|---|---|---|
| `UNFULFILLED_MIN_AGE_HOURS` | `12` | Hours since approval before alerting. Prevents noise on brand-new requests. |
- Skipped if Radarr reports `isAvailable: false` (unreleased) or Sonarr reports `status: "upcoming"`.
- **Auto-resolves** when the file appears.
- Manual close: no cooldown — reopens on the next refresh if the file still isn't there.
---
#### Incomplete Download
> An ended TV series is missing one or more episodes.
| Parameter | Default | Description |
|---|---|---|
| `UNFULFILLED_MIN_AGE_HOURS` | `12` | Hours since approval before alerting. |
| Completion threshold | `100%` | Any missing episode on a finished series triggers this alert. |
- Only fires for series with `status: "ended"` in Sonarr. Continuing shows are excluded because missing episodes may not have aired yet.
- Completion is calculated as `episodeFileCount / totalEpisodeCount` (not Sonarr's `percentOfEpisodes`, which measures against monitored episodes only).
- **Auto-resolves** when all episodes are on disk.
- Manual close: no cooldown — reopens on the next refresh if episodes are still missing.
---
#### Pending Approval
> A request has been sitting unapproved for too long.
| Parameter | Default | Description |
|---|---|---|
| `PENDING_MIN_AGE_DAYS` | `2` | Days a request must be pending before alerting. |
- One alert per request item, not per user.
- Skipped if the content is unreleased.
- **Auto-resolves** when the request is approved or declined.
- Manual close: no cooldown — reopens on the next refresh if still pending.
---
### User behavior alerts
These fire once per user. Ghost Requester takes priority over Low Watch Rate — a user will only ever have one behavior alert open at a time. Both require the user to be "established" (at least one request older than `USER_MIN_AGE_DAYS`) to avoid flagging new users.
> Requires Tautulli to be configured.
| Parameter | Default | Description |
|---|---|---|
| `USER_MIN_AGE_DAYS` | `14` | Days since oldest request before a user is eligible for behavior alerts. |
---
#### Ghost Requester
> A user hasn't watched anything on Plex since before their last N approved requests were made.
Rather than checking lifetime play counts, this looks at recency: if a user's last Plex activity predates all of their most recent N approved requests, they're not watching what they're requesting.
| Parameter | Default | Description |
|---|---|---|
| `GHOST_RECENT_REQUESTS` | `5` | Number of recent approved requests to evaluate. Also the minimum required before the alert can fire. |
- Manual close cooldown: **7 days**.
---
#### Low Watch Rate
> A user watches a small fraction of what they request.
| Parameter | Default | Description |
|---|---|---|
| `MIN_REQUESTS_WATCHRATE` | `10` | Minimum requests before the ratio is considered meaningful. |
| `LOW_WATCH_RATE` | `0.2` | Ratio of `plays / requests` below which an alert fires (default: under 20%). |
- Manual close cooldown: **7 days**.
---
### System alerts
#### No Tautulli Watch Data
> Tautulli is configured but no plays matched any Overseerr user.
This usually means emails don't align between the two services. Check that users have the same email address in both Overseerr and Tautulli (or that display names match as a fallback).
- Manual close: no cooldown.
---
## Alert lifecycle
```
Condition detected
[OPEN] ◄──────────────────────────────────────┐
│ │
┌────┴───────────────┐ Cooldown │
│ │ expires │
▼ ▼ │
Condition Manually │
clears closed │
│ │ │
▼ ▼ │
[AUTO-RESOLVED] [CLOSED] ──── Condition ────────┘
no cooldown cooldown returns after
reopens suppresses cooldown
immediately re-open
```
- **Auto-resolved** alerts reopen immediately if the condition returns.
- **Content alerts** (unfulfilled, pending) have no cooldown on manual close — they reopen on the next refresh if the condition still exists. Closing is an acknowledgment, not a suppression.
- **User-behavior alerts** (ghost, watchrate) suppress re-opening for 7 days after a manual close.
- Reopening a manually closed alert via the UI always clears the cooldown immediately.

858
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,12 +8,15 @@
"start": "next start"
},
"dependencies": {
"better-sqlite3": "^12.8.0",
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"recharts": "^3.8.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",

View File

@@ -1,8 +1,9 @@
"use client";
import { useState } from "react";
import { useState, useRef, useEffect, type ReactNode } from "react";
import Link from "next/link";
import { Alert, AlertSeverity } from "@/lib/types";
import { useRouter } from "next/navigation";
import { Alert, AlertSeverity, AlertComment } from "@/lib/types";
const severityAccent: Record<AlertSeverity, string> = {
danger: "border-l-red-500",
@@ -10,6 +11,18 @@ const severityAccent: Record<AlertSeverity, string> = {
info: "border-l-blue-500",
};
const severityText: Record<AlertSeverity, string> = {
danger: "text-red-400",
warning: "text-yellow-400",
info: "text-blue-400",
};
const severityLabel: Record<AlertSeverity, string> = {
danger: "Critical",
warning: "Warning",
info: "Info",
};
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
@@ -20,18 +33,277 @@ function timeAgo(iso: string): string {
return `${Math.floor(hrs / 24)}d ago`;
}
function fullDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: "short", day: "numeric", year: "numeric",
hour: "numeric", minute: "2-digit",
});
}
function shortDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
month: "short", day: "numeric", hour: "numeric", minute: "2-digit",
});
}
export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
// ── Chip ──────────────────────────────────────────────────────────────────────
function Chip({ label, dim }: { label: string; dim?: boolean }) {
return (
<span className={`inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-medium ${
dim
? "border-slate-700/40 bg-slate-800/40 text-slate-500"
: "border-slate-700 bg-slate-800 text-slate-300"
}`}>
{label}
</span>
);
}
// ── 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: &lt;20%</p>
</div>
);
}
// ── Comment row ───────────────────────────────────────────────────────────────
function CommentRow({ comment }: { comment: AlertComment }) {
const isSystem = comment.author === "system";
if (isSystem) {
return (
<div className="flex items-center gap-3">
<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.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>
<span className="text-slate-700">·</span>
<span>{timeAgo(comment.createdAt)}</span>
</div>
<div className="h-px flex-1 bg-slate-800" />
</div>
);
}
return (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded-full bg-slate-700 flex items-center justify-center 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.5 w-3.5 text-slate-400">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
</div>
<span className="text-xs font-semibold text-slate-300">User</span>
<span className="text-xs text-slate-600">{shortDate(comment.createdAt)}</span>
</div>
<div className="ml-8">
<p className="text-sm text-slate-300 whitespace-pre-wrap leading-relaxed bg-slate-800/60 rounded-lg border border-slate-700/40 px-4 py-3">
{comment.body}
</p>
</div>
</div>
);
}
// ── 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 ──────────────────────────────────────────────────────────────────────
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("");
const [commentLoading, setCommentLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [alert.comments.length]);
async function toggleStatus() {
setActionLoading(true);
@@ -44,7 +316,18 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
body: JSON.stringify({ status: newStatus }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setAlert(await res.json());
const updated: Alert = await res.json();
setAlert(updated);
try {
const raw = localStorage.getItem("oversnitch_stats");
if (raw) {
const stats = JSON.parse(raw);
const delta = updated.status === "open" ? 1 : -1;
stats.summary.openAlertCount = Math.max(0, (stats.summary.openAlertCount ?? 0) + delta);
localStorage.setItem("oversnitch_stats", JSON.stringify(stats));
}
} catch {}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
@@ -76,20 +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-2xl px-4 py-8 space-y-5">
<main className="mx-auto max-w-5xl px-6 py-8 space-y-6">
<Link
href="/?tab=alerts"
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors"
{/* Back */}
<button
onClick={() => router.back()}
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors cursor-pointer"
>
<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">
@@ -97,74 +407,188 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
</div>
)}
{/* Alert card */}
<div className={`rounded-xl bg-slate-800/40 border border-slate-700/60 border-l-4 px-6 py-5 space-y-3 ${severityAccent[alert.severity]}`}>
{/* ── 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-4">
{/* Status row */}
<div className="flex items-center justify-between gap-3">
<span className={`inline-flex items-center gap-1.5 text-xs font-semibold ${
isOpen ? "text-green-400" : isResolved ? "text-teal-400" : "text-slate-500"
}`}>
{isOpen && <span className="h-1.5 w-1.5 rounded-full bg-green-400 shrink-0" />}
{isOpen ? "Open" : isResolved ? "Auto-resolved" : "Closed"}
<span className="text-slate-700 font-normal">·</span>
<span className="text-slate-600 font-normal">{timeAgo(statusTime)}</span>
</span>
{/* 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]}
</span>
<span className="text-slate-700">·</span>
<span className={`inline-flex items-center gap-1.5 text-xs font-semibold ${
isOpen ? "text-green-400" : isResolved ? "text-teal-400" : "text-slate-500"
}`}>
{isOpen && <span className="h-1.5 w-1.5 rounded-full bg-green-400 shrink-0" />}
{isOpen ? "Open" : isResolved ? "Auto-resolved" : "Closed"}
</span>
<span className="text-slate-700">·</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-1.5">
<h1 className="text-lg font-bold text-white leading-snug">{alert.title}</h1>
<p className="text-sm text-slate-400 leading-relaxed">{alert.description}</p>
{/* 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>
)}
{/* 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>
)}
{/* 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"
}`}
>
{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>
{/* Notes */}
<section className="space-y-3 pt-1">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-600">Notes</h2>
{/* ── Comments ────────────────────────────────────────────────────── */}
<section className="space-y-4">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-600">
Comments
</h2>
{alert.comments.length === 0 && (
<p className="text-sm text-slate-700">No notes yet.</p>
)}
<div className="space-y-4">
{alert.comments.length === 0 && (
<p className="text-sm text-slate-700">No comments yet.</p>
)}
{alert.comments.map((c) => (
<CommentRow key={c.id} comment={c} />
))}
<div ref={bottomRef} />
</div>
{alert.comments.map((c) => (
<div key={c.id} className="space-y-1">
<p className="text-xs text-slate-600">{shortDate(c.createdAt)}</p>
<p className="text-sm text-slate-300 whitespace-pre-wrap leading-relaxed">{c.body}</p>
</div>
))}
<form onSubmit={submitComment} className="flex flex-col gap-2 pt-1">
<form onSubmit={submitComment} className="flex flex-col gap-2 pt-2 border-t border-slate-800">
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Add a note…"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (commentText.trim()) submitComment(e as unknown as React.FormEvent);
}
}}
placeholder="Add a comment…"
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()}
className="rounded-lg bg-slate-700 hover:bg-slate-600 disabled:opacity-40 disabled:cursor-not-allowed px-4 py-2 text-sm font-medium text-white transition-colors"
>
{commentLoading ? "Saving…" : "Save"}
{commentLoading ? "Saving…" : "Comment"}
</button>
</div>
</form>
</section>
</main>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -1,63 +1,9 @@
import { buildRadarrMap } from "@/lib/radarr";
import { buildSonarrMap } from "@/lib/sonarr";
import { fetchAllUsers, fetchUserRequests } from "@/lib/overseerr";
import { buildTautulliMap } from "@/lib/tautulli";
import { computeStats } from "@/lib/aggregate";
import { DashboardStats, OverseerrRequest } from "@/lib/types";
const BATCH_SIZE = 5;
// ── Server-side SWR cache ────────────────────────────────────────────────────
// Persists in the Node.js process between requests.
// Background-refreshes after STALE_MS so reads always return instantly.
const STALE_MS = 5 * 60 * 1000; // start background refresh after 5 min
let cache: { stats: DashboardStats; at: number } | null = null;
let refreshing = false;
async function buildStats(): Promise<DashboardStats> {
const [radarrMap, sonarrMap, users, tautulliMap] = await Promise.all([
buildRadarrMap(),
buildSonarrMap(),
fetchAllUsers(),
buildTautulliMap(),
]);
const allRequests = new Map<number, OverseerrRequest[]>();
for (let i = 0; i < users.length; i += BATCH_SIZE) {
const chunk = users.slice(i, i + BATCH_SIZE);
const results = await Promise.all(
chunk.map((u) => fetchUserRequests(u.id))
);
chunk.forEach((u, idx) => allRequests.set(u.id, results[idx]));
}
return computeStats(users, allRequests, radarrMap, sonarrMap, tautulliMap);
}
import { getStats } from "@/lib/statsBuilder";
export async function GET(req: Request) {
const force = new URL(req.url).searchParams.has("force");
try {
// Force (Refresh button) or cold start: wait for fresh data
if (force || !cache) {
const stats = await buildStats();
cache = { stats, at: Date.now() };
return Response.json(cache.stats);
}
// Stale: kick off background refresh, return cache immediately
const age = Date.now() - cache.at;
if (age > STALE_MS && !refreshing) {
refreshing = true;
buildStats()
.then((stats) => { cache = { stats, at: Date.now() }; })
.catch(() => {})
.finally(() => { refreshing = false; });
}
return Response.json(cache.stats);
return Response.json(await getStats(force));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return Response.json({ error: message }, { status: 500 });

View File

@@ -0,0 +1,92 @@
import { getStats, getRawCache } from "@/lib/statsBuilder";
import { lookupTautulliUser, fetchUserWatchHistory } from "@/lib/tautulli";
import { getAllAlerts } from "@/lib/db";
import { bytesToGB } from "@/lib/aggregate";
import { EnrichedRequest, UserPageData } from "@/lib/types";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const userId = parseInt(id, 10);
if (isNaN(userId)) {
return Response.json({ error: "Invalid user ID" }, { status: 400 });
}
try {
// Find the user in the cached stats (triggers a build if cache is cold)
const stats = await getStats();
const stat = stats.users.find((u) => u.userId === userId);
if (!stat) {
return Response.json({ error: "User not found" }, { status: 404 });
}
// Enrich requests with resolved title + size from cached media maps
const raw = getRawCache();
const userRequests = raw?.allRequests.get(userId) ?? [];
const enrichedRequests: EnrichedRequest[] = userRequests.map((req) => {
let sizeOnDisk = 0;
let title = req.media.title ?? "";
if (req.type === "movie") {
const entry = raw?.radarrMap.get(req.media.tmdbId);
sizeOnDisk = entry?.sizeOnDisk ?? 0;
if (entry?.title) title = entry.title;
} else if (req.type === "tv" && req.media.tvdbId) {
const entry = raw?.sonarrMap.get(req.media.tvdbId);
sizeOnDisk = entry?.sizeOnDisk ?? 0;
if (entry?.title) title = entry.title;
}
if (!title) {
title = req.type === "movie"
? `Movie #${req.media.tmdbId}`
: `Show #${req.media.tmdbId}`;
}
return {
id: req.id,
type: req.type,
status: req.status,
createdAt: req.createdAt,
mediaId: req.type === "movie" ? req.media.tmdbId : (req.media.tvdbId ?? 0),
title,
sizeOnDisk,
sizeGB: bytesToGB(sizeOnDisk),
};
});
// Fetch watch history from Tautulli (if available)
let tautulliUserId: number | null = null;
if (raw?.tautulliMap) {
const tu = lookupTautulliUser(raw.tautulliMap, stat.email, stat.displayName);
tautulliUserId = tu?.user_id ?? null;
}
const watchHistory = tautulliUserId
? await fetchUserWatchHistory(tautulliUserId)
: [];
// Open alerts involving this user
const allAlerts = getAllAlerts();
const openAlerts = allAlerts.filter(
(a) =>
a.status === "open" &&
(a.userId === userId || a.requesterIds?.includes(userId))
);
const result: UserPageData = {
stat,
enrichedRequests,
watchHistory,
openAlerts,
};
return Response.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return Response.json({ error: message }, { status: 500 });
}
}

View File

@@ -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,11 +65,12 @@ function DashboardContent() {
load();
}, [load]);
function setTab(t: Tab) {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", t);
router.push(`?${params.toString()}`, { scroll: false });
}
// Poll every 2 minutes to keep the UI fresh against the server cache.
// The server itself refreshes every 5 min via the background poller.
useEffect(() => {
const id = setInterval(() => load(), 2 * 60 * 1000);
return () => clearInterval(id);
}, [load]);
const hasTautulli = data?.summary.totalWatchHours !== null;
const openAlertCount = data?.summary.openAlertCount ?? 0;
@@ -79,6 +78,7 @@ function DashboardContent() {
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 +89,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
@@ -146,7 +159,7 @@ function DashboardContent() {
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors cursor-pointer ${
tab === t
? "border-yellow-400 text-white"
: "border-transparent text-slate-500 hover:text-slate-300"
@@ -175,14 +188,12 @@ function DashboardContent() {
{tab === "alerts" && <AlertsPanel />}
</>
)}
<SettingsModal
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
onSaved={() => load(true)}
/>
</main>
);
}
export default function Page() {
return (
<Suspense>
<DashboardContent />
</Suspense>
);
}

View File

@@ -0,0 +1,614 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import Link from "next/link";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
ReferenceLine,
} from "recharts";
import {
EnrichedRequest,
WatchDataPoint,
UserPageData,
AlertSeverity,
} from "@/lib/types";
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatGB(gb: number): string {
if (gb === 0) return "—";
return gb >= 1000 ? `${(gb / 1000).toFixed(2)} TB` : `${gb.toFixed(1)} GB`;
}
function formatHours(h: number): string {
if (h >= 1000) return `${(h / 1000).toFixed(1)}k h`;
return `${h.toFixed(0)}h`;
}
function timeAgo(iso: string | null | undefined): string {
if (!iso) return "Never";
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`;
return new Date(iso).toLocaleDateString(undefined, { month: "short", year: "numeric" });
}
function unixTimeAgo(ts: number | null): string {
if (ts === null) return "Never";
return timeAgo(new Date(ts * 1000).toISOString());
}
// ── Status badge ──────────────────────────────────────────────────────────────
// Overseerr media status codes (media.status, not request approval status):
// 1=Unknown, 2=Pending, 3=Processing, 4=Partially Available, 5=Available
const STATUS_LABEL: Record<number, string> = {
1: "Unknown",
2: "Pending",
3: "Processing",
4: "Partial",
5: "Available",
};
const STATUS_COLOR: Record<number, string> = {
1: "bg-slate-700/30 text-slate-500 border-slate-600/40",
2: "bg-yellow-500/15 text-yellow-400 border-yellow-700/40",
3: "bg-blue-500/15 text-blue-400 border-blue-700/40",
4: "bg-cyan-500/15 text-cyan-400 border-cyan-700/40",
5: "bg-green-500/15 text-green-400 border-green-700/40",
};
function StatusBadge({ status }: { status: number }) {
return (
<span className={`inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium ${STATUS_COLOR[status] ?? "bg-slate-700 text-slate-400 border-slate-600"}`}>
{STATUS_LABEL[status] ?? `Status ${status}`}
</span>
);
}
// ── Rank chip ─────────────────────────────────────────────────────────────────
function RankChip({ rank, total }: { rank: number | null; total: number }) {
if (rank === null) return null;
return (
<span className="text-xs font-mono text-slate-500">
#{rank}<span className="text-slate-700">/{total}</span>
</span>
);
}
// ── Stat cards ────────────────────────────────────────────────────────────────
function StatCard({ label, value, rank, total, highlight }: {
label: string;
value: string;
rank?: number | null;
total?: number;
highlight?: boolean;
}) {
return (
<div className="flex flex-col gap-1 rounded-xl border border-slate-700/60 bg-slate-800/60 px-4 py-3">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">{label}</span>
<span className={`text-2xl font-bold tabular-nums ${highlight ? "text-yellow-300" : "text-white"}`}>
{value}
</span>
{rank !== undefined && rank !== null && total !== undefined && (
<RankChip rank={rank} total={total} />
)}
</div>
);
}
// ── Chart ─────────────────────────────────────────────────────────────────────
type Timeframe = "1W" | "1M" | "3M" | "1Y";
const TF_CONFIG: Record<Timeframe, { rangeDays: number; bucketDays: number }> = {
"1W": { rangeDays: 7, bucketDays: 1 },
"1M": { rangeDays: 30, bucketDays: 1 },
"3M": { rangeDays: 91, bucketDays: 7 },
"1Y": { rangeDays: 365, bucketDays: 30 },
};
interface ChartPoint {
label: string;
requests: number;
gb: number;
plays: number;
watchHours: number;
/** GB requested ÷ watch hours. null when watchHours = 0 (no denominator). */
load: number | null;
}
function buildChartPoints(
enrichedRequests: EnrichedRequest[],
watchHistory: WatchDataPoint[],
tf: Timeframe
): ChartPoint[] {
const now = Date.now();
const MS = 86_400_000;
const { rangeDays, bucketDays } = TF_CONFIG[tf];
const numBuckets = Math.ceil(rangeDays / bucketDays);
const points: ChartPoint[] = Array.from({ length: numBuckets }, (_, i) => {
const midMs = now - (numBuckets - 1 - i + 0.5) * bucketDays * MS;
const d = new Date(midMs);
const label =
bucketDays >= 28
? d.toLocaleDateString(undefined, { month: "short", year: "2-digit" })
: d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
return { label, requests: 0, gb: 0, plays: 0, watchHours: 0, load: null };
});
for (const req of enrichedRequests) {
const ageMs = now - new Date(req.createdAt).getTime();
if (ageMs < 0 || ageMs > rangeDays * MS) continue;
const idx = numBuckets - 1 - Math.floor(ageMs / (bucketDays * MS));
if (idx >= 0 && idx < numBuckets) {
points[idx].requests += 1;
points[idx].gb = Math.round((points[idx].gb + req.sizeGB) * 10) / 10;
}
}
for (const wh of watchHistory) {
const ageMs = now - new Date(wh.date + "T12:00:00").getTime();
if (ageMs < 0 || ageMs > rangeDays * MS) continue;
const idx = numBuckets - 1 - Math.floor(ageMs / (bucketDays * MS));
if (idx >= 0 && idx < numBuckets) {
points[idx].plays += wh.plays;
points[idx].watchHours = Math.round((points[idx].watchHours + wh.durationHours) * 10) / 10;
}
}
for (const p of points) {
p.load = p.watchHours > 0 ? Math.round((p.gb / p.watchHours) * 10) / 10 : null;
}
return points;
}
/**
* Normalize each series to % of its own period average.
* A value equal to the mean shows as 100; double the mean shows as 200.
* Series with a mean of 0 stay at 0. load is a ratio — not normalized.
*/
function normalizeData(points: ChartPoint[]): ChartPoint[] {
if (points.length === 0) return points;
const n = points.length;
const meanReq = points.reduce((s, p) => s + p.requests, 0) / n;
const meanGb = points.reduce((s, p) => s + p.gb, 0) / n;
const meanWh = points.reduce((s, p) => s + p.watchHours, 0) / n;
return points.map((p) => ({
label: p.label,
requests: meanReq > 0 ? Math.round((p.requests / meanReq) * 100) : 0,
gb: meanGb > 0 ? Math.round((p.gb / meanGb) * 100) : 0,
plays: p.plays,
watchHours: meanWh > 0 ? Math.round((p.watchHours / meanWh) * 100) : 0,
load: p.load,
}));
}
// ── Alert severity helpers ────────────────────────────────────────────────────
const SEV_COLOR: Record<AlertSeverity, string> = {
danger: "border-l-red-500 bg-red-950/20",
warning: "border-l-yellow-500 bg-yellow-950/10",
info: "border-l-blue-500 bg-blue-950/20",
};
const SEV_TEXT: Record<AlertSeverity, string> = {
danger: "text-red-400",
warning: "text-yellow-400",
info: "text-blue-400",
};
// ── Main component ─────────────────────────────────────────────────────────────
export default function UserDetail({ userId }: { userId: number }) {
const [data, setData] = useState<UserPageData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tf, setTf] = useState<Timeframe>("1M");
const [viewMode, setViewMode] = useState<"metrics" | "load">("metrics");
const [normalized, setNormalized] = useState(false);
const [showRequests, setShowRequests] = useState(true);
const [showStorage, setShowStorage] = useState(true);
const [showWatchHours, setShowWatchHours] = useState(true);
const [showAll, setShowAll] = useState(false);
useEffect(() => {
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then((json) => {
if (json.error) throw new Error(json.error);
setData(json as UserPageData);
})
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setLoading(false));
}, [userId]);
const chartData = useMemo(() => {
if (!data) return [];
return buildChartPoints(data.enrichedRequests, data.watchHistory, tf);
}, [data, tf]);
const displayData = useMemo(
() => (normalized && viewMode === "metrics" ? normalizeData(chartData) : chartData),
[chartData, normalized, viewMode]
);
const hasWatch = (data?.watchHistory.length ?? 0) > 0;
// Request history sorted newest first
const sortedByDate = useMemo(() => {
if (!data) return [];
return [...data.enrichedRequests].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}, [data]);
const displayedRequests = showAll ? sortedByDate : sortedByDate.slice(0, 20);
if (loading) {
return (
<main className="mx-auto max-w-6xl px-4 py-8">
<div className="flex flex-col items-center justify-center py-24 gap-4">
<svg className="animate-spin h-8 w-8 text-yellow-400" 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>
<p className="text-slate-400 text-sm">Loading user data</p>
</div>
</main>
);
}
if (error || !data) {
return (
<main className="mx-auto max-w-6xl px-4 py-8 space-y-4">
<Link href="/" className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors cursor-pointer">
<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">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
All Users
</Link>
<div className="rounded-xl border border-red-800 bg-red-950/40 px-5 py-4 text-sm">
<span className="font-semibold text-red-400">Error: </span>
<span className="text-red-300">{error ?? "User not found"}</span>
</div>
</main>
);
}
const { stat, openAlerts } = data;
const hasTautulli = stat.plays !== null;
// User's overall average load (GB requested per watch hour, all time)
const overallLoad =
hasTautulli && stat.watchHours && stat.watchHours > 0
? Math.round((stat.totalGB / stat.watchHours) * 10) / 10
: null;
const statCols = hasTautulli ? "sm:grid-cols-5" : "sm:grid-cols-3";
return (
<main className="mx-auto max-w-6xl px-4 py-8 space-y-6">
{/* Back */}
<Link href="/" className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors cursor-pointer">
<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">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
All Users
</Link>
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2.5">
<h1 className="text-3xl font-bold tracking-tight text-white">{stat.displayName}</h1>
{stat.requestCount === 0 && (
<span className="rounded-full border border-slate-700 bg-slate-800 px-2 py-0.5 text-xs text-slate-500">
No requests
</span>
)}
</div>
<p className="mt-1 text-sm text-slate-500">{stat.email}</p>
</div>
<div className="flex flex-col items-end gap-1">
{hasTautulli && (
<span className="text-xs text-slate-600">
Last seen: <span className="text-slate-400">{unixTimeAgo(stat.tautulliLastSeen)}</span>
</span>
)}
<span className="text-xs text-slate-600">
#{stat.storageRank} of {stat.totalUsers} by storage
</span>
</div>
</div>
{/* Stat cards */}
<div className={`grid grid-cols-2 gap-3 ${statCols}`}>
<StatCard label="Requests" value={stat.requestCount.toLocaleString()} rank={stat.requestRank} total={stat.totalUsers} />
<StatCard label="Storage" value={formatGB(stat.totalGB)} rank={stat.storageRank} total={stat.totalUsers} highlight />
<StatCard label="Avg / Req" value={stat.requestCount > 0 ? formatGB(stat.avgGB) : "—"} />
{hasTautulli && (
<StatCard label="Plays" value={(stat.plays ?? 0).toLocaleString()} rank={stat.playsRank} total={stat.totalUsers} />
)}
{hasTautulli && (
<StatCard
label="Watch Time"
value={stat.watchHours !== null && stat.watchHours > 0 ? formatHours(stat.watchHours) : "0h"}
rank={stat.watchRank}
total={stat.totalUsers}
/>
)}
</div>
{/* Activity chart */}
<div className="rounded-xl border border-slate-700/60 bg-slate-800/40 p-5 space-y-4">
{/* Chart header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-white">Activity</h2>
{/* Metrics / Storage Load mode */}
<div className="flex rounded-lg border border-slate-700/60 overflow-hidden text-xs font-medium">
<button
onClick={() => setViewMode("metrics")}
className={`cursor-pointer px-2.5 py-1 transition-colors ${viewMode === "metrics" ? "bg-slate-700 text-white" : "text-slate-500 hover:text-slate-300"}`}
>
Metrics
</button>
<button
onClick={() => setViewMode("load")}
title="GB requested ÷ watch hours — how much server storage each hour of viewing costs"
className={`cursor-pointer px-2.5 py-1 transition-colors border-l border-slate-700/60 ${viewMode === "load" ? "bg-orange-500/20 text-orange-300" : "text-slate-500 hover:text-slate-300"}`}
>
Storage Load
</button>
</div>
{/* Raw / Relative — metrics mode only */}
{viewMode === "metrics" && (
<button
onClick={() => setNormalized((v) => !v)}
title={normalized ? "Switch to raw values" : "Normalize to % of period average"}
className={`cursor-pointer rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors ${normalized ? "border-violet-700/60 bg-violet-500/15 text-violet-400" : "border-slate-700/40 bg-slate-800/40 text-slate-500 hover:text-slate-300"}`}
>
{normalized ? "Relative" : "Raw"}
</button>
)}
</div>
<div className="flex items-center gap-1">
{(["1W", "1M", "3M", "1Y"] as Timeframe[]).map((t) => (
<button
key={t}
onClick={() => setTf(t)}
className={`cursor-pointer rounded px-2.5 py-1 text-xs font-medium transition-colors ${tf === t ? "bg-yellow-400/20 text-yellow-300" : "text-slate-500 hover:text-slate-300"}`}
>
{t}
</button>
))}
</div>
</div>
{/* Series toggles — metrics mode only */}
{viewMode === "metrics" && (
<div className="flex flex-wrap gap-3">
<button
onClick={() => setShowRequests((v) => !v)}
className={`cursor-pointer inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity ${showRequests ? "border-blue-700/60 bg-blue-500/10 text-blue-400" : "border-slate-700/40 bg-slate-800/40 text-slate-600 opacity-50"}`}
>
<span className="h-2 w-2 rounded-full bg-blue-500 inline-block" />
Requests
</button>
<button
onClick={() => setShowStorage((v) => !v)}
className={`cursor-pointer inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity ${showStorage ? "border-yellow-700/60 bg-yellow-500/10 text-yellow-400" : "border-slate-700/40 bg-slate-800/40 text-slate-600 opacity-50"}`}
>
<span className="h-2 w-2 rounded-full bg-yellow-400 inline-block" />
Storage (GB)
</button>
{hasWatch && (
<button
onClick={() => setShowWatchHours((v) => !v)}
className={`cursor-pointer inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity ${showWatchHours ? "border-green-700/60 bg-green-500/10 text-green-400" : "border-slate-700/40 bg-slate-800/40 text-slate-600 opacity-50"}`}
>
<span className="h-2 w-2 rounded-full bg-green-400 inline-block" />
Watch Hours
</button>
)}
</div>
)}
{/* Load mode explainer */}
{viewMode === "load" && (
<p className="text-xs text-slate-600">
GB requested ÷ watch hours per period. Lower is healthier a well-watched library stays near your average.
{overallLoad !== null && (
<> Your overall average is <span className="text-slate-400">{overallLoad} GB/hr</span>.</>
)}
</p>
)}
{/* Chart */}
<ResponsiveContainer width="100%" height={260}>
<LineChart
data={displayData}
margin={{ top: 4, right: viewMode === "load" || normalized ? 8 : 16, left: -8, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="label" tick={{ fill: "#475569", fontSize: 11 }} axisLine={{ stroke: "#334155" }} tickLine={false} />
{viewMode === "load" && (
<YAxis yAxisId="left" orientation="left" tick={{ fill: "#475569", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `${v}G/h`} width={44} />
)}
{viewMode === "metrics" && normalized && (
<YAxis yAxisId="left" orientation="left" tick={{ fill: "#475569", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `${v}%`} width={40} />
)}
{viewMode === "metrics" && !normalized && (
<YAxis yAxisId="counts" orientation="left" tick={{ fill: "#475569", fontSize: 11 }} axisLine={false} tickLine={false} width={32} />
)}
{viewMode === "metrics" && !normalized && (
<YAxis yAxisId="gb" orientation="right" tick={{ fill: "#475569", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `${v}G`} width={40} />
)}
<Tooltip
contentStyle={{ background: "#0f172a", border: "1px solid #334155", borderRadius: "8px", fontSize: "12px" }}
labelStyle={{ color: "#94a3b8", marginBottom: "4px" }}
itemStyle={{ color: "#e2e8f0" }}
formatter={(value, name) => {
const num = typeof value === "number" ? value : Number(value);
const label = String(name ?? "");
if (viewMode === "load") return [`${num} GB/hr`, label];
if (normalized) return [`${num}%`, label];
if (label === "Storage (GB)") return [formatGB(num), label];
if (label === "Watch Hours") return [`${num}h`, label];
return [num, label];
}}
/>
<Legend wrapperStyle={{ display: "none" }} />
{viewMode === "metrics" && normalized && (
<ReferenceLine yAxisId="left" y={100} stroke="#334155" strokeDasharray="4 3" label={{ value: "avg", position: "insideTopRight", fill: "#475569", fontSize: 10 }} />
)}
{viewMode === "load" && overallLoad !== null && (
<ReferenceLine yAxisId="left" y={overallLoad} stroke="#f97316" strokeOpacity={0.4} strokeDasharray="4 3" label={{ value: "avg", position: "insideTopRight", fill: "#f97316", fontSize: 10 }} />
)}
{viewMode === "load" && (
<Line yAxisId="left" type="monotone" dataKey="load" stroke="#f97316" strokeWidth={2} dot={false} activeDot={{ r: 4 }} connectNulls={false} name="GB / Watch Hr" />
)}
{viewMode === "metrics" && showRequests && (
<Line yAxisId={normalized ? "left" : "counts"} type="monotone" dataKey="requests" stroke="#3b82f6" strokeWidth={2} dot={false} activeDot={{ r: 4 }} name="Requests" />
)}
{viewMode === "metrics" && showStorage && (
<Line yAxisId={normalized ? "left" : "gb"} type="monotone" dataKey="gb" stroke="#facc15" strokeWidth={2} dot={false} activeDot={{ r: 4 }} name="Storage (GB)" />
)}
{viewMode === "metrics" && hasWatch && showWatchHours && (
<Line yAxisId={normalized ? "left" : "counts"} type="monotone" dataKey="watchHours" stroke="#4ade80" strokeWidth={2} dot={false} activeDot={{ r: 4 }} name="Watch Hours" />
)}
</LineChart>
</ResponsiveContainer>
{chartData.every((p) => p.requests === 0 && p.gb === 0 && p.watchHours === 0) && (
<p className="text-center text-sm text-slate-600 py-2">No activity in this period</p>
)}
</div>
{/* Request history */}
<div className="space-y-3">
<h2 className="text-base font-semibold text-white">
Request History
<span className="ml-2 text-xs font-normal text-slate-600">{sortedByDate.length} total</span>
</h2>
{sortedByDate.length === 0 ? (
<div className="rounded-xl border border-slate-700/40 bg-slate-800/20 px-5 py-6 text-center text-sm text-slate-600">
No requests yet
</div>
) : (
<>
<div className="overflow-x-auto rounded-xl border border-slate-700/60">
<table className="w-full text-sm">
<thead className="bg-slate-800/80 border-b border-slate-700/60">
<tr>
<th className="py-2.5 px-4 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">Title</th>
<th className="py-2.5 px-4 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">Type</th>
<th className="py-2.5 px-4 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">Status</th>
<th className="py-2.5 px-4 text-right text-xs font-semibold uppercase tracking-wider text-slate-500">Size</th>
<th className="py-2.5 px-4 text-right text-xs font-semibold uppercase tracking-wider text-slate-500">Requested</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/30">
{displayedRequests.map((req) => (
<tr key={req.id} className="bg-slate-900 hover:bg-slate-800/50 transition-colors">
<td className="py-2.5 px-4 font-medium text-white max-w-xs truncate">{req.title}</td>
<td className="py-2.5 px-4">
<span className={`rounded border px-1.5 py-0.5 text-xs ${req.type === "movie" ? "border-purple-700/40 bg-purple-500/10 text-purple-400" : "border-cyan-700/40 bg-cyan-500/10 text-cyan-400"}`}>
{req.type === "movie" ? "Movie" : "TV"}
</span>
</td>
<td className="py-2.5 px-4">
<StatusBadge status={req.status} />
</td>
<td className="py-2.5 px-4 text-right font-mono text-xs tabular-nums text-slate-400">
{req.sizeGB > 0 ? formatGB(req.sizeGB) : <span className="text-slate-700"></span>}
</td>
<td className="py-2.5 px-4 text-right text-xs text-slate-600">
{new Date(req.createdAt).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}
</td>
</tr>
))}
</tbody>
</table>
</div>
{sortedByDate.length > 20 && (
<button
onClick={() => setShowAll((v) => !v)}
className="cursor-pointer w-full rounded-lg border border-slate-700/40 bg-slate-800/20 py-2 text-sm text-slate-500 hover:text-slate-300 hover:bg-slate-800/40 transition-colors"
>
{showAll ? "Show recent 20" : `Show all ${sortedByDate.length} requests`}
</button>
)}
</>
)}
</div>
{/* Open alerts */}
{openAlerts.length > 0 && (
<div className="space-y-3">
<h2 className="text-base font-semibold text-white">
Open Alerts
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-yellow-500 text-black text-xs font-bold px-1.5 py-0.5 leading-none">
{openAlerts.length}
</span>
</h2>
<div className="space-y-2">
{openAlerts.map((alert) => (
<Link
key={alert.id}
href={`/alerts/${alert.id}`}
className={`block rounded-xl border border-slate-700/60 border-l-4 px-4 py-3.5 transition-colors hover:bg-slate-800/60 cursor-pointer ${SEV_COLOR[alert.severity]}`}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<span className={`text-xs font-semibold uppercase tracking-wider ${SEV_TEXT[alert.severity]}`}>
{alert.category}
</span>
<p className="mt-0.5 text-sm font-medium text-white leading-snug">{alert.title}</p>
<p className="mt-1 text-xs text-slate-500 line-clamp-2">{alert.description}</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4 shrink-0 text-slate-600 mt-0.5">
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</div>
</Link>
))}
</div>
</div>
)}
</main>
);
}

View File

@@ -0,0 +1,10 @@
import UserDetail from "./UserDetail";
export default async function UserPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <UserDetail userId={Number(id)} />;
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { UserStat } from "@/lib/types";
type SortKey = "totalBytes" | "requestCount" | "avgGB" | "plays" | "watchHours";
@@ -124,8 +125,15 @@ export default function LeaderboardTable({
{/* User */}
<td className="py-3 px-4">
<div className="font-medium text-white leading-snug">{user.displayName}</div>
<div className="text-xs text-slate-600 mt-0.5">{user.email}</div>
<Link
href={`/users/${user.userId}`}
className="group/name cursor-pointer"
>
<div className="font-medium text-white leading-snug group-hover/name:text-yellow-300 transition-colors">
{user.displayName}
</div>
<div className="text-xs text-slate-600 mt-0.5">{user.email}</div>
</Link>
</td>
{/* Requests */}

View File

@@ -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>
);
}

14
src/instrumentation.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* Next.js instrumentation hook — runs once when the server starts.
* Used to kick off the background stats poller so alerts are generated
* and Discord notifications are sent even when no client is connected.
*/
export async function register() {
// Only run in the Node.js runtime (not Edge).
// better-sqlite3 and the fetch clients require Node.js APIs.
if (process.env.NEXT_RUNTIME === "edge") return;
const { startBackgroundPoller } = await import("@/lib/statsBuilder");
startBackgroundPoller();
}

View File

@@ -9,6 +9,7 @@ import {
import { lookupTautulliUser } from "@/lib/tautulli";
import { generateAlertCandidates } from "@/lib/alerts";
import { upsertAlerts } from "@/lib/db";
import { sendDiscordNotifications } from "@/lib/discord";
export function bytesToGB(bytes: number): number {
return Math.round((bytes / 1024 / 1024 / 1024) * 10) / 10;
@@ -58,11 +59,13 @@ export function computeStats(
let plays: number | null = null;
let watchHours: number | null = null;
let tautulliLastSeen: number | null = null;
if (hasTautulli && tautulliMap) {
const tu = lookupTautulliUser(tautulliMap, user.email, user.displayName);
plays = tu?.plays ?? 0;
watchHours = tu ? Math.round((tu.duration / 3600) * 10) / 10 : 0;
tautulliLastSeen = tu?.last_seen ?? null;
}
return {
@@ -73,6 +76,7 @@ export function computeStats(
totalBytes,
plays,
watchHours,
tautulliLastSeen,
};
});
@@ -121,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(

View File

@@ -1,31 +1,40 @@
import { UserStat, OverseerrRequest, MediaEntry, AlertCandidate } from "@/lib/types";
import { getSettings } from "@/lib/settings";
// ─── Tunables ─────────────────────────────────────────────────────────────────
/** A movie/show must have been approved this many days ago before we alert on it */
const UNFULFILLED_MIN_AGE_DAYS = 3;
/** A movie/show must have been approved at least this many hours before we alert on it */
const UNFULFILLED_MIN_AGE_HOURS = 12;
/** A pending request must be this old before we alert on it */
const PENDING_MIN_AGE_DAYS = 7;
/** A pending request must be this many days old before we alert on it */
const PENDING_MIN_AGE_DAYS = 2;
/** User must have made their first request at least this long ago before ghost/watchrate alerts */
/** User must have made their first request at least this long ago before behavior alerts */
const USER_MIN_AGE_DAYS = 14;
/** Minimum requests before ghost/watchrate alerts */
const MIN_REQUESTS_GHOST = 5;
const MIN_REQUESTS_WATCHRATE = 10;
/** Ghost requester: how many of the user's most recent approved requests to evaluate */
const GHOST_RECENT_REQUESTS = 5;
/** Watch-rate threshold — below this fraction (plays/requests) triggers a warning */
const LOW_WATCH_RATE = 0.2;
/** Minimum declines in the lookback window to flag */
const MIN_DECLINES = 3;
const DECLINE_LOOKBACK_DAYS = 60;
/** Minimum requests before a low watch rate alert fires */
const MIN_REQUESTS_WATCHRATE = 10;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function hoursSince(iso: string): number {
return (Date.now() - new Date(iso).getTime()) / 3_600_000;
}
function daysSince(iso: string): number {
return (Date.now() - new Date(iso).getTime()) / 86_400_000;
return hoursSince(iso) / 24;
}
function formatAge(hours: number): string {
if (hours < 24) return `${Math.floor(hours)} hours`;
const days = Math.floor(hours / 24);
return days === 1 ? "1 day" : `${days} days`;
}
// ─── Generator ───────────────────────────────────────────────────────────────
@@ -38,19 +47,20 @@ 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 ──────────────────────────
// Track which content we've already flagged to avoid duplicate per-user alerts
const flaggedMovies = new Set<number>();
const flaggedShows = new Set<number>();
// Collect all unfulfilled content across all users
// Key: tmdbId/tvdbId → { entry, requestedBy: string[], oldestApproval: Date }
interface UnfilledEntry {
entry: MediaEntry;
requestedBy: string[];
oldestAge: number; // days since oldest qualifying request
requestedByIds: number[];
tmdbId?: number; // TV shows only — needed to build the Seerr URL (movies use map key)
oldestAgeHours: number;
partial?: boolean;
}
const unfilledMovies = new Map<number, UnfilledEntry>();
const unfilledShows = new Map<number, UnfilledEntry>();
@@ -59,102 +69,113 @@ export function generateAlertCandidates(
const requests = allRequests.get(user.userId) ?? [];
for (const req of requests) {
// Only look at approved requests old enough to have been expected to download
if (req.status !== 2) continue;
const age = daysSince(req.createdAt);
if (age < UNFULFILLED_MIN_AGE_DAYS) continue;
const ageHours = hoursSince(req.createdAt);
if (ageHours < UNFULFILLED_MIN_AGE_HOURS) continue;
if (req.type === "movie") {
const entry = radarrMap.get(req.media.tmdbId);
// Skip if not yet released (Radarr's isAvailable = false)
if (entry && !entry.available) continue;
const isUnfilled = !entry || entry.sizeOnDisk === 0;
if (!isUnfilled) continue;
if (entry && entry.sizeOnDisk > 0) continue;
const title =
entry?.title ?? req.media.title ?? `Movie #${req.media.tmdbId}`;
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.oldestAge = Math.max(existing.oldestAge, age);
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],
oldestAge: age,
requestedByIds: [user.userId],
oldestAgeHours: ageHours,
});
}
} else if (req.type === "tv" && req.media.tvdbId) {
const entry = sonarrMap.get(req.media.tvdbId);
// Skip if series hasn't started airing yet (Sonarr status = "upcoming")
if (entry && !entry.available) continue;
const isNothingDownloaded = !entry || entry.sizeOnDisk === 0;
// Partial: ended series with < 90% of episodes on disk
// Partial: ended series missing any episodes
const isPartiallyDownloaded =
entry !== undefined &&
entry.sizeOnDisk > 0 &&
entry.seriesStatus === "ended" &&
entry.percentOfEpisodes !== undefined &&
entry.percentOfEpisodes < 90;
const isUnfilled = isNothingDownloaded || isPartiallyDownloaded;
if (!isUnfilled) continue;
entry.episodeFileCount !== undefined &&
entry.totalEpisodeCount !== undefined &&
entry.episodeFileCount < entry.totalEpisodeCount;
const title =
entry?.title ?? req.media.title ?? `Show #${req.media.tvdbId}`;
if (!isNothingDownloaded && !isPartiallyDownloaded) continue;
const title = entry?.title ?? req.media.title ?? `Show #${req.media.tvdbId}`;
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.oldestAge = Math.max(existing.oldestAge, age);
// Upgrade to partial flag if we now know it's partial
if (partial) (existing as UnfilledEntry & { partial?: boolean }).partial = true;
existing.requestedByIds.push(user.userId);
}
existing.oldestAgeHours = Math.max(existing.oldestAgeHours, ageHours);
if (partial) existing.partial = true;
} else {
const record: UnfilledEntry & { partial?: boolean } = {
unfilledShows.set(req.media.tvdbId, {
entry: { title, sizeOnDisk: entry?.sizeOnDisk ?? 0, available: true },
requestedBy: [user.displayName],
oldestAge: age,
};
if (partial) record.partial = true;
unfilledShows.set(req.media.tvdbId, record);
requestedByIds: [user.userId],
tmdbId: req.media.tmdbId,
oldestAgeHours: ageHours,
partial,
});
}
}
}
}
for (const [tmdbId, { entry, requestedBy, oldestAge }] of unfilledMovies) {
for (const [tmdbId, { entry, requestedBy, requestedByIds, oldestAgeHours }] of unfilledMovies) {
if (flaggedMovies.has(tmdbId)) continue;
flaggedMovies.add(tmdbId);
const daysStr = Math.floor(oldestAge) === 1 ? "1 day" : `${Math.floor(oldestAge)} days`;
const byStr = requestedBy.slice(0, 3).join(", ") + (requestedBy.length > 3 ? ` +${requestedBy.length - 3}` : "");
const 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",
severity: "warning",
title: `Not Downloaded: ${entry.title}`,
description: `Approved ${daysStr} ago but no file found in Radarr. Requested by ${byStr}.`,
description: `Approved ${formatAge(oldestAgeHours)} ago but no file found in Radarr. Requested by ${byStr}.`,
mediaId: tmdbId,
mediaType: "movie",
mediaTitle: entry.title,
mediaUrl,
seerrMediaUrl,
requesterIds: requestedByIds,
});
}
for (const [tvdbId, data] of unfilledShows) {
for (const [tvdbId, { entry, requestedBy, requestedByIds, tmdbId: showTmdbId, oldestAgeHours, partial }] of unfilledShows) {
if (flaggedShows.has(tvdbId)) continue;
flaggedShows.add(tvdbId);
const { entry, requestedBy, oldestAge } = data;
const partial = (data as UnfilledEntry & { partial?: boolean }).partial ?? false;
const sonarrEntry = sonarrMap.get(tvdbId);
const daysStr = Math.floor(oldestAge) === 1 ? "1 day" : `${Math.floor(oldestAge)} days`;
const byStr = requestedBy.slice(0, 3).join(", ") + (requestedBy.length > 3 ? ` +${requestedBy.length - 3}` : "");
const pct = partial && sonarrEntry?.episodeFileCount !== undefined && sonarrEntry.totalEpisodeCount
? Math.round((sonarrEntry.episodeFileCount / sonarrEntry.totalEpisodeCount) * 100)
: null;
const description = partial && pct !== null
? `Only ${pct}% of episodes downloaded (${sonarrEntry!.episodeFileCount}/${sonarrEntry!.totalEpisodeCount}). Approved ${daysStr} ago. Requested by ${byStr}.`
: `Approved ${daysStr} ago but no files found in Sonarr. Requested by ${byStr}.`;
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 && sonarrSettings.url
? `${sonarrSettings.url}/series/${sonarrEntry.titleSlug}`
: undefined;
const seerrMediaUrl = showTmdbId && seerrSettings.url
? `${seerrSettings.url}/tv/${showTmdbId}`
: undefined;
candidates.push({
key: `unfulfilled:tv:${tvdbId}`,
category: "unfulfilled",
@@ -164,11 +185,14 @@ export function generateAlertCandidates(
mediaId: tvdbId,
mediaType: "tv",
mediaTitle: entry.title,
mediaUrl,
seerrMediaUrl,
requesterIds: requestedByIds,
});
}
// ── CONTENT-CENTRIC: stale pending requests ───────────────────────────────
// One alert per pending request item (not per user)
const flaggedPending = new Set<number>();
for (const user of userStats) {
@@ -177,18 +201,13 @@ export function generateAlertCandidates(
if (req.status !== 1) continue;
if (daysSince(req.createdAt) < PENDING_MIN_AGE_DAYS) continue;
const age = Math.floor(daysSince(req.createdAt));
const ageStr = age === 1 ? "1 day" : `${age} days`;
const ageStr = formatAge(hoursSince(req.createdAt));
if (req.type === "movie" && !flaggedPending.has(req.id)) {
// Skip if movie isn't released yet
const movieEntry = radarrMap.get(req.media.tmdbId);
if (movieEntry && !movieEntry.available) continue;
flaggedPending.add(req.id);
const title =
radarrMap.get(req.media.tmdbId)?.title ??
req.media.title ??
`Movie #${req.media.tmdbId}`;
const title = movieEntry?.title ?? req.media.title ?? `Movie #${req.media.tmdbId}`;
candidates.push({
key: `pending:req:${req.id}`,
category: "pending",
@@ -200,14 +219,14 @@ 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)) {
// Skip if show hasn't started airing yet
const showEntry = sonarrMap.get(req.media.tvdbId);
if (showEntry && !showEntry.available) continue;
flaggedPending.add(req.id);
const title =
showEntry?.title ?? req.media.title ?? `Show #${req.media.tvdbId}`;
const title = showEntry?.title ?? req.media.title ?? `Show #${req.media.tvdbId}`;
candidates.push({
key: `pending:req:${req.id}`,
category: "pending",
@@ -219,41 +238,50 @@ export function generateAlertCandidates(
mediaTitle: title,
userId: user.userId,
userName: user.displayName,
requesterIds: [user.userId],
seerrMediaUrl: seerrSettings.url ? `${seerrSettings.url}/tv/${req.media.tmdbId}` : undefined,
});
}
}
}
// ── USER-BEHAVIOR: one category per user, most severe wins ───────────────
// Ghost Requester takes priority over Low Watch Rate for the same user.
// Only generate these alerts if the user is "established" (old enough account).
// ── USER-BEHAVIOR ─────────────────────────────────────────────────────────
for (const user of userStats) {
const requests = allRequests.get(user.userId) ?? [];
if (requests.length === 0) continue;
// Check if user is established (has at least one request old enough)
const oldestRequestAge = Math.max(...requests.map((r) => daysSince(r.createdAt)));
const isEstablished = oldestRequestAge >= USER_MIN_AGE_DAYS;
if (!isEstablished) continue;
if (oldestRequestAge < USER_MIN_AGE_DAYS) continue;
// ── Ghost Requester ───────────────────────────────────────────────────
if (
hasTautulli &&
user.plays === 0 &&
user.requestCount >= MIN_REQUESTS_GHOST
) {
candidates.push({
key: `ghost:${user.userId}`,
category: "ghost",
severity: "warning",
title: `Ghost Requester`,
description: `${user.displayName} has ${user.requestCount} requests but has never watched anything on Plex.`,
userId: user.userId,
userName: user.displayName,
});
// Ghost takes priority — skip watch-rate check for this user
continue;
// Fires if the user hasn't watched anything since before their last
// GHOST_RECENT_REQUESTS approved requests were made.
if (hasTautulli) {
const approved = requests
.filter((r) => r.status === 2)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
if (approved.length >= GHOST_RECENT_REQUESTS) {
const nthRequest = approved[GHOST_RECENT_REQUESTS - 1];
const nthDate = new Date(nthRequest.createdAt).getTime();
// tautulliLastSeen is unix seconds; null means never seen
const lastSeenMs = user.tautulliLastSeen ? user.tautulliLastSeen * 1000 : null;
const hasNotWatchedSinceRequesting = lastSeenMs === null || lastSeenMs < nthDate;
if (hasNotWatchedSinceRequesting) {
candidates.push({
key: `ghost:${user.userId}`,
category: "ghost",
severity: "warning",
title: `Ghost Requester`,
description: `${user.displayName} has made ${approved.length} requests but hasn't watched anything since before their last ${GHOST_RECENT_REQUESTS} were approved.`,
userId: user.userId,
userName: user.displayName,
});
continue; // ghost takes priority over low watch rate
}
}
}
// ── Low Watch Rate ────────────────────────────────────────────────────
@@ -275,25 +303,8 @@ export function generateAlertCandidates(
userId: user.userId,
userName: user.displayName,
});
continue; // don't also check declines for same priority slot
}
}
// ── Declined Streak ───────────────────────────────────────────────────
const recentDeclines = requests.filter(
(r) => r.status === 3 && daysSince(r.createdAt) <= DECLINE_LOOKBACK_DAYS
);
if (recentDeclines.length >= MIN_DECLINES) {
candidates.push({
key: `declined:${user.userId}`,
category: "declined",
severity: "info",
title: `Frequent Declines`,
description: `${user.displayName} has had ${recentDeclines.length} requests declined in the last ${DECLINE_LOOKBACK_DAYS} days.`,
userId: user.userId,
userName: user.displayName,
});
}
}
// ── SYSTEM: Tautulli configured but no matches ────────────────────────────

View File

@@ -1,9 +1,13 @@
/**
* Lightweight JSON file store for alert persistence.
* Lives at data/alerts.json (gitignored, created on first run).
* SQLite-backed alert store using better-sqlite3.
* Lives at data/alerts.db (gitignored).
*
* Uses a global singleton so Next.js hot-reload doesn't open multiple
* connections. WAL mode is enabled for concurrent read performance.
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
import Database from "better-sqlite3";
import { mkdirSync, existsSync, readFileSync, renameSync } from "fs";
import { join } from "path";
import {
AlertCandidate,
@@ -14,230 +18,472 @@ 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 — suppresses re-opening for this long.
// Auto-resolved alerts have no cooldown and can reopen immediately if the
// condition returns.
// Cooldown days applied on MANUAL close.
// 0 = no cooldown: content alerts reopen immediately on the next refresh if
// the condition still exists. Closing is an acknowledgment, not a suppression.
// >0 = user-behavior alerts: suppress re-opening for this many days so a single
// acknowledgment isn't immediately undone by the next refresh.
const COOLDOWN: Record<string, number> = {
unfulfilled: 3,
pending: 3,
ghost: 14,
watchrate: 14,
declined: 14,
"tautulli-no-matches": 1,
"dark-library": 30,
unfulfilled: 0,
pending: 0,
ghost: 7,
watchrate: 7,
"tautulli-no-matches": 0,
};
const DEFAULT_COOLDOWN = 7;
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;
status: AlertStatus;
closeReason: AlertCloseReason | null;
suppressedUntil: string | null;
firstSeen: string;
lastSeen: string;
closedAt: string | null;
comments: Array<{ id: number; body: string; createdAt: string }>;
}
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,
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
if (existing.status === "closed") {
existing.status = "open";
existing.closeReason = null;
existing.closedAt = null;
existing.suppressedUntil = null;
existing.firstSeen = nowISO; // treat as a new occurrence
existing.comments = [];
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;
} 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,
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;
}
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 suppressUntil = new Date();
suppressUntil.setDate(suppressUntil.getDate() + cooldownDays);
const cooldownDays = COOLDOWN[row.category] ?? DEFAULT_COOLDOWN;
let suppressedUntil: string | null = null;
if (cooldownDays > 0) {
const d = new Date();
d.setDate(d.getDate() + cooldownDays);
suppressedUntil = d.toISOString();
}
alert.status = "closed";
alert.closeReason = "manual";
alert.closedAt = new Date().toISOString();
alert.suppressedUntil = suppressUntil.toISOString();
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;
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): AlertComment | null {
const store = load();
const alert = Object.values(store.alerts).find((a) => a.id === alertId);
if (!alert) return null;
const comment = {
id: store.nextCommentId++,
export function addComment(
alertId: number,
body: string,
author: "user" | "system" = "user"
): AlertComment | null {
const db = getDb();
const exists = db.prepare<[number], { id: number }>(
"SELECT id FROM alerts WHERE id = ?"
).get(alertId);
if (!exists) return null;
const nowISO = new Date().toISOString();
const info = db.prepare(`
INSERT INTO comments (alertId, body, author, createdAt)
VALUES (?, ?, ?, ?)
`).run(alertId, body, author, nowISO);
return {
id: info.lastInsertRowid as number,
body,
createdAt: new Date().toISOString(),
author,
createdAt: nowISO,
};
alert.comments.push(comment);
save(store);
return comment;
}

208
src/lib/discord.ts Normal file
View File

@@ -0,0 +1,208 @@
/**
* Discord webhook notifications.
* Fired on newly opened or reopened alerts. Batches up to 10 embeds per
* message to stay within Discord's limits.
*/
import { AlertCandidate } from "@/lib/types";
import { getSettings } from "@/lib/settings";
// Discord embed colors per severity
const SEVERITY_COLOR: Record<string, number> = {
danger: 0xef4444, // red-500
warning: 0xeab308, // yellow-500
info: 0x3b82f6, // blue-500
};
const SEVERITY_LABEL: Record<string, string> = {
danger: "Critical",
warning: "Warning",
info: "Info",
};
type EmbedField = { name: string; value: string; inline: boolean };
interface DiscordEmbed {
title: string;
description?: string;
color: number;
url?: string;
fields: EmbedField[];
footer: { text: string };
timestamp: string;
}
// ── Description parsers (mirror AlertDetail.tsx regex patterns) ───────────────
function parseUnfulfilledComplete(desc: string) {
const m = desc.match(/^Approved (.+?) ago but (.+?)\. Requested by (.+?)\.?$/);
if (!m) return null;
return { age: m[1], detail: m[2], requesters: m[3] };
}
function parseUnfulfilledPartial(desc: string) {
// "Only X% of episodes downloaded (A/B). Approved N ago. Requested by Y."
const m = desc.match(/^Only .+?\. Approved (.+?) ago\. Requested by (.+?)\.?$/);
if (!m) return null;
const eps = desc.match(/\((\d+)\/(\d+)\)/);
return {
age: m[1],
requesters: m[2],
downloaded: eps ? parseInt(eps[1]) : null,
total: eps ? parseInt(eps[2]) : null,
};
}
function parsePending(desc: string) {
const m = desc.match(/^Awaiting approval for (.+?)\. Requested by (.+?)\.?$/);
if (!m) return null;
return { age: m[1], requesters: m[2] };
}
function parseWatchrate(desc: string) {
const pctM = desc.match(/~(\d+)%/);
const playsM = desc.match(/\((\d+) plays/);
const reqM = desc.match(/plays, (\d+) requests\)/);
if (!pctM || !playsM || !reqM) return null;
return { pct: pctM[1], plays: playsM[1], requests: reqM[1] };
}
// ── Embed builder ─────────────────────────────────────────────────────────────
function buildEmbed(alert: AlertCandidate): DiscordEmbed {
const color = SEVERITY_COLOR[alert.severity] ?? SEVERITY_COLOR.info;
const footer = { text: `OverSnitch · ${SEVERITY_LABEL[alert.severity] ?? alert.severity}` };
const timestamp = new Date().toISOString();
// Title links to Seerr media page if available, otherwise *arr
const url = alert.seerrMediaUrl ?? alert.mediaUrl ?? undefined;
const fields: EmbedField[] = [];
// ── unfulfilled ────────────────────────────────────────────────────────────
if (alert.category === "unfulfilled") {
const partial = parseUnfulfilledPartial(alert.description);
if (partial) {
fields.push({ name: "Requested by", value: partial.requesters, inline: true });
fields.push({ name: "Approved", value: `${partial.age} ago`, inline: true });
if (partial.downloaded !== null && partial.total !== null) {
const pct = Math.round((partial.downloaded / partial.total) * 100);
fields.push({
name: "Downloaded",
value: `${partial.downloaded} / ${partial.total} episodes (${pct}%)`,
inline: false,
});
}
return { title: alert.title, color, url, fields, footer, timestamp };
}
const complete = parseUnfulfilledComplete(alert.description);
if (complete) {
fields.push({ name: "Requested by", value: complete.requesters, inline: true });
fields.push({ name: "Approved", value: `${complete.age} ago`, inline: true });
// Capitalise "no file found in Radarr" → "No file found in Radarr"
const detail = complete.detail.charAt(0).toUpperCase() + complete.detail.slice(1);
fields.push({ name: "Status", value: detail, inline: false });
return { title: alert.title, color, url, fields, footer, timestamp };
}
}
// ── pending ────────────────────────────────────────────────────────────────
if (alert.category === "pending") {
const p = parsePending(alert.description);
if (p) {
fields.push({ name: "Requested by", value: p.requesters, inline: true });
fields.push({ name: "Waiting", value: p.age, inline: true });
return { title: alert.title, color, url, fields, footer, timestamp };
}
}
// ── ghost ──────────────────────────────────────────────────────────────────
if (alert.category === "ghost" && alert.userName) {
fields.push({ name: "User", value: alert.userName, inline: false });
// Trim the redundant name prefix from the description
// "Peri Wright has made 8 requests but hasn't watched…"
// → "Has made 8 requests but hasn't watched…"
const desc = alert.description.replace(
new RegExp(`^${alert.userName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s+`),
""
);
const sentence = desc.charAt(0).toUpperCase() + desc.slice(1);
return { title: alert.title, description: sentence, color, url, fields, footer, timestamp };
}
// ── watchrate ──────────────────────────────────────────────────────────────
if (alert.category === "watchrate") {
const w = parseWatchrate(alert.description);
if (w && alert.userName) {
fields.push({ name: "User", value: alert.userName, inline: true });
fields.push({ name: "Watch rate", value: `~${w.pct}%`, inline: true });
fields.push({ name: "Plays", value: w.plays, inline: true });
fields.push({ name: "Requests", value: w.requests, inline: true });
return { title: alert.title, color, url, fields, footer, timestamp };
}
}
// ── fallback: plain description ────────────────────────────────────────────
return {
title: alert.title,
description: alert.description,
color,
url,
fields,
footer,
timestamp,
};
}
// ── Transport ─────────────────────────────────────────────────────────────────
async function postToWebhook(webhookUrl: string, embeds: DiscordEmbed[]): Promise<void> {
const res = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "OverSnitch", embeds }),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Discord webhook error ${res.status}: ${text}`);
}
}
/**
* Sends one Discord message per batch of up to 10 alerts.
* Silently no-ops if no webhook URL is configured.
* Errors are logged but never thrown.
*/
export async function sendDiscordNotifications(alerts: AlertCandidate[]): Promise<void> {
if (alerts.length === 0) return;
const { discord } = getSettings();
if (!discord.webhookUrl) return;
const embeds = alerts.map(buildEmbed);
// Discord allows up to 10 embeds per message
for (let i = 0; i < embeds.length; i += 10) {
try {
await postToWebhook(discord.webhookUrl, embeds.slice(i, i + 10));
} catch (err) {
console.error("[discord] Failed to send notification:", err);
}
}
}
/**
* Sends a single test embed. Used by the settings test endpoint.
*/
export async function sendDiscordTestNotification(webhookUrl: string): Promise<void> {
await postToWebhook(webhookUrl, [
{
title: "OverSnitch — Test Notification",
description: "Your Discord webhook is configured correctly. You'll receive alerts here when new issues are detected.",
color: SEVERITY_COLOR.info,
fields: [],
footer: { text: "OverSnitch" },
timestamp: new Date().toISOString(),
},
]);
}

View File

@@ -1,15 +1,17 @@
import { OverseerrUser, OverseerrRequest } from "@/lib/types";
import { getSettings } from "@/lib/settings";
const TAKE = 100;
export async function fetchAllUsers(): Promise<OverseerrUser[]> {
const { seerr } = getSettings();
const all: OverseerrUser[] = [];
let skip = 0;
while (true) {
const res = await fetch(
`${process.env.SEERR_URL}/api/v1/user?take=${TAKE}&skip=${skip}&sort=created`,
{ headers: { "X-Api-Key": process.env.SEERR_API! } }
`${seerr.url}/api/v1/user?take=${TAKE}&skip=${skip}&sort=created`,
{ headers: { "X-Api-Key": seerr.apiKey } }
);
if (!res.ok) {
@@ -27,13 +29,14 @@ export async function fetchAllUsers(): Promise<OverseerrUser[]> {
}
export async function fetchUserRequests(userId: number): Promise<OverseerrRequest[]> {
const { seerr } = getSettings();
const all: OverseerrRequest[] = [];
let skip = 0;
while (true) {
const res = await fetch(
`${process.env.SEERR_URL}/api/v1/request?requestedBy=${userId}&take=${TAKE}&skip=${skip}`,
{ headers: { "X-Api-Key": process.env.SEERR_API! } }
`${seerr.url}/api/v1/request?requestedBy=${userId}&take=${TAKE}&skip=${skip}`,
{ headers: { "X-Api-Key": seerr.apiKey } }
);
if (!res.ok) {

View File

@@ -1,8 +1,10 @@
import { RadarrMovie, MediaEntry } from "@/lib/types";
import { getSettings } from "@/lib/settings";
export async function buildRadarrMap(): Promise<Map<number, MediaEntry>> {
const res = await fetch(`${process.env.RADARR_URL}/api/v3/movie`, {
headers: { "X-Api-Key": process.env.RADARR_API! },
const { radarr } = getSettings();
const res = await fetch(`${radarr.url}/api/v3/movie`, {
headers: { "X-Api-Key": radarr.apiKey },
});
if (!res.ok) {
@@ -13,7 +15,7 @@ export async function buildRadarrMap(): Promise<Map<number, MediaEntry>> {
return new Map(
movies.map((m) => [
m.tmdbId,
{ title: m.title, sizeOnDisk: m.sizeOnDisk, available: m.isAvailable },
{ title: m.title, titleSlug: m.titleSlug, sizeOnDisk: m.sizeOnDisk, available: m.isAvailable },
])
);
}

89
src/lib/settings.ts Normal file
View File

@@ -0,0 +1,89 @@
/**
* Persistent settings store.
*
* Settings are read from data/settings.json when present, with process.env
* values used as fallbacks. This means existing .env.local setups keep working
* with no changes; the UI just provides an alternative way to configure them.
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
import { join } from "path";
const DATA_DIR = join(process.cwd(), "data");
const SETTINGS_PATH = join(DATA_DIR, "settings.json");
export interface ServiceConfig {
url: string;
apiKey: string;
}
export interface DiscordConfig {
webhookUrl: string;
}
export interface AppSettings {
radarr: ServiceConfig;
sonarr: ServiceConfig;
seerr: ServiceConfig;
tautulli: ServiceConfig;
discord: DiscordConfig;
}
interface StoredSettings {
radarr?: Partial<ServiceConfig>;
sonarr?: Partial<ServiceConfig>;
seerr?: Partial<ServiceConfig>;
tautulli?: Partial<ServiceConfig>;
discord?: Partial<DiscordConfig>;
}
function readFile(): StoredSettings {
try {
return JSON.parse(readFileSync(SETTINGS_PATH, "utf-8")) as StoredSettings;
} catch {
return {};
}
}
/** Returns the merged settings (file values override env vars). */
export function getSettings(): AppSettings {
const f = readFile();
return {
radarr: {
url: f.radarr?.url ?? process.env.RADARR_URL ?? "",
apiKey: f.radarr?.apiKey ?? process.env.RADARR_API ?? "",
},
sonarr: {
url: f.sonarr?.url ?? process.env.SONARR_URL ?? "",
apiKey: f.sonarr?.apiKey ?? process.env.SONARR_API ?? "",
},
seerr: {
url: f.seerr?.url ?? process.env.SEERR_URL ?? "",
apiKey: f.seerr?.apiKey ?? process.env.SEERR_API ?? "",
},
tautulli: {
url: f.tautulli?.url ?? process.env.TAUTULLI_URL ?? "",
apiKey: f.tautulli?.apiKey ?? process.env.TAUTULLI_API ?? "",
},
discord: {
webhookUrl: f.discord?.webhookUrl ?? process.env.DISCORD_WEBHOOK ?? "",
},
};
}
/** Saves the provided settings to disk and returns the merged result. */
export function saveSettings(settings: AppSettings): AppSettings {
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
// Strip trailing slashes from URLs for consistency
const clean: StoredSettings = {
radarr: { url: settings.radarr.url.replace(/\/+$/, ""), apiKey: settings.radarr.apiKey },
sonarr: { url: settings.sonarr.url.replace(/\/+$/, ""), apiKey: settings.sonarr.apiKey },
seerr: { url: settings.seerr.url.replace(/\/+$/, ""), apiKey: settings.seerr.apiKey },
tautulli: { url: settings.tautulli.url.replace(/\/+$/, ""), apiKey: settings.tautulli.apiKey },
discord: { webhookUrl: settings.discord.webhookUrl.trim() },
};
writeFileSync(SETTINGS_PATH, JSON.stringify(clean, null, 2), "utf-8");
return getSettings();
}

View File

@@ -1,8 +1,10 @@
import { SonarrSeries, MediaEntry } from "@/lib/types";
import { getSettings } from "@/lib/settings";
export async function buildSonarrMap(): Promise<Map<number, MediaEntry>> {
const res = await fetch(`${process.env.SONARR_URL}/api/v3/series`, {
headers: { "X-Api-Key": process.env.SONARR_API! },
const { sonarr } = getSettings();
const res = await fetch(`${sonarr.url}/api/v3/series`, {
headers: { "X-Api-Key": sonarr.apiKey },
});
if (!res.ok) {
@@ -15,6 +17,7 @@ export async function buildSonarrMap(): Promise<Map<number, MediaEntry>> {
s.tvdbId,
{
title: s.title,
titleSlug: s.titleSlug,
sizeOnDisk: s.statistics.sizeOnDisk,
// "upcoming" = series hasn't started airing yet
available: s.status !== "upcoming",

111
src/lib/statsBuilder.ts Normal file
View File

@@ -0,0 +1,111 @@
/**
* Shared stats-build logic and server-side SWR cache.
*
* Imported by both the /api/stats route handler (for on-demand fetches) and
* instrumentation.ts (for the background poller that runs independent of
* client activity).
*/
import { buildRadarrMap } from "@/lib/radarr";
import { buildSonarrMap } from "@/lib/sonarr";
import { fetchAllUsers, fetchUserRequests } from "@/lib/overseerr";
import { buildTautulliMap } from "@/lib/tautulli";
import { computeStats } from "@/lib/aggregate";
import { DashboardStats, MediaEntry, OverseerrRequest, TautulliUser } from "@/lib/types";
const BATCH_SIZE = 5;
const STALE_MS = 5 * 60 * 1000;
const POLL_INTERVAL_MS = 5 * 60 * 1000;
// ── Encapsulated cache ────────────────────────────────────────────────────────
let cache: { stats: DashboardStats; at: number } | null = null;
let refreshing = false;
/** Raw data cached alongside stats for use by the user-page API route. */
export interface RawCache {
radarrMap: Map<number, MediaEntry>;
sonarrMap: Map<number, MediaEntry>;
allRequests: Map<number, OverseerrRequest[]>;
tautulliMap: Map<string, TautulliUser> | null;
}
let rawCache: RawCache | null = null;
export function getRawCache(): RawCache | null {
return rawCache;
}
// ── Core build function ───────────────────────────────────────────────────────
async function buildStats(): Promise<DashboardStats> {
const [radarrMap, sonarrMap, users, tautulliMap] = await Promise.all([
buildRadarrMap(),
buildSonarrMap(),
fetchAllUsers(),
buildTautulliMap(),
]);
const allRequests = new Map<number, OverseerrRequest[]>();
for (let i = 0; i < users.length; i += BATCH_SIZE) {
const chunk = users.slice(i, i + BATCH_SIZE);
const results = await Promise.all(chunk.map((u) => fetchUserRequests(u.id)));
chunk.forEach((u, idx) => allRequests.set(u.id, results[idx]));
}
rawCache = { radarrMap, sonarrMap, allRequests, tautulliMap };
return computeStats(users, allRequests, radarrMap, sonarrMap, tautulliMap);
}
// ── Public API used by the route handler ─────────────────────────────────────
/**
* Returns stats, using the in-process cache.
* - force=true: always fetches fresh data and waits for it
* - force=false: returns cache immediately; if stale, kicks a background refresh
*/
export async function getStats(force = false): Promise<DashboardStats> {
if (force || !cache) {
const stats = await buildStats();
cache = { stats, at: Date.now() };
return stats;
}
const age = Date.now() - cache.at;
if (age > STALE_MS && !refreshing) {
refreshing = true;
buildStats()
.then((stats) => { cache = { stats, at: Date.now() }; })
.catch(() => {})
.finally(() => { refreshing = false; });
}
return cache.stats;
}
// ── Background poller ─────────────────────────────────────────────────────────
async function poll() {
if (refreshing) return;
refreshing = true;
try {
const stats = await buildStats();
cache = { stats, at: Date.now() };
console.log("[poller] Stats refreshed at", new Date().toISOString());
} catch (err) {
console.error("[poller] Refresh failed:", err);
} finally {
refreshing = false;
}
}
/**
* Starts the background poller. Called once from instrumentation.ts on server
* startup. Runs an initial fetch immediately, then repeats every 5 minutes.
*/
export function startBackgroundPoller() {
console.log("[poller] Starting (interval: 5 min)");
poll(); // immediate first run — no waiting for a client request
setInterval(poll, POLL_INTERVAL_MS);
}

View File

@@ -1,6 +1,8 @@
import { TautulliUser } from "@/lib/types";
import { TautulliUser, WatchDataPoint } from "@/lib/types";
import { getSettings } from "@/lib/settings";
interface TautulliRow {
user_id: number;
friendly_name: string;
email: string;
plays: number;
@@ -22,8 +24,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;
@@ -46,6 +49,7 @@ export async function buildTautulliMap(): Promise<Map<string, TautulliUser> | nu
for (const row of json.response.data.data) {
const user: TautulliUser = {
user_id: row.user_id ?? 0,
friendly_name: row.friendly_name,
email: row.email ?? "",
plays: row.plays ?? 0,
@@ -76,3 +80,71 @@ export function lookupTautulliUser(
null
);
}
interface TautulliHistoryRow {
date: number; // unix timestamp (session start)
duration: number; // seconds watched
}
interface TautulliHistoryResponse {
response: {
result: string;
data: {
recordsFiltered: number;
recordsTotal: number;
data: TautulliHistoryRow[];
};
};
}
/**
* Fetches individual session history for a Tautulli user and aggregates by day.
* Returns an empty array if Tautulli is not configured or the call fails.
*/
export async function fetchUserWatchHistory(
tautulliUserId: number
): Promise<WatchDataPoint[]> {
const { tautulli } = getSettings();
const { url, apiKey } = tautulli;
if (!url || !apiKey || !tautulliUserId) return [];
let res: Response;
try {
res = await fetch(
`${url}/api/v2?apikey=${apiKey}&cmd=get_history&user_id=${tautulliUserId}&length=10000&order_column=date&order_dir=asc`,
{ cache: "no-store" }
);
} catch {
return [];
}
if (!res.ok) return [];
let json: TautulliHistoryResponse;
try {
json = await res.json() as TautulliHistoryResponse;
} catch {
return [];
}
if (json.response?.result !== "success") return [];
const byDate = new Map<string, { plays: number; durationSeconds: number }>();
for (const row of json.response.data.data ?? []) {
if (!row.date) continue;
const date = new Date(row.date * 1000).toISOString().slice(0, 10);
const existing = byDate.get(date) ?? { plays: 0, durationSeconds: 0 };
existing.plays += 1;
existing.durationSeconds += row.duration ?? 0;
byDate.set(date, existing);
}
return Array.from(byDate.entries())
.map(([date, { plays, durationSeconds }]) => ({
date,
plays,
durationHours: Math.round((durationSeconds / 3600) * 10) / 10,
}))
.sort((a, b) => a.date.localeCompare(b.date));
}

View File

@@ -11,7 +11,7 @@ export interface OverseerrUser {
export interface OverseerrRequest {
id: number;
type: "movie" | "tv";
status: number; // 1=pending, 2=approved, 3=declined, 4=available
status: number; // media status: 1=unknown, 2=pending, 3=processing, 4=partial, 5=available
createdAt: string; // ISO timestamp
media: {
tmdbId: number;
@@ -23,6 +23,7 @@ export interface OverseerrRequest {
export interface RadarrMovie {
tmdbId: number;
title: string;
titleSlug: string;
sizeOnDisk: number; // bytes
isAvailable: boolean; // false = unreleased / below minimum availability
}
@@ -30,6 +31,7 @@ export interface RadarrMovie {
export interface SonarrSeries {
tvdbId: number;
title: string;
titleSlug: string;
status: string; // "continuing" | "ended" | "upcoming" | "deleted"
statistics: {
sizeOnDisk: number; // bytes
@@ -40,6 +42,7 @@ export interface SonarrSeries {
}
export interface TautulliUser {
user_id: number;
friendly_name: string;
email: string;
plays: number;
@@ -51,6 +54,7 @@ export interface TautulliUser {
export interface MediaEntry {
title: string;
titleSlug?: string;
sizeOnDisk: number; // bytes
available: boolean; // false = unreleased, skip unfulfilled alerts
// TV-specific (undefined for movies)
@@ -73,6 +77,7 @@ export interface UserStat {
// Tautulli (null when not configured)
plays: number | null;
watchHours: number | null;
tautulliLastSeen: number | null; // unix timestamp (seconds), null if no Tautulli data
// Per-metric ranks (1 = top user for that metric, null = Tautulli not available)
storageRank: number;
requestRank: number;
@@ -111,16 +116,49 @@ export interface AlertCandidate {
mediaId?: number;
mediaType?: "movie" | "tv";
mediaTitle?: string;
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 {
id: number;
body: string;
createdAt: string;
author: "user" | "system";
}
export type AlertCloseReason = "manual" | "resolved";
// ─── User page ────────────────────────────────────────────────────────────────
/** A single Overseerr request enriched with resolved media title and size */
export interface EnrichedRequest {
id: number;
type: "movie" | "tv";
status: number; // media status: 1=unknown, 2=pending, 3=processing, 4=partial, 5=available
createdAt: string;
mediaId: number; // tmdbId for movies, tvdbId for TV
title: string;
sizeOnDisk: number; // bytes
sizeGB: number;
}
/** One day of watch activity from Tautulli */
export interface WatchDataPoint {
date: string; // YYYY-MM-DD
plays: number;
durationHours: number;
}
export interface UserPageData {
stat: UserStat;
enrichedRequests: EnrichedRequest[];
watchHistory: WatchDataPoint[]; // daily, sorted ascending; empty if Tautulli not configured
openAlerts: Alert[];
}
/** Full persisted alert returned by the API */
export interface Alert extends AlertCandidate {
id: number; // auto-increment DB id (used in URLs)