Compare commits
10 Commits
f871f86284
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b2c1642065 | |||
| 641a7fd096 | |||
| 2374bad7ba | |||
| d0bd17ed7e | |||
| 4b2c82cf90 | |||
| c86b8ff33a | |||
| bf83c1a779 | |||
| 6fa246d3c4 | |||
| 8fe61cdeb8 | |||
| a8a03b59d5 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -42,3 +42,10 @@ next-env.d.ts
|
|||||||
|
|
||||||
# alert persistence
|
# alert persistence
|
||||||
/data/alerts.json
|
/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
227
README.md
@@ -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
|
```bash
|
||||||
npm run dev
|
git clone https://gitea.thewrightserver.net/josh/OverSnitch.git
|
||||||
# or
|
cd OverSnitch
|
||||||
yarn dev
|
npm install
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
**Option B — Environment variables**
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
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
858
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,12 +8,15 @@
|
|||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.8.0",
|
||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"recharts": "^3.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useRef, useEffect, type ReactNode } from "react";
|
||||||
import Link from "next/link";
|
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> = {
|
const severityAccent: Record<AlertSeverity, string> = {
|
||||||
danger: "border-l-red-500",
|
danger: "border-l-red-500",
|
||||||
@@ -10,6 +11,18 @@ const severityAccent: Record<AlertSeverity, string> = {
|
|||||||
info: "border-l-blue-500",
|
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 {
|
function timeAgo(iso: string): string {
|
||||||
const diff = Date.now() - new Date(iso).getTime();
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
const mins = Math.floor(diff / 60_000);
|
const mins = Math.floor(diff / 60_000);
|
||||||
@@ -20,18 +33,277 @@ function timeAgo(iso: string): string {
|
|||||||
return `${Math.floor(hrs / 24)}d ago`;
|
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 {
|
function shortDate(iso: string): string {
|
||||||
return new Date(iso).toLocaleDateString(undefined, {
|
return new Date(iso).toLocaleDateString(undefined, {
|
||||||
month: "short", day: "numeric", hour: "numeric", minute: "2-digit",
|
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: <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 [alert, setAlert] = useState<Alert>(initialAlert);
|
||||||
const [actionLoading, setActionLoading] = useState(false);
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
const [commentText, setCommentText] = useState("");
|
const [commentText, setCommentText] = useState("");
|
||||||
const [commentLoading, setCommentLoading] = useState(false);
|
const [commentLoading, setCommentLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [alert.comments.length]);
|
||||||
|
|
||||||
async function toggleStatus() {
|
async function toggleStatus() {
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
@@ -44,7 +316,18 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
|
|||||||
body: JSON.stringify({ status: newStatus }),
|
body: JSON.stringify({ status: newStatus }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
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) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -76,20 +359,47 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
|
|||||||
|
|
||||||
const isOpen = alert.status === "open";
|
const isOpen = alert.status === "open";
|
||||||
const isResolved = alert.closeReason === "resolved";
|
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 (
|
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
|
{/* Back */}
|
||||||
href="/?tab=alerts"
|
<button
|
||||||
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors"
|
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}>
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
All Alerts
|
All Alerts
|
||||||
</Link>
|
</button>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-red-800 bg-red-950/30 px-4 py-3 text-red-300 text-sm">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Alert card */}
|
{/* ── Alert overview ──────────────────────────────────────────────── */}
|
||||||
<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]}`}>
|
<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 */}
|
{/* Top row: severity + status | action buttons */}
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<span className={`inline-flex items-center gap-1.5 text-xs font-semibold ${
|
{/* Left: severity + status + timestamps */}
|
||||||
isOpen ? "text-green-400" : isResolved ? "text-teal-400" : "text-slate-500"
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
}`}>
|
<span className={`text-xs font-bold uppercase tracking-widest ${severityText[alert.severity]}`}>
|
||||||
{isOpen && <span className="h-1.5 w-1.5 rounded-full bg-green-400 shrink-0" />}
|
{severityLabel[alert.severity]}
|
||||||
{isOpen ? "Open" : isResolved ? "Auto-resolved" : "Closed"}
|
</span>
|
||||||
<span className="text-slate-700 font-normal">·</span>
|
<span className="text-slate-700">·</span>
|
||||||
<span className="text-slate-600 font-normal">{timeAgo(statusTime)}</span>
|
<span className={`inline-flex items-center gap-1.5 text-xs font-semibold ${
|
||||||
</span>
|
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
|
{/* Right: action buttons */}
|
||||||
onClick={toggleStatus}
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
disabled={actionLoading}
|
{/* Search fallback (not yet in *arr) */}
|
||||||
className={`rounded-lg px-4 py-1.5 text-xs font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
|
{searchUrl && (
|
||||||
isOpen
|
<a
|
||||||
? "bg-slate-700 hover:bg-slate-600 text-white"
|
href={searchUrl}
|
||||||
: "bg-green-900/50 hover:bg-green-800/50 text-green-300 border border-green-800"
|
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"
|
||||||
{actionLoading ? "…" : isOpen ? "Close" : "Reopen"}
|
>
|
||||||
</button>
|
{alert.mediaType === "movie" ? "Search in Radarr" : "Search in Sonarr"}
|
||||||
</div>
|
<ExternalIcon />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Title + description */}
|
{/* Primary: view in Radarr/Sonarr */}
|
||||||
<div className="space-y-1.5">
|
{alert.mediaUrl && (
|
||||||
<h1 className="text-lg font-bold text-white leading-snug">{alert.title}</h1>
|
<a
|
||||||
<p className="text-sm text-slate-400 leading-relaxed">{alert.description}</p>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes */}
|
{/* ── Comments ────────────────────────────────────────────────────── */}
|
||||||
<section className="space-y-3 pt-1">
|
<section className="space-y-4">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-600">Notes</h2>
|
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-600">
|
||||||
|
Comments
|
||||||
|
</h2>
|
||||||
|
|
||||||
{alert.comments.length === 0 && (
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-slate-700">No notes yet.</p>
|
{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) => (
|
<form onSubmit={submitComment} className="flex flex-col gap-2 pt-2 border-t border-slate-800">
|
||||||
<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">
|
|
||||||
<textarea
|
<textarea
|
||||||
value={commentText}
|
value={commentText}
|
||||||
onChange={(e) => setCommentText(e.target.value)}
|
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}
|
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"
|
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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={commentLoading || !commentText.trim()}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getAlertById } from "@/lib/db";
|
import { getAlertById } from "@/lib/db";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import AlertDetail from "./AlertDetail";
|
import AlertDetail from "./AlertDetail";
|
||||||
|
|
||||||
@@ -10,5 +11,13 @@ export default async function AlertPage({
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const alert = getAlertById(Number(id));
|
const alert = getAlertById(Number(id));
|
||||||
if (!alert) notFound();
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/app/api/settings/route.ts
Normal file
16
src/app/api/settings/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/app/api/settings/test/route.ts
Normal file
121
src/app/api/settings/test/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,63 +1,9 @@
|
|||||||
import { buildRadarrMap } from "@/lib/radarr";
|
import { getStats } from "@/lib/statsBuilder";
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const force = new URL(req.url).searchParams.has("force");
|
const force = new URL(req.url).searchParams.has("force");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Force (Refresh button) or cold start: wait for fresh data
|
return Response.json(await getStats(force));
|
||||||
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);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
return Response.json({ error: message }, { status: 500 });
|
return Response.json({ error: message }, { status: 500 });
|
||||||
|
|||||||
92
src/app/api/users/[id]/route.ts
Normal file
92
src/app/api/users/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef, Suspense } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
|
||||||
import { DashboardStats } from "@/lib/types";
|
import { DashboardStats } from "@/lib/types";
|
||||||
import SummaryCards from "@/components/SummaryCards";
|
import SummaryCards from "@/components/SummaryCards";
|
||||||
import LeaderboardTable from "@/components/LeaderboardTable";
|
import LeaderboardTable from "@/components/LeaderboardTable";
|
||||||
import AlertsPanel from "@/components/AlertsPanel";
|
import AlertsPanel from "@/components/AlertsPanel";
|
||||||
import RefreshButton from "@/components/RefreshButton";
|
import RefreshButton from "@/components/RefreshButton";
|
||||||
|
import SettingsModal from "@/components/SettingsModal";
|
||||||
|
|
||||||
type Tab = "leaderboard" | "alerts";
|
type Tab = "leaderboard" | "alerts";
|
||||||
const LS_KEY = "oversnitch_stats";
|
const LS_KEY = "oversnitch_stats";
|
||||||
@@ -21,15 +21,13 @@ function timeAgo(iso: string): string {
|
|||||||
return new Date(iso).toLocaleDateString();
|
return new Date(iso).toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function DashboardContent() {
|
export default function Page() {
|
||||||
const searchParams = useSearchParams();
|
const [tab, setTab] = useState<Tab>("leaderboard");
|
||||||
const router = useRouter();
|
|
||||||
const tab = (searchParams.get("tab") ?? "leaderboard") as Tab;
|
|
||||||
|
|
||||||
const [data, setData] = useState<DashboardStats | null>(null);
|
const [data, setData] = useState<DashboardStats | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const didInit = useRef(false);
|
const didInit = useRef(false);
|
||||||
|
|
||||||
const load = useCallback(async (force = false) => {
|
const load = useCallback(async (force = false) => {
|
||||||
@@ -67,11 +65,12 @@ function DashboardContent() {
|
|||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
function setTab(t: Tab) {
|
// Poll every 2 minutes to keep the UI fresh against the server cache.
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
// The server itself refreshes every 5 min via the background poller.
|
||||||
params.set("tab", t);
|
useEffect(() => {
|
||||||
router.push(`?${params.toString()}`, { scroll: false });
|
const id = setInterval(() => load(), 2 * 60 * 1000);
|
||||||
}
|
return () => clearInterval(id);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
const hasTautulli = data?.summary.totalWatchHours !== null;
|
const hasTautulli = data?.summary.totalWatchHours !== null;
|
||||||
const openAlertCount = data?.summary.openAlertCount ?? 0;
|
const openAlertCount = data?.summary.openAlertCount ?? 0;
|
||||||
@@ -79,6 +78,7 @@ function DashboardContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-4 py-8 space-y-6">
|
<main className="mx-auto max-w-6xl px-4 py-8 space-y-6">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -89,7 +89,20 @@ function DashboardContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
<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 && (
|
{generatedAt && (
|
||||||
<span className="text-xs text-slate-600">
|
<span className="text-xs text-slate-600">
|
||||||
{refreshing
|
{refreshing
|
||||||
@@ -146,7 +159,7 @@ function DashboardContent() {
|
|||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTab(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
|
tab === t
|
||||||
? "border-yellow-400 text-white"
|
? "border-yellow-400 text-white"
|
||||||
: "border-transparent text-slate-500 hover:text-slate-300"
|
: "border-transparent text-slate-500 hover:text-slate-300"
|
||||||
@@ -175,14 +188,12 @@ function DashboardContent() {
|
|||||||
{tab === "alerts" && <AlertsPanel />}
|
{tab === "alerts" && <AlertsPanel />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SettingsModal
|
||||||
|
open={settingsOpen}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
onSaved={() => load(true)}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<Suspense>
|
|
||||||
<DashboardContent />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
614
src/app/users/[id]/UserDetail.tsx
Normal file
614
src/app/users/[id]/UserDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/app/users/[id]/page.tsx
Normal file
10
src/app/users/[id]/page.tsx
Normal 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)} />;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { UserStat } from "@/lib/types";
|
import { UserStat } from "@/lib/types";
|
||||||
|
|
||||||
type SortKey = "totalBytes" | "requestCount" | "avgGB" | "plays" | "watchHours";
|
type SortKey = "totalBytes" | "requestCount" | "avgGB" | "plays" | "watchHours";
|
||||||
@@ -124,8 +125,15 @@ export default function LeaderboardTable({
|
|||||||
|
|
||||||
{/* User */}
|
{/* User */}
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<div className="font-medium text-white leading-snug">{user.displayName}</div>
|
<Link
|
||||||
<div className="text-xs text-slate-600 mt-0.5">{user.email}</div>
|
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>
|
</td>
|
||||||
|
|
||||||
{/* Requests */}
|
{/* Requests */}
|
||||||
|
|||||||
428
src/components/SettingsModal.tsx
Normal file
428
src/components/SettingsModal.tsx
Normal 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
14
src/instrumentation.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { lookupTautulliUser } from "@/lib/tautulli";
|
import { lookupTautulliUser } from "@/lib/tautulli";
|
||||||
import { generateAlertCandidates } from "@/lib/alerts";
|
import { generateAlertCandidates } from "@/lib/alerts";
|
||||||
import { upsertAlerts } from "@/lib/db";
|
import { upsertAlerts } from "@/lib/db";
|
||||||
|
import { sendDiscordNotifications } from "@/lib/discord";
|
||||||
|
|
||||||
export function bytesToGB(bytes: number): number {
|
export function bytesToGB(bytes: number): number {
|
||||||
return Math.round((bytes / 1024 / 1024 / 1024) * 10) / 10;
|
return Math.round((bytes / 1024 / 1024 / 1024) * 10) / 10;
|
||||||
@@ -58,11 +59,13 @@ export function computeStats(
|
|||||||
|
|
||||||
let plays: number | null = null;
|
let plays: number | null = null;
|
||||||
let watchHours: number | null = null;
|
let watchHours: number | null = null;
|
||||||
|
let tautulliLastSeen: number | null = null;
|
||||||
|
|
||||||
if (hasTautulli && tautulliMap) {
|
if (hasTautulli && tautulliMap) {
|
||||||
const tu = lookupTautulliUser(tautulliMap, user.email, user.displayName);
|
const tu = lookupTautulliUser(tautulliMap, user.email, user.displayName);
|
||||||
plays = tu?.plays ?? 0;
|
plays = tu?.plays ?? 0;
|
||||||
watchHours = tu ? Math.round((tu.duration / 3600) * 10) / 10 : 0;
|
watchHours = tu ? Math.round((tu.duration / 3600) * 10) / 10 : 0;
|
||||||
|
tautulliLastSeen = tu?.last_seen ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -73,6 +76,7 @@ export function computeStats(
|
|||||||
totalBytes,
|
totalBytes,
|
||||||
plays,
|
plays,
|
||||||
watchHours,
|
watchHours,
|
||||||
|
tautulliLastSeen,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,7 +125,10 @@ export function computeStats(
|
|||||||
sonarrMap,
|
sonarrMap,
|
||||||
hasTautulli
|
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 totalRequests = userStats.reduce((s, u) => s + u.requestCount, 0);
|
||||||
const totalStorageGB = bytesToGB(
|
const totalStorageGB = bytesToGB(
|
||||||
|
|||||||
@@ -1,31 +1,40 @@
|
|||||||
import { UserStat, OverseerrRequest, MediaEntry, AlertCandidate } from "@/lib/types";
|
import { UserStat, OverseerrRequest, MediaEntry, AlertCandidate } from "@/lib/types";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
|
||||||
// ─── Tunables ─────────────────────────────────────────────────────────────────
|
// ─── Tunables ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** A movie/show must have been approved this many days ago before we alert on it */
|
/** A movie/show must have been approved at least this many hours before we alert on it */
|
||||||
const UNFULFILLED_MIN_AGE_DAYS = 3;
|
const UNFULFILLED_MIN_AGE_HOURS = 12;
|
||||||
|
|
||||||
/** A pending request must be this old before we alert on it */
|
/** A pending request must be this many days old before we alert on it */
|
||||||
const PENDING_MIN_AGE_DAYS = 7;
|
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;
|
const USER_MIN_AGE_DAYS = 14;
|
||||||
|
|
||||||
/** Minimum requests before ghost/watchrate alerts */
|
/** Ghost requester: how many of the user's most recent approved requests to evaluate */
|
||||||
const MIN_REQUESTS_GHOST = 5;
|
const GHOST_RECENT_REQUESTS = 5;
|
||||||
const MIN_REQUESTS_WATCHRATE = 10;
|
|
||||||
|
|
||||||
/** Watch-rate threshold — below this fraction (plays/requests) triggers a warning */
|
/** Watch-rate threshold — below this fraction (plays/requests) triggers a warning */
|
||||||
const LOW_WATCH_RATE = 0.2;
|
const LOW_WATCH_RATE = 0.2;
|
||||||
|
|
||||||
/** Minimum declines in the lookback window to flag */
|
/** Minimum requests before a low watch rate alert fires */
|
||||||
const MIN_DECLINES = 3;
|
const MIN_REQUESTS_WATCHRATE = 10;
|
||||||
const DECLINE_LOOKBACK_DAYS = 60;
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function hoursSince(iso: string): number {
|
||||||
|
return (Date.now() - new Date(iso).getTime()) / 3_600_000;
|
||||||
|
}
|
||||||
|
|
||||||
function daysSince(iso: string): number {
|
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 ───────────────────────────────────────────────────────────────
|
// ─── Generator ───────────────────────────────────────────────────────────────
|
||||||
@@ -38,19 +47,20 @@ export function generateAlertCandidates(
|
|||||||
hasTautulli: boolean
|
hasTautulli: boolean
|
||||||
): AlertCandidate[] {
|
): AlertCandidate[] {
|
||||||
const candidates: AlertCandidate[] = [];
|
const candidates: AlertCandidate[] = [];
|
||||||
|
const { radarr: radarrSettings, sonarr: sonarrSettings, seerr: seerrSettings } = getSettings();
|
||||||
|
|
||||||
// ── CONTENT-CENTRIC: one alert per piece of media ──────────────────────────
|
// ── 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 flaggedMovies = new Set<number>();
|
||||||
const flaggedShows = 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 {
|
interface UnfilledEntry {
|
||||||
entry: MediaEntry;
|
entry: MediaEntry;
|
||||||
requestedBy: string[];
|
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 unfilledMovies = new Map<number, UnfilledEntry>();
|
||||||
const unfilledShows = new Map<number, UnfilledEntry>();
|
const unfilledShows = new Map<number, UnfilledEntry>();
|
||||||
@@ -59,102 +69,113 @@ export function generateAlertCandidates(
|
|||||||
const requests = allRequests.get(user.userId) ?? [];
|
const requests = allRequests.get(user.userId) ?? [];
|
||||||
|
|
||||||
for (const req of requests) {
|
for (const req of requests) {
|
||||||
// Only look at approved requests old enough to have been expected to download
|
|
||||||
if (req.status !== 2) continue;
|
if (req.status !== 2) continue;
|
||||||
const age = daysSince(req.createdAt);
|
const ageHours = hoursSince(req.createdAt);
|
||||||
if (age < UNFULFILLED_MIN_AGE_DAYS) continue;
|
if (ageHours < UNFULFILLED_MIN_AGE_HOURS) continue;
|
||||||
|
|
||||||
if (req.type === "movie") {
|
if (req.type === "movie") {
|
||||||
const entry = radarrMap.get(req.media.tmdbId);
|
const entry = radarrMap.get(req.media.tmdbId);
|
||||||
// Skip if not yet released (Radarr's isAvailable = false)
|
|
||||||
if (entry && !entry.available) continue;
|
if (entry && !entry.available) continue;
|
||||||
const isUnfilled = !entry || entry.sizeOnDisk === 0;
|
if (entry && entry.sizeOnDisk > 0) continue;
|
||||||
if (!isUnfilled) continue;
|
|
||||||
|
|
||||||
const title =
|
const title = entry?.title ?? req.media.title ?? `Movie #${req.media.tmdbId}`;
|
||||||
entry?.title ?? req.media.title ?? `Movie #${req.media.tmdbId}`;
|
|
||||||
const existing = unfilledMovies.get(req.media.tmdbId);
|
const existing = unfilledMovies.get(req.media.tmdbId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!existing.requestedBy.includes(user.displayName))
|
if (!existing.requestedBy.includes(user.displayName)) {
|
||||||
existing.requestedBy.push(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 {
|
} else {
|
||||||
unfilledMovies.set(req.media.tmdbId, {
|
unfilledMovies.set(req.media.tmdbId, {
|
||||||
entry: { title, sizeOnDisk: 0, available: true },
|
entry: { title, sizeOnDisk: 0, available: true },
|
||||||
requestedBy: [user.displayName],
|
requestedBy: [user.displayName],
|
||||||
oldestAge: age,
|
requestedByIds: [user.userId],
|
||||||
|
oldestAgeHours: ageHours,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (req.type === "tv" && req.media.tvdbId) {
|
} else if (req.type === "tv" && req.media.tvdbId) {
|
||||||
const entry = sonarrMap.get(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;
|
if (entry && !entry.available) continue;
|
||||||
|
|
||||||
const isNothingDownloaded = !entry || entry.sizeOnDisk === 0;
|
const isNothingDownloaded = !entry || entry.sizeOnDisk === 0;
|
||||||
// Partial: ended series with < 90% of episodes on disk
|
// Partial: ended series missing any episodes
|
||||||
const isPartiallyDownloaded =
|
const isPartiallyDownloaded =
|
||||||
entry !== undefined &&
|
entry !== undefined &&
|
||||||
entry.sizeOnDisk > 0 &&
|
entry.sizeOnDisk > 0 &&
|
||||||
entry.seriesStatus === "ended" &&
|
entry.seriesStatus === "ended" &&
|
||||||
entry.percentOfEpisodes !== undefined &&
|
entry.episodeFileCount !== undefined &&
|
||||||
entry.percentOfEpisodes < 90;
|
entry.totalEpisodeCount !== undefined &&
|
||||||
const isUnfilled = isNothingDownloaded || isPartiallyDownloaded;
|
entry.episodeFileCount < entry.totalEpisodeCount;
|
||||||
if (!isUnfilled) continue;
|
|
||||||
|
|
||||||
const title =
|
if (!isNothingDownloaded && !isPartiallyDownloaded) continue;
|
||||||
entry?.title ?? req.media.title ?? `Show #${req.media.tvdbId}`;
|
|
||||||
|
const title = entry?.title ?? req.media.title ?? `Show #${req.media.tvdbId}`;
|
||||||
const partial = !isNothingDownloaded && isPartiallyDownloaded;
|
const partial = !isNothingDownloaded && isPartiallyDownloaded;
|
||||||
const existing = unfilledShows.get(req.media.tvdbId);
|
const existing = unfilledShows.get(req.media.tvdbId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!existing.requestedBy.includes(user.displayName))
|
if (!existing.requestedBy.includes(user.displayName)) {
|
||||||
existing.requestedBy.push(user.displayName);
|
existing.requestedBy.push(user.displayName);
|
||||||
existing.oldestAge = Math.max(existing.oldestAge, age);
|
existing.requestedByIds.push(user.userId);
|
||||||
// Upgrade to partial flag if we now know it's partial
|
}
|
||||||
if (partial) (existing as UnfilledEntry & { partial?: boolean }).partial = true;
|
existing.oldestAgeHours = Math.max(existing.oldestAgeHours, ageHours);
|
||||||
|
if (partial) existing.partial = true;
|
||||||
} else {
|
} else {
|
||||||
const record: UnfilledEntry & { partial?: boolean } = {
|
unfilledShows.set(req.media.tvdbId, {
|
||||||
entry: { title, sizeOnDisk: entry?.sizeOnDisk ?? 0, available: true },
|
entry: { title, sizeOnDisk: entry?.sizeOnDisk ?? 0, available: true },
|
||||||
requestedBy: [user.displayName],
|
requestedBy: [user.displayName],
|
||||||
oldestAge: age,
|
requestedByIds: [user.userId],
|
||||||
};
|
tmdbId: req.media.tmdbId,
|
||||||
if (partial) record.partial = true;
|
oldestAgeHours: ageHours,
|
||||||
unfilledShows.set(req.media.tvdbId, record);
|
partial,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [tmdbId, { entry, requestedBy, oldestAge }] of unfilledMovies) {
|
for (const [tmdbId, { entry, requestedBy, requestedByIds, oldestAgeHours }] of unfilledMovies) {
|
||||||
if (flaggedMovies.has(tmdbId)) continue;
|
if (flaggedMovies.has(tmdbId)) continue;
|
||||||
flaggedMovies.add(tmdbId);
|
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 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({
|
candidates.push({
|
||||||
key: `unfulfilled:movie:${tmdbId}`,
|
key: `unfulfilled:movie:${tmdbId}`,
|
||||||
category: "unfulfilled",
|
category: "unfulfilled",
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
title: `Not Downloaded: ${entry.title}`,
|
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,
|
mediaId: tmdbId,
|
||||||
mediaType: "movie",
|
mediaType: "movie",
|
||||||
mediaTitle: entry.title,
|
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;
|
if (flaggedShows.has(tvdbId)) continue;
|
||||||
flaggedShows.add(tvdbId);
|
flaggedShows.add(tvdbId);
|
||||||
const { entry, requestedBy, oldestAge } = data;
|
|
||||||
const partial = (data as UnfilledEntry & { partial?: boolean }).partial ?? false;
|
|
||||||
const sonarrEntry = sonarrMap.get(tvdbId);
|
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 byStr = requestedBy.slice(0, 3).join(", ") + (requestedBy.length > 3 ? ` +${requestedBy.length - 3}` : "");
|
||||||
const pct = partial && sonarrEntry?.episodeFileCount !== undefined && sonarrEntry.totalEpisodeCount
|
const pct = partial && sonarrEntry?.episodeFileCount !== undefined && sonarrEntry.totalEpisodeCount
|
||||||
? Math.round((sonarrEntry.episodeFileCount / sonarrEntry.totalEpisodeCount) * 100)
|
? Math.round((sonarrEntry.episodeFileCount / sonarrEntry.totalEpisodeCount) * 100)
|
||||||
: null;
|
: null;
|
||||||
const description = partial && pct !== null
|
const description = pct !== null
|
||||||
? `Only ${pct}% of episodes downloaded (${sonarrEntry!.episodeFileCount}/${sonarrEntry!.totalEpisodeCount}). Approved ${daysStr} ago. Requested by ${byStr}.`
|
? `Only ${pct}% of episodes downloaded (${sonarrEntry!.episodeFileCount}/${sonarrEntry!.totalEpisodeCount}). Approved ${formatAge(oldestAgeHours)} ago. Requested by ${byStr}.`
|
||||||
: `Approved ${daysStr} ago but no files found in Sonarr. 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({
|
candidates.push({
|
||||||
key: `unfulfilled:tv:${tvdbId}`,
|
key: `unfulfilled:tv:${tvdbId}`,
|
||||||
category: "unfulfilled",
|
category: "unfulfilled",
|
||||||
@@ -164,11 +185,14 @@ export function generateAlertCandidates(
|
|||||||
mediaId: tvdbId,
|
mediaId: tvdbId,
|
||||||
mediaType: "tv",
|
mediaType: "tv",
|
||||||
mediaTitle: entry.title,
|
mediaTitle: entry.title,
|
||||||
|
mediaUrl,
|
||||||
|
seerrMediaUrl,
|
||||||
|
requesterIds: requestedByIds,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CONTENT-CENTRIC: stale pending requests ───────────────────────────────
|
// ── CONTENT-CENTRIC: stale pending requests ───────────────────────────────
|
||||||
// One alert per pending request item (not per user)
|
|
||||||
const flaggedPending = new Set<number>();
|
const flaggedPending = new Set<number>();
|
||||||
|
|
||||||
for (const user of userStats) {
|
for (const user of userStats) {
|
||||||
@@ -177,18 +201,13 @@ export function generateAlertCandidates(
|
|||||||
if (req.status !== 1) continue;
|
if (req.status !== 1) continue;
|
||||||
if (daysSince(req.createdAt) < PENDING_MIN_AGE_DAYS) continue;
|
if (daysSince(req.createdAt) < PENDING_MIN_AGE_DAYS) continue;
|
||||||
|
|
||||||
const age = Math.floor(daysSince(req.createdAt));
|
const ageStr = formatAge(hoursSince(req.createdAt));
|
||||||
const ageStr = age === 1 ? "1 day" : `${age} days`;
|
|
||||||
|
|
||||||
if (req.type === "movie" && !flaggedPending.has(req.id)) {
|
if (req.type === "movie" && !flaggedPending.has(req.id)) {
|
||||||
// Skip if movie isn't released yet
|
|
||||||
const movieEntry = radarrMap.get(req.media.tmdbId);
|
const movieEntry = radarrMap.get(req.media.tmdbId);
|
||||||
if (movieEntry && !movieEntry.available) continue;
|
if (movieEntry && !movieEntry.available) continue;
|
||||||
flaggedPending.add(req.id);
|
flaggedPending.add(req.id);
|
||||||
const title =
|
const title = movieEntry?.title ?? req.media.title ?? `Movie #${req.media.tmdbId}`;
|
||||||
radarrMap.get(req.media.tmdbId)?.title ??
|
|
||||||
req.media.title ??
|
|
||||||
`Movie #${req.media.tmdbId}`;
|
|
||||||
candidates.push({
|
candidates.push({
|
||||||
key: `pending:req:${req.id}`,
|
key: `pending:req:${req.id}`,
|
||||||
category: "pending",
|
category: "pending",
|
||||||
@@ -200,14 +219,14 @@ export function generateAlertCandidates(
|
|||||||
mediaTitle: title,
|
mediaTitle: title,
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
userName: user.displayName,
|
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)) {
|
} 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);
|
const showEntry = sonarrMap.get(req.media.tvdbId);
|
||||||
if (showEntry && !showEntry.available) continue;
|
if (showEntry && !showEntry.available) continue;
|
||||||
flaggedPending.add(req.id);
|
flaggedPending.add(req.id);
|
||||||
const title =
|
const title = showEntry?.title ?? req.media.title ?? `Show #${req.media.tvdbId}`;
|
||||||
showEntry?.title ?? req.media.title ?? `Show #${req.media.tvdbId}`;
|
|
||||||
candidates.push({
|
candidates.push({
|
||||||
key: `pending:req:${req.id}`,
|
key: `pending:req:${req.id}`,
|
||||||
category: "pending",
|
category: "pending",
|
||||||
@@ -219,41 +238,50 @@ export function generateAlertCandidates(
|
|||||||
mediaTitle: title,
|
mediaTitle: title,
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
userName: user.displayName,
|
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 ───────────────
|
// ── USER-BEHAVIOR ─────────────────────────────────────────────────────────
|
||||||
// Ghost Requester takes priority over Low Watch Rate for the same user.
|
|
||||||
// Only generate these alerts if the user is "established" (old enough account).
|
|
||||||
|
|
||||||
for (const user of userStats) {
|
for (const user of userStats) {
|
||||||
const requests = allRequests.get(user.userId) ?? [];
|
const requests = allRequests.get(user.userId) ?? [];
|
||||||
if (requests.length === 0) continue;
|
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 oldestRequestAge = Math.max(...requests.map((r) => daysSince(r.createdAt)));
|
||||||
const isEstablished = oldestRequestAge >= USER_MIN_AGE_DAYS;
|
if (oldestRequestAge < USER_MIN_AGE_DAYS) continue;
|
||||||
if (!isEstablished) continue;
|
|
||||||
|
|
||||||
// ── Ghost Requester ───────────────────────────────────────────────────
|
// ── Ghost Requester ───────────────────────────────────────────────────
|
||||||
if (
|
// Fires if the user hasn't watched anything since before their last
|
||||||
hasTautulli &&
|
// GHOST_RECENT_REQUESTS approved requests were made.
|
||||||
user.plays === 0 &&
|
if (hasTautulli) {
|
||||||
user.requestCount >= MIN_REQUESTS_GHOST
|
const approved = requests
|
||||||
) {
|
.filter((r) => r.status === 2)
|
||||||
candidates.push({
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
key: `ghost:${user.userId}`,
|
|
||||||
category: "ghost",
|
if (approved.length >= GHOST_RECENT_REQUESTS) {
|
||||||
severity: "warning",
|
const nthRequest = approved[GHOST_RECENT_REQUESTS - 1];
|
||||||
title: `Ghost Requester`,
|
const nthDate = new Date(nthRequest.createdAt).getTime();
|
||||||
description: `${user.displayName} has ${user.requestCount} requests but has never watched anything on Plex.`,
|
// tautulliLastSeen is unix seconds; null means never seen
|
||||||
userId: user.userId,
|
const lastSeenMs = user.tautulliLastSeen ? user.tautulliLastSeen * 1000 : null;
|
||||||
userName: user.displayName,
|
const hasNotWatchedSinceRequesting = lastSeenMs === null || lastSeenMs < nthDate;
|
||||||
});
|
|
||||||
// Ghost takes priority — skip watch-rate check for this user
|
if (hasNotWatchedSinceRequesting) {
|
||||||
continue;
|
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 ────────────────────────────────────────────────────
|
// ── Low Watch Rate ────────────────────────────────────────────────────
|
||||||
@@ -275,25 +303,8 @@ export function generateAlertCandidates(
|
|||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
userName: user.displayName,
|
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 ────────────────────────────
|
// ── SYSTEM: Tautulli configured but no matches ────────────────────────────
|
||||||
|
|||||||
602
src/lib/db.ts
602
src/lib/db.ts
@@ -1,9 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Lightweight JSON file store for alert persistence.
|
* SQLite-backed alert store using better-sqlite3.
|
||||||
* Lives at data/alerts.json (gitignored, created on first run).
|
* 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 { join } from "path";
|
||||||
import {
|
import {
|
||||||
AlertCandidate,
|
AlertCandidate,
|
||||||
@@ -14,230 +18,472 @@ import {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const DATA_DIR = join(process.cwd(), "data");
|
const DATA_DIR = join(process.cwd(), "data");
|
||||||
const DB_PATH = join(DATA_DIR, "alerts.json");
|
const DB_PATH = join(DATA_DIR, "alerts.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.
|
// Cooldown days applied on MANUAL close.
|
||||||
// Auto-resolved alerts have no cooldown and can reopen immediately if the
|
// 0 = no cooldown: content alerts reopen immediately on the next refresh if
|
||||||
// condition returns.
|
// the condition still exists. Closing is an acknowledgment, not a suppression.
|
||||||
|
// >0 = user-behavior alerts: suppress re-opening for this many days so a single
|
||||||
|
// acknowledgment isn't immediately undone by the next refresh.
|
||||||
const COOLDOWN: Record<string, number> = {
|
const COOLDOWN: Record<string, number> = {
|
||||||
unfulfilled: 3,
|
unfulfilled: 0,
|
||||||
pending: 3,
|
pending: 0,
|
||||||
ghost: 14,
|
ghost: 7,
|
||||||
watchrate: 14,
|
watchrate: 7,
|
||||||
declined: 14,
|
"tautulli-no-matches": 0,
|
||||||
"tautulli-no-matches": 1,
|
|
||||||
"dark-library": 30,
|
|
||||||
};
|
};
|
||||||
const DEFAULT_COOLDOWN = 7;
|
const DEFAULT_COOLDOWN = 0;
|
||||||
|
|
||||||
interface Store {
|
// ── Singleton ──────────────────────────────────────────────────────────────────
|
||||||
nextId: number;
|
|
||||||
nextCommentId: number;
|
declare global {
|
||||||
alerts: Record<string, StoredAlert>; // keyed by alert.key
|
// eslint-disable-next-line no-var
|
||||||
|
var __alertsDb: Database.Database | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StoredAlert {
|
function getDb(): Database.Database {
|
||||||
id: number;
|
if (global.__alertsDb) return global.__alertsDb;
|
||||||
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 load(): Store {
|
|
||||||
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
||||||
if (!existsSync(DB_PATH)) {
|
|
||||||
const empty: Store = { nextId: 1, nextCommentId: 1, alerts: {} };
|
const db = new Database(DB_PATH);
|
||||||
writeFileSync(DB_PATH, JSON.stringify(empty, null, 2));
|
db.pragma("journal_mode = WAL");
|
||||||
return empty;
|
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) {
|
// ── Row → Alert ────────────────────────────────────────────────────────────────
|
||||||
writeFileSync(DB_PATH, JSON.stringify(store, null, 2));
|
|
||||||
|
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 {
|
return {
|
||||||
id: s.id,
|
id: row.id,
|
||||||
key: s.key,
|
key: row.key,
|
||||||
category: s.category,
|
category: row.category,
|
||||||
severity: s.severity as Alert["severity"],
|
severity: row.severity as Alert["severity"],
|
||||||
title: s.title,
|
title: row.title,
|
||||||
description: s.description,
|
description: row.description,
|
||||||
userId: s.userId,
|
userId: row.userId ?? undefined,
|
||||||
userName: s.userName,
|
userName: row.userName ?? undefined,
|
||||||
mediaId: s.mediaId,
|
mediaId: row.mediaId ?? undefined,
|
||||||
mediaType: s.mediaType as Alert["mediaType"],
|
mediaType: row.mediaType as Alert["mediaType"],
|
||||||
mediaTitle: s.mediaTitle,
|
mediaTitle: row.mediaTitle ?? undefined,
|
||||||
status: s.status,
|
mediaUrl: row.mediaUrl ?? undefined,
|
||||||
closeReason: s.closeReason ?? null,
|
requesterIds: row.requesterIds ? (JSON.parse(row.requesterIds) as number[]) : undefined,
|
||||||
suppressedUntil: s.suppressedUntil,
|
seerrMediaUrl: row.seerrMediaUrl ?? undefined,
|
||||||
firstSeen: s.firstSeen,
|
status: row.status as AlertStatus,
|
||||||
lastSeen: s.lastSeen,
|
closeReason: row.closeReason as AlertCloseReason | null,
|
||||||
closedAt: s.closedAt,
|
suppressedUntil: row.suppressedUntil,
|
||||||
comments: s.comments,
|
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
|
* 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).
|
* whose condition is no longer present (key not in this run's candidate set).
|
||||||
*
|
*
|
||||||
* Auto-resolved alerts:
|
* Returns the count of open alerts after the merge and the list of newly
|
||||||
* - Are marked closed with closeReason = "resolved"
|
* created or reopened alert candidates (for Discord notifications etc.).
|
||||||
* - 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.
|
|
||||||
*/
|
*/
|
||||||
export function upsertAlerts(candidates: AlertCandidate[]): number {
|
export function upsertAlerts(candidates: AlertCandidate[]): UpsertResult {
|
||||||
const store = load();
|
const db = getDb();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const nowISO = now.toISOString();
|
const nowISO = now.toISOString();
|
||||||
const candidateKeys = new Set(candidates.map((c) => c.key));
|
const candidateKeys = new Set(candidates.map((c) => c.key));
|
||||||
|
|
||||||
// ── Step 1: upsert candidates ─────────────────────────────────────────────
|
const getByKey = db.prepare<[string], AlertRow>(
|
||||||
for (const c of candidates) {
|
"SELECT * FROM alerts WHERE key = ?"
|
||||||
const existing = store.alerts[c.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 newAlerts: AlertCandidate[] = [];
|
||||||
const isSuppressed =
|
|
||||||
existing.status === "closed" &&
|
|
||||||
existing.suppressedUntil !== null &&
|
|
||||||
new Date(existing.suppressedUntil) > now;
|
|
||||||
|
|
||||||
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) {
|
||||||
if (existing.status === "closed") {
|
const isSuppressed =
|
||||||
existing.status = "open";
|
existing.status === "closed" &&
|
||||||
existing.closeReason = null;
|
existing.suppressedUntil !== null &&
|
||||||
existing.closedAt = null;
|
new Date(existing.suppressedUntil) > now;
|
||||||
existing.suppressedUntil = null;
|
|
||||||
existing.firstSeen = nowISO; // treat as a new occurrence
|
if (isSuppressed) continue;
|
||||||
existing.comments = [];
|
|
||||||
|
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 ───────────────────
|
// ── Step 2: auto-resolve alerts whose condition is gone ─────────────────
|
||||||
for (const alert of Object.values(store.alerts)) {
|
const openAlerts = getOpenAlerts.all();
|
||||||
if (alert.status !== "open") continue;
|
for (const a of openAlerts) {
|
||||||
if (candidateKeys.has(alert.key)) continue;
|
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
|
const { n } = db.prepare(
|
||||||
alert.status = "closed";
|
"SELECT COUNT(*) as n FROM alerts WHERE status = 'open'"
|
||||||
alert.closeReason = "resolved";
|
).get() as { n: number };
|
||||||
alert.closedAt = nowISO;
|
return { openCount: n, newAlerts };
|
||||||
alert.suppressedUntil = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
save(store);
|
|
||||||
|
|
||||||
return Object.values(store.alerts).filter((a) => a.status === "open").length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllAlerts(): Alert[] {
|
export function getAllAlerts(): Alert[] {
|
||||||
const store = load();
|
const db = getDb();
|
||||||
return Object.values(store.alerts)
|
const rows = db.prepare<[], AlertRow>(`
|
||||||
.sort((a, b) => {
|
SELECT * FROM alerts
|
||||||
if (a.status !== b.status) return a.status === "open" ? -1 : 1;
|
ORDER BY
|
||||||
return new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime();
|
CASE status WHEN 'open' THEN 0 ELSE 1 END ASC,
|
||||||
})
|
lastSeen DESC
|
||||||
.map(toAlert);
|
`).all();
|
||||||
|
|
||||||
|
return rows.map((row) => rowToAlert(row, getCommentsForAlert(db, row.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAlertById(id: number): Alert | null {
|
export function getAlertById(id: number): Alert | null {
|
||||||
const store = load();
|
const db = getDb();
|
||||||
const found = Object.values(store.alerts).find((a) => a.id === id);
|
const row = db.prepare<[number], AlertRow>(
|
||||||
return found ? toAlert(found) : null;
|
"SELECT * FROM alerts WHERE id = ?"
|
||||||
|
).get(id);
|
||||||
|
if (!row) return null;
|
||||||
|
return rowToAlert(row, getCommentsForAlert(db, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeAlert(id: number): Alert | null {
|
export function closeAlert(id: number): Alert | null {
|
||||||
const store = load();
|
const db = getDb();
|
||||||
const alert = Object.values(store.alerts).find((a) => a.id === id);
|
const row = db.prepare<[number], AlertRow>(
|
||||||
if (!alert) return null;
|
"SELECT * FROM alerts WHERE id = ?"
|
||||||
|
).get(id);
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
const cooldownDays = COOLDOWN[alert.category] ?? DEFAULT_COOLDOWN;
|
const cooldownDays = COOLDOWN[row.category] ?? DEFAULT_COOLDOWN;
|
||||||
const suppressUntil = new Date();
|
let suppressedUntil: string | null = null;
|
||||||
suppressUntil.setDate(suppressUntil.getDate() + cooldownDays);
|
if (cooldownDays > 0) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + cooldownDays);
|
||||||
|
suppressedUntil = d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
alert.status = "closed";
|
const nowISO = new Date().toISOString();
|
||||||
alert.closeReason = "manual";
|
|
||||||
alert.closedAt = new Date().toISOString();
|
db.transaction(() => {
|
||||||
alert.suppressedUntil = suppressUntil.toISOString();
|
db.prepare(`
|
||||||
save(store);
|
UPDATE alerts SET status = 'closed', closeReason = 'manual',
|
||||||
return toAlert(alert);
|
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 {
|
export function reopenAlert(id: number): Alert | null {
|
||||||
const store = load();
|
const db = getDb();
|
||||||
const alert = Object.values(store.alerts).find((a) => a.id === id);
|
const row = db.prepare<[number], AlertRow>(
|
||||||
if (!alert) return null;
|
"SELECT * FROM alerts WHERE id = ?"
|
||||||
alert.status = "open";
|
).get(id);
|
||||||
alert.closeReason = null;
|
if (!row) return null;
|
||||||
alert.closedAt = null;
|
|
||||||
alert.suppressedUntil = null;
|
const nowISO = new Date().toISOString();
|
||||||
save(store);
|
|
||||||
return toAlert(alert);
|
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 {
|
export function addComment(
|
||||||
const store = load();
|
alertId: number,
|
||||||
const alert = Object.values(store.alerts).find((a) => a.id === alertId);
|
body: string,
|
||||||
if (!alert) return null;
|
author: "user" | "system" = "user"
|
||||||
const comment = {
|
): AlertComment | null {
|
||||||
id: store.nextCommentId++,
|
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,
|
body,
|
||||||
createdAt: new Date().toISOString(),
|
author,
|
||||||
|
createdAt: nowISO,
|
||||||
};
|
};
|
||||||
alert.comments.push(comment);
|
|
||||||
save(store);
|
|
||||||
return comment;
|
|
||||||
}
|
}
|
||||||
|
|||||||
208
src/lib/discord.ts
Normal file
208
src/lib/discord.ts
Normal 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(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import { OverseerrUser, OverseerrRequest } from "@/lib/types";
|
import { OverseerrUser, OverseerrRequest } from "@/lib/types";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
|
||||||
const TAKE = 100;
|
const TAKE = 100;
|
||||||
|
|
||||||
export async function fetchAllUsers(): Promise<OverseerrUser[]> {
|
export async function fetchAllUsers(): Promise<OverseerrUser[]> {
|
||||||
|
const { seerr } = getSettings();
|
||||||
const all: OverseerrUser[] = [];
|
const all: OverseerrUser[] = [];
|
||||||
let skip = 0;
|
let skip = 0;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${process.env.SEERR_URL}/api/v1/user?take=${TAKE}&skip=${skip}&sort=created`,
|
`${seerr.url}/api/v1/user?take=${TAKE}&skip=${skip}&sort=created`,
|
||||||
{ headers: { "X-Api-Key": process.env.SEERR_API! } }
|
{ headers: { "X-Api-Key": seerr.apiKey } }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -27,13 +29,14 @@ export async function fetchAllUsers(): Promise<OverseerrUser[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchUserRequests(userId: number): Promise<OverseerrRequest[]> {
|
export async function fetchUserRequests(userId: number): Promise<OverseerrRequest[]> {
|
||||||
|
const { seerr } = getSettings();
|
||||||
const all: OverseerrRequest[] = [];
|
const all: OverseerrRequest[] = [];
|
||||||
let skip = 0;
|
let skip = 0;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${process.env.SEERR_URL}/api/v1/request?requestedBy=${userId}&take=${TAKE}&skip=${skip}`,
|
`${seerr.url}/api/v1/request?requestedBy=${userId}&take=${TAKE}&skip=${skip}`,
|
||||||
{ headers: { "X-Api-Key": process.env.SEERR_API! } }
|
{ headers: { "X-Api-Key": seerr.apiKey } }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { RadarrMovie, MediaEntry } from "@/lib/types";
|
import { RadarrMovie, MediaEntry } from "@/lib/types";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
|
||||||
export async function buildRadarrMap(): Promise<Map<number, MediaEntry>> {
|
export async function buildRadarrMap(): Promise<Map<number, MediaEntry>> {
|
||||||
const res = await fetch(`${process.env.RADARR_URL}/api/v3/movie`, {
|
const { radarr } = getSettings();
|
||||||
headers: { "X-Api-Key": process.env.RADARR_API! },
|
const res = await fetch(`${radarr.url}/api/v3/movie`, {
|
||||||
|
headers: { "X-Api-Key": radarr.apiKey },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -13,7 +15,7 @@ export async function buildRadarrMap(): Promise<Map<number, MediaEntry>> {
|
|||||||
return new Map(
|
return new Map(
|
||||||
movies.map((m) => [
|
movies.map((m) => [
|
||||||
m.tmdbId,
|
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
89
src/lib/settings.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { SonarrSeries, MediaEntry } from "@/lib/types";
|
import { SonarrSeries, MediaEntry } from "@/lib/types";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
|
||||||
export async function buildSonarrMap(): Promise<Map<number, MediaEntry>> {
|
export async function buildSonarrMap(): Promise<Map<number, MediaEntry>> {
|
||||||
const res = await fetch(`${process.env.SONARR_URL}/api/v3/series`, {
|
const { sonarr } = getSettings();
|
||||||
headers: { "X-Api-Key": process.env.SONARR_API! },
|
const res = await fetch(`${sonarr.url}/api/v3/series`, {
|
||||||
|
headers: { "X-Api-Key": sonarr.apiKey },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -15,6 +17,7 @@ export async function buildSonarrMap(): Promise<Map<number, MediaEntry>> {
|
|||||||
s.tvdbId,
|
s.tvdbId,
|
||||||
{
|
{
|
||||||
title: s.title,
|
title: s.title,
|
||||||
|
titleSlug: s.titleSlug,
|
||||||
sizeOnDisk: s.statistics.sizeOnDisk,
|
sizeOnDisk: s.statistics.sizeOnDisk,
|
||||||
// "upcoming" = series hasn't started airing yet
|
// "upcoming" = series hasn't started airing yet
|
||||||
available: s.status !== "upcoming",
|
available: s.status !== "upcoming",
|
||||||
|
|||||||
111
src/lib/statsBuilder.ts
Normal file
111
src/lib/statsBuilder.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { TautulliUser } from "@/lib/types";
|
import { TautulliUser, WatchDataPoint } from "@/lib/types";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
|
||||||
interface TautulliRow {
|
interface TautulliRow {
|
||||||
|
user_id: number;
|
||||||
friendly_name: string;
|
friendly_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
plays: number;
|
plays: number;
|
||||||
@@ -22,8 +24,9 @@ interface TautulliResponse {
|
|||||||
* Returns null if TAUTULLI_URL/TAUTULLI_API are not set.
|
* Returns null if TAUTULLI_URL/TAUTULLI_API are not set.
|
||||||
*/
|
*/
|
||||||
export async function buildTautulliMap(): Promise<Map<string, TautulliUser> | null> {
|
export async function buildTautulliMap(): Promise<Map<string, TautulliUser> | null> {
|
||||||
const url = process.env.TAUTULLI_URL;
|
const { tautulli } = getSettings();
|
||||||
const key = process.env.TAUTULLI_API;
|
const url = tautulli.url;
|
||||||
|
const key = tautulli.apiKey;
|
||||||
|
|
||||||
if (!url || !key) return null;
|
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) {
|
for (const row of json.response.data.data) {
|
||||||
const user: TautulliUser = {
|
const user: TautulliUser = {
|
||||||
|
user_id: row.user_id ?? 0,
|
||||||
friendly_name: row.friendly_name,
|
friendly_name: row.friendly_name,
|
||||||
email: row.email ?? "",
|
email: row.email ?? "",
|
||||||
plays: row.plays ?? 0,
|
plays: row.plays ?? 0,
|
||||||
@@ -76,3 +80,71 @@ export function lookupTautulliUser(
|
|||||||
null
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface OverseerrUser {
|
|||||||
export interface OverseerrRequest {
|
export interface OverseerrRequest {
|
||||||
id: number;
|
id: number;
|
||||||
type: "movie" | "tv";
|
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
|
createdAt: string; // ISO timestamp
|
||||||
media: {
|
media: {
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
@@ -23,6 +23,7 @@ export interface OverseerrRequest {
|
|||||||
export interface RadarrMovie {
|
export interface RadarrMovie {
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
titleSlug: string;
|
||||||
sizeOnDisk: number; // bytes
|
sizeOnDisk: number; // bytes
|
||||||
isAvailable: boolean; // false = unreleased / below minimum availability
|
isAvailable: boolean; // false = unreleased / below minimum availability
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,7 @@ export interface RadarrMovie {
|
|||||||
export interface SonarrSeries {
|
export interface SonarrSeries {
|
||||||
tvdbId: number;
|
tvdbId: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
titleSlug: string;
|
||||||
status: string; // "continuing" | "ended" | "upcoming" | "deleted"
|
status: string; // "continuing" | "ended" | "upcoming" | "deleted"
|
||||||
statistics: {
|
statistics: {
|
||||||
sizeOnDisk: number; // bytes
|
sizeOnDisk: number; // bytes
|
||||||
@@ -40,6 +42,7 @@ export interface SonarrSeries {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TautulliUser {
|
export interface TautulliUser {
|
||||||
|
user_id: number;
|
||||||
friendly_name: string;
|
friendly_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
plays: number;
|
plays: number;
|
||||||
@@ -51,6 +54,7 @@ export interface TautulliUser {
|
|||||||
|
|
||||||
export interface MediaEntry {
|
export interface MediaEntry {
|
||||||
title: string;
|
title: string;
|
||||||
|
titleSlug?: string;
|
||||||
sizeOnDisk: number; // bytes
|
sizeOnDisk: number; // bytes
|
||||||
available: boolean; // false = unreleased, skip unfulfilled alerts
|
available: boolean; // false = unreleased, skip unfulfilled alerts
|
||||||
// TV-specific (undefined for movies)
|
// TV-specific (undefined for movies)
|
||||||
@@ -73,6 +77,7 @@ export interface UserStat {
|
|||||||
// Tautulli (null when not configured)
|
// Tautulli (null when not configured)
|
||||||
plays: number | null;
|
plays: number | null;
|
||||||
watchHours: 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)
|
// Per-metric ranks (1 = top user for that metric, null = Tautulli not available)
|
||||||
storageRank: number;
|
storageRank: number;
|
||||||
requestRank: number;
|
requestRank: number;
|
||||||
@@ -111,16 +116,49 @@ export interface AlertCandidate {
|
|||||||
mediaId?: number;
|
mediaId?: number;
|
||||||
mediaType?: "movie" | "tv";
|
mediaType?: "movie" | "tv";
|
||||||
mediaTitle?: string;
|
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 {
|
export interface AlertComment {
|
||||||
id: number;
|
id: number;
|
||||||
body: string;
|
body: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
author: "user" | "system";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AlertCloseReason = "manual" | "resolved";
|
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 */
|
/** Full persisted alert returned by the API */
|
||||||
export interface Alert extends AlertCandidate {
|
export interface Alert extends AlertCandidate {
|
||||||
id: number; // auto-increment DB id (used in URLs)
|
id: number; // auto-increment DB id (used in URLs)
|
||||||
|
|||||||
Reference in New Issue
Block a user