Add settings UI, Discord notifications, and alert detail improvements

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 14:57:07 -04:00
parent 2374bad7ba
commit 641a7fd096
20 changed files with 2191 additions and 302 deletions

7
.gitignore vendored
View File

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

View File

@@ -10,6 +10,8 @@ Built with Next.js 16, TypeScript, and Tailwind CSS.
- **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
- **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
---
@@ -24,9 +26,21 @@ cd OverSnitch
npm install
```
### 2. Configure environment
### 2. Configure
Create `.env.local` in the project root:
**Option A — Settings UI (recommended)**
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**.
```bash
npm run dev # or: npm run build && npm start
```
Settings are written to `data/settings.json` (gitignored).
**Option B — Environment variables**
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.
```env
# Required
@@ -43,22 +57,38 @@ SONARR_API=your_sonarr_api_key
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
```
### 3. Run
---
```bash
npm run dev # development
npm run build && npm start # production
```
## 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.json` (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.
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

445
package-lock.json generated
View File

@@ -8,12 +8,14 @@
"name": "oversnitch",
"version": "0.1.0",
"dependencies": {
"better-sqlite3": "^12.8.0",
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -1046,6 +1048,16 @@
"tailwindcss": "4.2.2"
}
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
@@ -1076,6 +1088,26 @@
"@types/react": "^19.2.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.17",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz",
@@ -1088,6 +1120,64 @@
"node": ">=6.0.0"
}
},
"node_modules/better-sqlite3": {
"version": "12.8.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
"integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001787",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
@@ -1108,6 +1198,12 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -1121,16 +1217,48 @@
"dev": true,
"license": "MIT"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.20.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
@@ -1145,6 +1273,33 @@
"node": ">=10.13.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -1152,6 +1307,38 @@
"dev": true,
"license": "ISC"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -1445,6 +1632,33 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -1463,6 +1677,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/next": {
"version": "16.2.3",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.3.tgz",
@@ -1544,6 +1764,27 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/node-abi": {
"version": "3.89.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1579,6 +1820,58 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -1600,6 +1893,40 @@
"react": "^19.2.4"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -1611,7 +1938,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
@@ -1664,6 +1990,51 @@
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1673,6 +2044,24 @@
"node": ">=0.10.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -1717,12 +2106,52 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1743,6 +2172,18 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}

View File

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

View File

@@ -1,7 +1,8 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, type ReactNode } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Alert, AlertSeverity, AlertComment } from "@/lib/types";
const severityAccent: Record<AlertSeverity, string> = {
@@ -45,7 +46,7 @@ function shortDate(iso: string): string {
});
}
// ── Meta chip ─────────────────────────────────────────────────────────────────
// ── Chip ──────────────────────────────────────────────────────────────────────
function Chip({ label, dim }: { label: string; dim?: boolean }) {
return (
@@ -59,6 +60,170 @@ function Chip({ label, dim }: { label: string; dim?: boolean }) {
);
}
// ── External link icon ────────────────────────────────────────────────────────
function ExternalIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3 w-3 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
);
}
// ── Episode progress bar ──────────────────────────────────────────────────────
function EpisodeBar({ downloaded, total }: { downloaded: number; total: number }) {
const pct = Math.round((downloaded / total) * 100);
return (
<div className="space-y-1.5 pt-1">
<div className="flex justify-between items-center">
<span className="text-xs font-medium text-slate-400">Episodes downloaded</span>
<span className="text-xs tabular-nums text-slate-500">{downloaded} / {total} ({pct}%)</span>
</div>
<div className="h-1.5 w-full rounded-full bg-slate-700 overflow-hidden">
<div
className="h-full rounded-full bg-yellow-500/80 transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
// ── Structured description for content alerts ────────────────────────────────
interface DescRow { label: string; value?: string; chips?: ReactNode }
function DescriptionTable({ rows }: { rows: DescRow[] }) {
return (
<div className="space-y-2.5">
{rows.map(({ label, value, chips }) => (
<div key={label} className="flex gap-3 text-sm">
<span className="w-28 shrink-0 text-slate-600">{label}</span>
{chips ?? <span className="text-slate-300">{value}</span>}
</div>
))}
</div>
);
}
interface ContentDescriptionProps {
description: string;
category: string;
requesterIds?: number[];
seerrUrl?: string;
}
function RequesterChips({
names,
requesterIds,
seerrUrl,
}: {
names: string[];
requesterIds?: number[];
seerrUrl?: string;
}) {
return (
<span>
{names.map((name, i) => {
const uid = requesterIds?.[i];
const href = uid && seerrUrl ? `${seerrUrl}/users/${uid}` : null;
return (
<span key={i}>
{i > 0 && <span className="text-slate-600">, </span>}
{href ? (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-slate-300 hover:text-white transition-colors"
>
{name}
</a>
) : (
<span className="text-slate-300">{name}</span>
)}
</span>
);
})}
</span>
);
}
function ContentDescription({ description, category, requesterIds, seerrUrl }: ContentDescriptionProps) {
if (category === "unfulfilled") {
// Partial TV: "Only X% of episodes downloaded (A/B). Approved N ago. Requested by Y."
const partial = description.match(/^Only .+?\. Approved (.+?) ago\. Requested by (.+?)\.?$/);
if (partial) {
const [, age, reqStr] = partial;
const names = reqStr.split(", ").filter(Boolean);
return (
<DescriptionTable rows={[
{ label: "Requested by", chips: <RequesterChips names={names} requesterIds={requesterIds} seerrUrl={seerrUrl} /> },
{ label: "Approved", value: `${age} ago` },
]} />
);
}
// Complete miss: "Approved N ago but no file found in Radarr/Sonarr. Requested by Y."
const complete = description.match(/^Approved (.+?) ago but (.+?)\. Requested by (.+?)\.?$/);
if (complete) {
const [, age, detail, reqStr] = complete;
const names = reqStr.split(", ").filter(Boolean);
return (
<DescriptionTable rows={[
{ label: "Requested by", chips: <RequesterChips names={names} requesterIds={requesterIds} seerrUrl={seerrUrl} /> },
{ label: "Approved", value: `${age} ago` },
{ label: "Details", value: detail.charAt(0).toUpperCase() + detail.slice(1) },
]} />
);
}
}
if (category === "pending") {
// "Awaiting approval for N days. Requested by Y."
const m = description.match(/^Awaiting approval for (.+?)\. Requested by (.+?)\.?$/);
if (m) {
const [, age, reqStr] = m;
const names = reqStr.split(", ").filter(Boolean);
return (
<DescriptionTable rows={[
{ label: "Requested by", chips: <RequesterChips names={names} requesterIds={requesterIds} seerrUrl={seerrUrl} /> },
{ label: "Waiting", value: age },
]} />
);
}
}
// Fallback: plain prose (ghost, watchrate handled separately, tautulli-no-matches)
return <p className="text-sm text-slate-400 leading-relaxed">{description}</p>;
}
// ── Watch rate stat block ─────────────────────────────────────────────────────
function WatchrateBlock({ plays, requests, pct }: { plays: number; requests: number; pct: number }) {
return (
<div className="rounded-lg border border-slate-700/60 bg-slate-900/50 px-4 py-3">
<div className="flex items-end gap-4 flex-wrap">
<div>
<div className="text-xl font-bold tabular-nums text-white">{plays.toLocaleString()}</div>
<div className="text-xs text-slate-500 mt-0.5">plays</div>
</div>
<div className="text-slate-700 pb-4 text-base">/</div>
<div>
<div className="text-xl font-bold tabular-nums text-white">{requests}</div>
<div className="text-xs text-slate-500 mt-0.5">requests</div>
</div>
<div className="text-slate-700 pb-4 text-base">=</div>
<div>
<div className="text-xl font-bold tabular-nums text-blue-400">{pct}%</div>
<div className="text-xs text-slate-500 mt-0.5">watch rate</div>
</div>
</div>
<p className="mt-2 text-xs text-slate-600">Alert threshold: &lt;20%</p>
</div>
);
}
// ── Comment row ───────────────────────────────────────────────────────────────
function CommentRow({ comment }: { comment: AlertComment }) {
@@ -70,7 +235,7 @@ function CommentRow({ comment }: { comment: AlertComment }) {
<div className="h-px flex-1 bg-slate-800" />
<div className="flex items-center gap-1.5 text-xs text-slate-600 shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-3 w-3">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.43l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
<span className="italic">{comment.body}</span>
@@ -102,9 +267,33 @@ function CommentRow({ comment }: { comment: AlertComment }) {
);
}
// ── Helpers: parse structured data from description prose ─────────────────────
function parseEpisodeCounts(desc: string): { downloaded: number; total: number } | null {
const m = desc.match(/\((\d+)\/(\d+)\)/);
if (!m) return null;
return { downloaded: parseInt(m[1]), total: parseInt(m[2]) };
}
function parseWatchrateStats(desc: string): { plays: number; requests: number; pct: number } | null {
const pctM = desc.match(/~(\d+)%/);
const playsM = desc.match(/\((\d+) plays/);
const reqM = desc.match(/plays, (\d+) requests\)/);
if (!pctM || !playsM || !reqM) return null;
return { pct: parseInt(pctM[1]), plays: parseInt(playsM[1]), requests: parseInt(reqM[1]) };
}
// ── Main ──────────────────────────────────────────────────────────────────────
export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
interface Props {
initialAlert: Alert;
radarrUrl?: string;
sonarrUrl?: string;
seerrUrl?: string;
}
export default function AlertDetail({ initialAlert, radarrUrl, sonarrUrl, seerrUrl }: Props) {
const router = useRouter();
const [alert, setAlert] = useState<Alert>(initialAlert);
const [actionLoading, setActionLoading] = useState(false);
const [commentText, setCommentText] = useState("");
@@ -130,8 +319,6 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
const updated: Alert = await res.json();
setAlert(updated);
// Keep the cached dashboard count in sync so the badge and summary
// card reflect the change immediately when navigating back.
try {
const raw = localStorage.getItem("oversnitch_stats");
if (raw) {
@@ -172,21 +359,47 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
const isOpen = alert.status === "open";
const isResolved = alert.closeReason === "resolved";
const statusTime = isOpen ? alert.firstSeen : (alert.closedAt ?? alert.firstSeen);
// ── Derived data ────────────────────────────────────────────────────────────
// Search fallback when media exists in description but not yet in *arr
const searchUrl =
!alert.mediaUrl && alert.mediaTitle
? alert.mediaType === "movie" && radarrUrl
? `${radarrUrl}/add/new?term=${encodeURIComponent(alert.mediaTitle)}`
: alert.mediaType === "tv" && sonarrUrl
? `${sonarrUrl}/add/new?term=${encodeURIComponent(alert.mediaTitle)}`
: null
: null;
// User dashboard link for behavior alerts
const userLink =
(alert.category === "ghost" || alert.category === "watchrate") && alert.userId
? `/?tab=leaderboard`
: null;
// Category-specific parsed data
const episodeCounts =
alert.category === "unfulfilled" && alert.mediaType === "tv"
? parseEpisodeCounts(alert.description)
: null;
const watchrateStats =
alert.category === "watchrate" ? parseWatchrateStats(alert.description) : null;
return (
<main className="mx-auto max-w-5xl px-6 py-8 space-y-6">
{/* Back */}
<Link
href="/?tab=alerts"
<button
onClick={() => router.back()}
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
All Alerts
</Link>
</button>
{error && (
<div className="rounded-lg border border-red-800 bg-red-950/30 px-4 py-3 text-red-300 text-sm">
@@ -196,10 +409,11 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
{/* ── Alert overview ──────────────────────────────────────────────── */}
<div className={`rounded-xl bg-slate-800/40 border border-slate-700/60 border-l-4 overflow-hidden ${severityAccent[alert.severity]}`}>
<div className="px-6 py-6 space-y-5">
<div className="px-6 py-6 space-y-4">
{/* Top row: status + action */}
<div className="flex items-center justify-between gap-4 flex-wrap">
{/* Top row: severity + status | action buttons */}
<div className="flex items-center justify-between gap-3 flex-wrap">
{/* Left: severity + status + timestamps */}
<div className="flex items-center gap-2.5 flex-wrap">
<span className={`text-xs font-bold uppercase tracking-widest ${severityText[alert.severity]}`}>
{severityLabel[alert.severity]}
@@ -212,57 +426,123 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
{isOpen ? "Open" : isResolved ? "Auto-resolved" : "Closed"}
</span>
<span className="text-slate-700">·</span>
<span className="text-xs text-slate-600">{timeAgo(statusTime)}</span>
<span className="text-xs text-slate-500">Opened {timeAgo(alert.firstSeen)}</span>
<span className="text-slate-700">·</span>
<span className="text-xs text-slate-500">Checked {timeAgo(alert.lastSeen)}</span>
</div>
<button
onClick={toggleStatus}
disabled={actionLoading}
className={`rounded-lg px-4 py-1.5 text-xs font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
isOpen
? "bg-slate-700 hover:bg-slate-600 text-white"
: "bg-green-900/50 hover:bg-green-800/50 text-green-300 border border-green-800"
}`}
>
{actionLoading ? "…" : isOpen ? "Close" : "Reopen"}
</button>
</div>
{/* Right: action buttons */}
<div className="flex items-center gap-2 flex-wrap">
{/* Search fallback (not yet in *arr) */}
{searchUrl && (
<a
href={searchUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 px-3 py-1.5 text-xs font-semibold text-slate-400 hover:text-white transition-colors"
>
{alert.mediaType === "movie" ? "Search in Radarr" : "Search in Sonarr"}
<ExternalIcon />
</a>
)}
{/* Title + description */}
<div className="space-y-2">
<h1 className="text-2xl font-bold text-white leading-snug">{alert.title}</h1>
<p className="text-sm text-slate-400 leading-relaxed">{alert.description}</p>
</div>
{/* Primary: view in Radarr/Sonarr */}
{alert.mediaUrl && (
<a
href={alert.mediaUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-600 bg-slate-700/60 hover:bg-slate-700 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:text-white transition-colors"
>
{alert.mediaType === "movie" ? "View in Radarr" : "View in Sonarr"}
<ExternalIcon />
</a>
)}
{/* Metadata row */}
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-slate-700/30">
{/* View in Seerr */}
{alert.seerrMediaUrl && (
<a
href={alert.seerrMediaUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-600 bg-slate-700/60 hover:bg-slate-700 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:text-white transition-colors"
>
View in Seerr
<ExternalIcon />
</a>
)}
{/* Dates */}
<Chip label={`Opened ${fullDate(alert.firstSeen)}`} dim />
{!isOpen && alert.closedAt && (
<Chip label={`${isResolved ? "Resolved" : "Closed"} ${fullDate(alert.closedAt)}`} dim />
)}
{/* User (for user-behavior alerts) */}
{alert.userName && !alert.mediaTitle && (
<Chip label={alert.userName} />
)}
{/* View in Radarr/Sonarr */}
{alert.mediaUrl && (
<a
href={alert.mediaUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-slate-600 bg-slate-700/60 hover:bg-slate-700 px-2.5 py-1 text-xs font-medium text-slate-300 hover:text-white transition-colors"
{/* Close / Reopen */}
<button
onClick={toggleStatus}
disabled={actionLoading}
className={`rounded-lg px-4 py-1.5 text-xs font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
isOpen
? "bg-slate-700 hover:bg-slate-600 text-white"
: "bg-green-900/50 hover:bg-green-800/50 text-green-300 border border-green-800"
}`}
>
{alert.mediaType === "movie" ? "View in Radarr" : "View in Sonarr"}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3 w-3">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
{actionLoading ? "…" : isOpen ? "Close" : "Reopen"}
</button>
</div>
</div>
{/* Title */}
<h1 className="text-2xl font-bold text-white leading-snug">{alert.title}</h1>
{/* Category body */}
<div className="space-y-3">
{/* Description — structured for content alerts, prose for others */}
{alert.category !== "watchrate" && (
<ContentDescription
description={alert.description}
category={alert.category}
requesterIds={alert.requesterIds}
seerrUrl={seerrUrl}
/>
)}
{/* Watchrate structured stat block */}
{watchrateStats && (
<WatchrateBlock
plays={watchrateStats.plays}
requests={watchrateStats.requests}
pct={watchrateStats.pct}
/>
)}
{/* Episode progress bar for partial TV downloads */}
{episodeCounts && (
<EpisodeBar
downloaded={episodeCounts.downloaded}
total={episodeCounts.total}
/>
)}
{/* User chip linking to dashboard for behavior alerts */}
{userLink && alert.userName && (
<div className="flex items-center gap-2 pt-0.5">
<span className="text-xs text-slate-600">User</span>
<Link
href={userLink}
className="inline-flex items-center gap-1.5 rounded-md border border-slate-700 bg-slate-800 hover:bg-slate-700 px-2.5 py-1 text-xs font-medium text-slate-300 hover:text-white transition-colors"
>
{alert.userName}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3 w-3">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
</Link>
</div>
)}
</div>
{/* Metadata footer — closed/resolved date only */}
{!isOpen && alert.closedAt && (
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-slate-700/30">
<Chip label={`${isResolved ? "Resolved" : "Closed"} ${fullDate(alert.closedAt)}`} dim />
</div>
)}
</div>
</div>
@@ -296,7 +576,8 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
rows={3}
className="w-full rounded-lg bg-slate-800/40 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-700 px-4 py-3 focus:outline-none resize-none transition-colors"
/>
<div className="flex justify-end">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-700"> to submit</span>
<button
type="submit"
disabled={commentLoading || !commentText.trim()}

View File

@@ -1,4 +1,5 @@
import { getAlertById } from "@/lib/db";
import { getSettings } from "@/lib/settings";
import { notFound } from "next/navigation";
import AlertDetail from "./AlertDetail";
@@ -10,5 +11,13 @@ export default async function AlertPage({
const { id } = await params;
const alert = getAlertById(Number(id));
if (!alert) notFound();
return <AlertDetail initialAlert={alert} />;
const { radarr, sonarr, seerr } = getSettings();
return (
<AlertDetail
initialAlert={alert}
radarrUrl={radarr.url || undefined}
sonarrUrl={sonarr.url || undefined}
seerrUrl={seerr.url || undefined}
/>
);
}

View File

@@ -0,0 +1,16 @@
import { getSettings, saveSettings, AppSettings } from "@/lib/settings";
export async function GET() {
return Response.json(getSettings());
}
export async function PUT(req: Request) {
try {
const body = await req.json() as AppSettings;
const saved = saveSettings(body);
return Response.json(saved);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return Response.json({ error: message }, { status: 400 });
}
}

View File

@@ -0,0 +1,121 @@
/**
* POST /api/settings/test
* Tests connectivity to a service using the provided URL + API key.
* Does NOT save anything — purely a connectivity check.
*/
import { sendDiscordTestNotification } from "@/lib/discord";
interface TestBody {
service: "radarr" | "sonarr" | "seerr" | "tautulli" | "discord";
url: string;
apiKey: string;
}
interface TestResult {
ok: boolean;
message: string;
}
const TIMEOUT_MS = 10_000;
async function testRadarr(url: string, apiKey: string): Promise<TestResult> {
const res = await fetch(`${url}/api/v3/system/status`, {
headers: { "X-Api-Key": apiKey },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) return { ok: false, message: `HTTP ${res.status} ${res.statusText}` };
const data = await res.json() as { version?: string };
return { ok: true, message: `Connected${data.version ? ` (v${data.version})` : ""}` };
}
async function testSonarr(url: string, apiKey: string): Promise<TestResult> {
const res = await fetch(`${url}/api/v3/system/status`, {
headers: { "X-Api-Key": apiKey },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) return { ok: false, message: `HTTP ${res.status} ${res.statusText}` };
const data = await res.json() as { version?: string };
return { ok: true, message: `Connected${data.version ? ` (v${data.version})` : ""}` };
}
async function testSeerr(url: string, apiKey: string): Promise<TestResult> {
const res = await fetch(`${url}/api/v1/status`, {
headers: { "X-Api-Key": apiKey },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) return { ok: false, message: `HTTP ${res.status} ${res.statusText}` };
const data = await res.json() as { version?: string };
return { ok: true, message: `Connected${data.version ? ` (v${data.version})` : ""}` };
}
async function testTautulli(url: string, apiKey: string): Promise<TestResult> {
const res = await fetch(
`${url}/api/v2?apikey=${encodeURIComponent(apiKey)}&cmd=get_server_info`,
{ signal: AbortSignal.timeout(TIMEOUT_MS) }
);
if (!res.ok) return { ok: false, message: `HTTP ${res.status} ${res.statusText}` };
const data = await res.json() as { response?: { result?: string; data?: { pms_version?: string } } };
if (data.response?.result !== "success") {
return { ok: false, message: "Tautulli returned a non-success result" };
}
const ver = data.response.data?.pms_version;
return { ok: true, message: `Connected${ver ? ` (Plex ${ver})` : ""}` };
}
export async function POST(req: Request) {
try {
const { service, url, apiKey } = await req.json() as TestBody;
// Discord only needs the webhook URL (passed as `url`)
if (service === "discord") {
if (!url) {
return Response.json({ ok: false, message: "Webhook URL is required" }, { status: 400 });
}
try {
new URL(url);
} catch {
return Response.json({ ok: false, message: "Invalid URL" }, { status: 400 });
}
await sendDiscordTestNotification(url.trim());
return Response.json({ ok: true, message: "Test message sent — check your channel" });
}
if (!url || !apiKey) {
return Response.json({ ok: false, message: "URL and API key are required" }, { status: 400 });
}
// Basic URL validation
try {
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol)) {
return Response.json({ ok: false, message: "URL must use http or https" }, { status: 400 });
}
} catch {
return Response.json({ ok: false, message: "Invalid URL" }, { status: 400 });
}
const trimmed = url.replace(/\/+$/, "");
let result: TestResult;
switch (service) {
case "radarr": result = await testRadarr(trimmed, apiKey); break;
case "sonarr": result = await testSonarr(trimmed, apiKey); break;
case "seerr": result = await testSeerr(trimmed, apiKey); break;
case "tautulli": result = await testTautulli(trimmed, apiKey); break;
default:
return Response.json({ ok: false, message: "Unknown service" }, { status: 400 });
}
return Response.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Provide friendlier messages for common failures
const friendly = message.includes("fetch failed") || message.includes("ECONNREFUSED")
? "Connection refused — check the URL and that the service is running"
: message.includes("TimeoutError") || message.includes("abort")
? "Connection timed out"
: message;
return Response.json({ ok: false, message: friendly });
}
}

View File

@@ -1,12 +1,12 @@
"use client";
import { useEffect, useState, useCallback, useRef, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useEffect, useState, useCallback, useRef } from "react";
import { DashboardStats } from "@/lib/types";
import SummaryCards from "@/components/SummaryCards";
import LeaderboardTable from "@/components/LeaderboardTable";
import AlertsPanel from "@/components/AlertsPanel";
import RefreshButton from "@/components/RefreshButton";
import SettingsModal from "@/components/SettingsModal";
type Tab = "leaderboard" | "alerts";
const LS_KEY = "oversnitch_stats";
@@ -21,15 +21,13 @@ function timeAgo(iso: string): string {
return new Date(iso).toLocaleDateString();
}
function DashboardContent() {
const searchParams = useSearchParams();
const router = useRouter();
const tab = (searchParams.get("tab") ?? "leaderboard") as Tab;
export default function Page() {
const [tab, setTab] = useState<Tab>("leaderboard");
const [data, setData] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const didInit = useRef(false);
const load = useCallback(async (force = false) => {
@@ -67,18 +65,13 @@ function DashboardContent() {
load();
}, [load]);
function setTab(t: Tab) {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", t);
router.push(`?${params.toString()}`, { scroll: false });
}
const hasTautulli = data?.summary.totalWatchHours !== null;
const openAlertCount = data?.summary.openAlertCount ?? 0;
const generatedAt = data?.generatedAt ?? null;
return (
<main className="mx-auto max-w-6xl px-4 py-8 space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
@@ -89,7 +82,20 @@ function DashboardContent() {
</div>
<div className="flex flex-col items-end gap-1.5 shrink-0">
<RefreshButton onRefresh={() => load(true)} loading={refreshing || loading} />
<div className="flex items-center gap-2">
<button
onClick={() => setSettingsOpen(true)}
className="rounded-lg border border-slate-700/60 bg-slate-800/40 hover:bg-slate-800 p-2 text-slate-500 hover:text-slate-300 transition-colors"
aria-label="Settings"
title="Settings"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.43l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</button>
<RefreshButton onRefresh={() => load(true)} loading={refreshing || loading} />
</div>
{generatedAt && (
<span className="text-xs text-slate-600">
{refreshing
@@ -175,14 +181,12 @@ function DashboardContent() {
{tab === "alerts" && <AlertsPanel />}
</>
)}
<SettingsModal
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
onSaved={() => load(true)}
/>
</main>
);
}
export default function Page() {
return (
<Suspense>
<DashboardContent />
</Suspense>
);
}

View File

@@ -0,0 +1,428 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { AppSettings, ServiceConfig, DiscordConfig } from "@/lib/settings";
// ── Icons ─────────────────────────────────────────────────────────────────────
function EyeIcon({ open }: { open: boolean }) {
return open ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
);
}
function XIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
);
}
// ── Per-service section ───────────────────────────────────────────────────────
type ServiceKey = "radarr" | "sonarr" | "seerr" | "tautulli";
interface SectionProps {
id: ServiceKey;
label: string;
placeholder: string;
optional?: boolean;
config: ServiceConfig;
onChange: (patch: Partial<ServiceConfig>) => void;
}
function ServiceSection({ id, label, placeholder, optional, config, onChange }: SectionProps) {
const [showKey, setShowKey] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
async function handleTest() {
setTesting(true);
setTestResult(null);
try {
const res = await fetch("/api/settings/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ service: id, url: config.url.trim(), apiKey: config.apiKey.trim() }),
});
const data = await res.json() as { ok: boolean; message: string };
setTestResult(data);
} catch {
setTestResult({ ok: false, message: "Network error" });
} finally {
setTesting(false);
}
}
// Clear test result when inputs change
function handleUrlChange(v: string) {
setTestResult(null);
onChange({ url: v });
}
function handleKeyChange(v: string) {
setTestResult(null);
onChange({ apiKey: v });
}
const canTest = config.url.trim().length > 0 && config.apiKey.trim().length > 0;
return (
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-200">{label}</h3>
{optional && (
<span className="text-xs text-slate-600 font-normal">optional</span>
)}
</div>
<div className="space-y-2">
{/* URL */}
<div className="flex items-center gap-3">
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">URL</label>
<input
type="text"
value={config.url}
onChange={(e) => handleUrlChange(e.target.value)}
placeholder={placeholder}
spellCheck={false}
autoComplete="off"
className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono"
/>
</div>
{/* API Key */}
<div className="flex items-center gap-3">
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">API Key</label>
<div className="flex flex-1 gap-2">
<div className="relative flex-1">
<input
type={showKey ? "text" : "password"}
value={config.apiKey}
onChange={(e) => handleKeyChange(e.target.value)}
placeholder="••••••••••••••••••••••••••••••••"
spellCheck={false}
autoComplete="off"
className="w-full rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-700 px-3 py-2 pr-9 focus:outline-none transition-colors font-mono"
/>
<button
type="button"
onClick={() => setShowKey((s) => !s)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-600 hover:text-slate-400 transition-colors"
tabIndex={-1}
aria-label={showKey ? "Hide API key" : "Show API key"}
>
<EyeIcon open={showKey} />
</button>
</div>
<button
type="button"
onClick={handleTest}
disabled={!canTest || testing}
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
>
{testing ? "Testing…" : "Test"}
</button>
</div>
</div>
</div>
{/* Test result */}
{testResult && (
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
{testResult.ok ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
)}
{testResult.message}
</div>
)}
</div>
);
}
// ── Discord section (webhook URL only, no API key) ────────────────────────────
interface DiscordSectionProps {
config: DiscordConfig;
onChange: (patch: Partial<DiscordConfig>) => void;
}
function DiscordSection({ config, onChange }: DiscordSectionProps) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
async function handleTest() {
setTesting(true);
setTestResult(null);
try {
const res = await fetch("/api/settings/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ service: "discord", url: config.webhookUrl.trim(), apiKey: "" }),
});
const data = await res.json() as { ok: boolean; message: string };
setTestResult(data);
} catch {
setTestResult({ ok: false, message: "Network error" });
} finally {
setTesting(false);
}
}
function handleChange(v: string) {
setTestResult(null);
onChange({ webhookUrl: v });
}
const canTest = config.webhookUrl.trim().length > 0;
return (
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-200">Discord</h3>
<span className="text-xs text-slate-600 font-normal">optional</span>
</div>
<div className="flex items-center gap-3">
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">Webhook</label>
<div className="flex flex-1 gap-2">
<input
type="text"
value={config.webhookUrl}
onChange={(e) => handleChange(e.target.value)}
placeholder="https://discord.com/api/webhooks/…"
spellCheck={false}
autoComplete="off"
className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono"
/>
<button
type="button"
onClick={handleTest}
disabled={!canTest || testing}
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
>
{testing ? "Sending…" : "Test"}
</button>
</div>
</div>
{testResult && (
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
{testResult.ok ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
)}
{testResult.message}
</div>
)}
</div>
);
}
// ── Modal ─────────────────────────────────────────────────────────────────────
interface Props {
open: boolean;
onClose: () => void;
onSaved?: () => void;
}
const EMPTY_CONFIG: ServiceConfig = { url: "", apiKey: "" };
const EMPTY_SETTINGS: AppSettings = {
radarr: EMPTY_CONFIG,
sonarr: EMPTY_CONFIG,
seerr: EMPTY_CONFIG,
tautulli: EMPTY_CONFIG,
discord: { webhookUrl: "" },
};
export default function SettingsModal({ open, onClose, onSaved }: Props) {
const [settings, setSettings] = useState<AppSettings>(EMPTY_SETTINGS);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [saveResult, setSaveResult] = useState<"saved" | "error" | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
// Load current settings when modal opens
useEffect(() => {
if (!open) return;
setSaveResult(null);
setLoading(true);
fetch("/api/settings")
.then((r) => r.json())
.then((data: AppSettings) => setSettings(data))
.catch(() => {})
.finally(() => setLoading(false));
}, [open]);
// Close on Escape
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Close on backdrop click
function handleBackdrop(e: React.MouseEvent) {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
onClose();
}
}
function patch(service: "radarr" | "sonarr" | "seerr" | "tautulli", partial: Partial<ServiceConfig>) {
setSaveResult(null);
setSettings((prev) => ({
...prev,
[service]: { ...prev[service], ...partial },
}));
}
function patchDiscord(partial: Partial<DiscordConfig>) {
setSaveResult(null);
setSettings((prev) => ({
...prev,
discord: { ...prev.discord, ...partial },
}));
}
async function handleSave() {
setSaving(true);
setSaveResult(null);
try {
const res = await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setSaveResult("saved");
onSaved?.();
} catch {
setSaveResult("error");
} finally {
setSaving(false);
}
}
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={handleBackdrop}
>
<div
ref={panelRef}
className="relative w-full max-w-lg rounded-2xl bg-slate-900 border border-slate-700/60 shadow-2xl flex flex-col max-h-[90vh]"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-5 border-b border-slate-800 shrink-0">
<h2 className="text-base font-semibold text-white">Settings</h2>
<button
onClick={onClose}
className="text-slate-500 hover:text-slate-300 transition-colors"
aria-label="Close"
>
<XIcon />
</button>
</div>
{/* Body */}
<div className="overflow-y-auto flex-1 px-6 py-5">
{loading ? (
<div className="flex justify-center py-8">
<svg className="animate-spin h-5 w-5 text-slate-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
</div>
) : (
<div className="space-y-5">
<ServiceSection
id="radarr"
label="Radarr"
placeholder="http://radarr:7878"
config={settings.radarr}
onChange={(p) => patch("radarr", p)}
/>
<ServiceSection
id="sonarr"
label="Sonarr"
placeholder="http://sonarr:8989"
config={settings.sonarr}
onChange={(p) => patch("sonarr", p)}
/>
<ServiceSection
id="seerr"
label="Overseerr / Jellyseerr"
placeholder="http://overseerr:5055"
config={settings.seerr}
onChange={(p) => patch("seerr", p)}
/>
<ServiceSection
id="tautulli"
label="Tautulli"
placeholder="http://tautulli:8181"
optional
config={settings.tautulli}
onChange={(p) => patch("tautulli", p)}
/>
<DiscordSection
config={settings.discord}
onChange={(p) => patchDiscord(p)}
/>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-slate-800 shrink-0 gap-3">
<div className="text-xs">
{saveResult === "saved" && (
<span className="text-green-400">Saved click Refresh to reload data</span>
)}
{saveResult === "error" && (
<span className="text-red-400">Save failed check the console</span>
)}
</div>
<div className="flex gap-2 shrink-0">
<button
onClick={onClose}
className="rounded-lg border border-slate-700 bg-slate-800/60 hover:bg-slate-700 px-4 py-2 text-sm font-medium text-slate-300 hover:text-white transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving || loading}
className="rounded-lg bg-yellow-500 hover:bg-yellow-400 disabled:opacity-40 disabled:cursor-not-allowed px-4 py-2 text-sm font-semibold text-black transition-colors"
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import {
import { lookupTautulliUser } from "@/lib/tautulli";
import { generateAlertCandidates } from "@/lib/alerts";
import { upsertAlerts } from "@/lib/db";
import { sendDiscordNotifications } from "@/lib/discord";
export function bytesToGB(bytes: number): number {
return Math.round((bytes / 1024 / 1024 / 1024) * 10) / 10;
@@ -124,7 +125,10 @@ export function computeStats(
sonarrMap,
hasTautulli
);
const openAlertCount = upsertAlerts(candidates);
const { openCount: openAlertCount, newAlerts } = upsertAlerts(candidates);
if (newAlerts.length > 0) {
sendDiscordNotifications(newAlerts).catch(() => {});
}
const totalRequests = userStats.reduce((s, u) => s + u.requestCount, 0);
const totalStorageGB = bytesToGB(

View File

@@ -1,4 +1,5 @@
import { UserStat, OverseerrRequest, MediaEntry, AlertCandidate } from "@/lib/types";
import { getSettings } from "@/lib/settings";
// ─── Tunables ─────────────────────────────────────────────────────────────────
@@ -46,6 +47,7 @@ export function generateAlertCandidates(
hasTautulli: boolean
): AlertCandidate[] {
const candidates: AlertCandidate[] = [];
const { radarr: radarrSettings, sonarr: sonarrSettings, seerr: seerrSettings } = getSettings();
// ── CONTENT-CENTRIC: one alert per piece of media ──────────────────────────
@@ -55,6 +57,8 @@ export function generateAlertCandidates(
interface UnfilledEntry {
entry: MediaEntry;
requestedBy: string[];
requestedByIds: number[];
tmdbId?: number; // TV shows only — needed to build the Seerr URL (movies use map key)
oldestAgeHours: number;
partial?: boolean;
}
@@ -77,13 +81,16 @@ export function generateAlertCandidates(
const title = entry?.title ?? req.media.title ?? `Movie #${req.media.tmdbId}`;
const existing = unfilledMovies.get(req.media.tmdbId);
if (existing) {
if (!existing.requestedBy.includes(user.displayName))
if (!existing.requestedBy.includes(user.displayName)) {
existing.requestedBy.push(user.displayName);
existing.requestedByIds.push(user.userId);
}
existing.oldestAgeHours = Math.max(existing.oldestAgeHours, ageHours);
} else {
unfilledMovies.set(req.media.tmdbId, {
entry: { title, sizeOnDisk: 0, available: true },
requestedBy: [user.displayName],
requestedByIds: [user.userId],
oldestAgeHours: ageHours,
});
}
@@ -108,14 +115,18 @@ export function generateAlertCandidates(
const partial = !isNothingDownloaded && isPartiallyDownloaded;
const existing = unfilledShows.get(req.media.tvdbId);
if (existing) {
if (!existing.requestedBy.includes(user.displayName))
if (!existing.requestedBy.includes(user.displayName)) {
existing.requestedBy.push(user.displayName);
existing.requestedByIds.push(user.userId);
}
existing.oldestAgeHours = Math.max(existing.oldestAgeHours, ageHours);
if (partial) existing.partial = true;
} else {
unfilledShows.set(req.media.tvdbId, {
entry: { title, sizeOnDisk: entry?.sizeOnDisk ?? 0, available: true },
requestedBy: [user.displayName],
requestedByIds: [user.userId],
tmdbId: req.media.tmdbId,
oldestAgeHours: ageHours,
partial,
});
@@ -124,13 +135,15 @@ export function generateAlertCandidates(
}
}
for (const [tmdbId, { entry, requestedBy, oldestAgeHours }] of unfilledMovies) {
for (const [tmdbId, { entry, requestedBy, requestedByIds, oldestAgeHours }] of unfilledMovies) {
if (flaggedMovies.has(tmdbId)) continue;
flaggedMovies.add(tmdbId);
const byStr = requestedBy.slice(0, 3).join(", ") + (requestedBy.length > 3 ? ` +${requestedBy.length - 3}` : "");
const mediaUrl = entry.titleSlug && process.env.RADARR_URL
? `${process.env.RADARR_URL}/movie/${entry.titleSlug}`
const radarrEntry = radarrMap.get(tmdbId);
const mediaUrl = radarrEntry?.titleSlug && radarrSettings.url
? `${radarrSettings.url}/movie/${radarrEntry.titleSlug}`
: undefined;
const seerrMediaUrl = seerrSettings.url ? `${seerrSettings.url}/movie/${tmdbId}` : undefined;
candidates.push({
key: `unfulfilled:movie:${tmdbId}`,
category: "unfulfilled",
@@ -141,10 +154,12 @@ export function generateAlertCandidates(
mediaType: "movie",
mediaTitle: entry.title,
mediaUrl,
seerrMediaUrl,
requesterIds: requestedByIds,
});
}
for (const [tvdbId, { entry, requestedBy, oldestAgeHours, partial }] of unfilledShows) {
for (const [tvdbId, { entry, requestedBy, requestedByIds, tmdbId: showTmdbId, oldestAgeHours, partial }] of unfilledShows) {
if (flaggedShows.has(tvdbId)) continue;
flaggedShows.add(tvdbId);
const sonarrEntry = sonarrMap.get(tvdbId);
@@ -155,8 +170,11 @@ export function generateAlertCandidates(
const description = pct !== null
? `Only ${pct}% of episodes downloaded (${sonarrEntry!.episodeFileCount}/${sonarrEntry!.totalEpisodeCount}). Approved ${formatAge(oldestAgeHours)} ago. Requested by ${byStr}.`
: `Approved ${formatAge(oldestAgeHours)} ago but no files found in Sonarr. Requested by ${byStr}.`;
const mediaUrl = sonarrEntry?.titleSlug && process.env.SONARR_URL
? `${process.env.SONARR_URL}/series/${sonarrEntry.titleSlug}`
const mediaUrl = sonarrEntry?.titleSlug && sonarrSettings.url
? `${sonarrSettings.url}/series/${sonarrEntry.titleSlug}`
: undefined;
const seerrMediaUrl = showTmdbId && seerrSettings.url
? `${seerrSettings.url}/tv/${showTmdbId}`
: undefined;
candidates.push({
key: `unfulfilled:tv:${tvdbId}`,
@@ -168,6 +186,8 @@ export function generateAlertCandidates(
mediaType: "tv",
mediaTitle: entry.title,
mediaUrl,
seerrMediaUrl,
requesterIds: requestedByIds,
});
}
@@ -199,6 +219,8 @@ export function generateAlertCandidates(
mediaTitle: title,
userId: user.userId,
userName: user.displayName,
requesterIds: [user.userId],
seerrMediaUrl: seerrSettings.url ? `${seerrSettings.url}/movie/${req.media.tmdbId}` : undefined,
});
} else if (req.type === "tv" && req.media.tvdbId && !flaggedPending.has(req.id)) {
const showEntry = sonarrMap.get(req.media.tvdbId);
@@ -216,6 +238,8 @@ export function generateAlertCandidates(
mediaTitle: title,
userId: user.userId,
userName: user.displayName,
requesterIds: [user.userId],
seerrMediaUrl: seerrSettings.url ? `${seerrSettings.url}/tv/${req.media.tmdbId}` : undefined,
});
}
}

View File

@@ -1,9 +1,13 @@
/**
* Lightweight JSON file store for alert persistence.
* Lives at data/alerts.json (gitignored, created on first run).
* SQLite-backed alert store using better-sqlite3.
* Lives at data/alerts.db (gitignored).
*
* Uses a global singleton so Next.js hot-reload doesn't open multiple
* connections. WAL mode is enabled for concurrent read performance.
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
import Database from "better-sqlite3";
import { mkdirSync, existsSync, readFileSync, renameSync } from "fs";
import { join } from "path";
import {
AlertCandidate,
@@ -14,7 +18,9 @@ import {
} from "./types";
const DATA_DIR = join(process.cwd(), "data");
const DB_PATH = join(DATA_DIR, "alerts.json");
const DB_PATH = join(DATA_DIR, "alerts.db");
const LEGACY_JSON_PATH = join(DATA_DIR, "alerts.json");
const LEGACY_MIGRATED_PATH = join(DATA_DIR, "alerts.json.migrated");
// Cooldown days applied on MANUAL close.
// 0 = no cooldown: content alerts reopen immediately on the next refresh if
@@ -30,196 +36,387 @@ const COOLDOWN: Record<string, number> = {
};
const DEFAULT_COOLDOWN = 0;
interface Store {
nextId: number;
nextCommentId: number;
alerts: Record<string, StoredAlert>; // keyed by alert.key
// ── Singleton ──────────────────────────────────────────────────────────────────
declare global {
// eslint-disable-next-line no-var
var __alertsDb: Database.Database | undefined;
}
interface StoredAlert {
id: number;
key: string;
category: string;
severity: string;
title: string;
description: string;
userId?: number;
userName?: string;
mediaId?: number;
mediaType?: string;
mediaTitle?: string;
mediaUrl?: string;
status: AlertStatus;
closeReason: AlertCloseReason | null;
suppressedUntil: string | null;
firstSeen: string;
lastSeen: string;
closedAt: string | null;
comments: Array<{ id: number; body: string; createdAt: string; author: "user" | "system" }>;
}
function getDb(): Database.Database {
if (global.__alertsDb) return global.__alertsDb;
function load(): Store {
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
if (!existsSync(DB_PATH)) {
const empty: Store = { nextId: 1, nextCommentId: 1, alerts: {} };
writeFileSync(DB_PATH, JSON.stringify(empty, null, 2));
return empty;
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
initSchema(db);
maybeMigrateJson(db);
global.__alertsDb = db;
return db;
}
// ── Schema ─────────────────────────────────────────────────────────────────────
function initSchema(db: Database.Database) {
db.exec(`
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
category TEXT NOT NULL,
severity TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
userId INTEGER,
userName TEXT,
mediaId INTEGER,
mediaType TEXT,
mediaTitle TEXT,
mediaUrl TEXT,
status TEXT NOT NULL DEFAULT 'open',
closeReason TEXT,
suppressedUntil TEXT,
firstSeen TEXT NOT NULL,
lastSeen TEXT NOT NULL,
closedAt TEXT,
requesterIds TEXT,
seerrMediaUrl TEXT
);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alertId INTEGER NOT NULL REFERENCES alerts(id) ON DELETE CASCADE,
body TEXT NOT NULL,
author TEXT NOT NULL DEFAULT 'user',
createdAt TEXT NOT NULL
);
`);
// Additive migrations for existing databases
try { db.exec("ALTER TABLE alerts ADD COLUMN requesterIds TEXT"); } catch {}
try { db.exec("ALTER TABLE alerts ADD COLUMN seerrMediaUrl TEXT"); } catch {}
}
// ── Legacy JSON migration ──────────────────────────────────────────────────────
function maybeMigrateJson(db: Database.Database) {
if (!existsSync(LEGACY_JSON_PATH)) return;
// Only migrate if the alerts table is empty
const count = (db.prepare("SELECT COUNT(*) as n FROM alerts").get() as { n: number }).n;
if (count > 0) {
// Table already has data — just remove the legacy file
renameSync(LEGACY_JSON_PATH, LEGACY_MIGRATED_PATH);
return;
}
try {
interface LegacyComment { id: number; body: string; author: "user" | "system"; createdAt: string; }
interface LegacyAlert {
id: number; key: string; category: string; severity: string;
title: string; description: string;
userId?: number; userName?: string;
mediaId?: number; mediaType?: string; mediaTitle?: string; mediaUrl?: string;
status: string; closeReason: string | null; suppressedUntil: string | null;
firstSeen: string; lastSeen: string; closedAt: string | null;
comments: LegacyComment[];
}
interface LegacyStore { alerts: Record<string, LegacyAlert>; }
const raw = readFileSync(LEGACY_JSON_PATH, "utf-8");
const store: LegacyStore = JSON.parse(raw);
const insertAlert = db.prepare(`
INSERT INTO alerts
(key, category, severity, title, description, userId, userName,
mediaId, mediaType, mediaTitle, mediaUrl, status, closeReason,
suppressedUntil, firstSeen, lastSeen, closedAt)
VALUES
(@key, @category, @severity, @title, @description, @userId, @userName,
@mediaId, @mediaType, @mediaTitle, @mediaUrl, @status, @closeReason,
@suppressedUntil, @firstSeen, @lastSeen, @closedAt)
`);
const insertComment = db.prepare(`
INSERT INTO comments (alertId, body, author, createdAt)
VALUES (@alertId, @body, @author, @createdAt)
`);
const migrate = db.transaction(() => {
for (const a of Object.values(store.alerts)) {
const info = insertAlert.run({
key: a.key, category: a.category, severity: a.severity,
title: a.title, description: a.description,
userId: a.userId ?? null, userName: a.userName ?? null,
mediaId: a.mediaId ?? null, mediaType: a.mediaType ?? null,
mediaTitle: a.mediaTitle ?? null, mediaUrl: a.mediaUrl ?? null,
status: a.status, closeReason: a.closeReason ?? null,
suppressedUntil: a.suppressedUntil ?? null,
firstSeen: a.firstSeen, lastSeen: a.lastSeen, closedAt: a.closedAt ?? null,
});
const alertId = info.lastInsertRowid as number;
for (const c of a.comments ?? []) {
insertComment.run({ alertId, body: c.body, author: c.author, createdAt: c.createdAt });
}
}
});
migrate();
renameSync(LEGACY_JSON_PATH, LEGACY_MIGRATED_PATH);
console.log("[db] Migrated alerts.json → SQLite");
} catch (err) {
console.error("[db] Migration failed:", err);
}
return JSON.parse(readFileSync(DB_PATH, "utf-8")) as Store;
}
function save(store: Store) {
writeFileSync(DB_PATH, JSON.stringify(store, null, 2));
// ── Row → Alert ────────────────────────────────────────────────────────────────
interface AlertRow {
id: number; key: string; category: string; severity: string;
title: string; description: string;
userId: number | null; userName: string | null;
mediaId: number | null; mediaType: string | null;
mediaTitle: string | null; mediaUrl: string | null;
status: string; closeReason: string | null; suppressedUntil: string | null;
firstSeen: string; lastSeen: string; closedAt: string | null;
requesterIds: string | null; // JSON-encoded number[]
seerrMediaUrl: string | null;
}
function toAlert(s: StoredAlert): Alert {
interface CommentRow {
id: number; alertId: number; body: string; author: string; createdAt: string;
}
function rowToAlert(row: AlertRow, comments: CommentRow[]): Alert {
return {
id: s.id,
key: s.key,
category: s.category,
severity: s.severity as Alert["severity"],
title: s.title,
description: s.description,
userId: s.userId,
userName: s.userName,
mediaId: s.mediaId,
mediaType: s.mediaType as Alert["mediaType"],
mediaTitle: s.mediaTitle,
mediaUrl: s.mediaUrl,
status: s.status,
closeReason: s.closeReason ?? null,
suppressedUntil: s.suppressedUntil,
firstSeen: s.firstSeen,
lastSeen: s.lastSeen,
closedAt: s.closedAt,
comments: s.comments,
id: row.id,
key: row.key,
category: row.category,
severity: row.severity as Alert["severity"],
title: row.title,
description: row.description,
userId: row.userId ?? undefined,
userName: row.userName ?? undefined,
mediaId: row.mediaId ?? undefined,
mediaType: row.mediaType as Alert["mediaType"],
mediaTitle: row.mediaTitle ?? undefined,
mediaUrl: row.mediaUrl ?? undefined,
requesterIds: row.requesterIds ? (JSON.parse(row.requesterIds) as number[]) : undefined,
seerrMediaUrl: row.seerrMediaUrl ?? undefined,
status: row.status as AlertStatus,
closeReason: row.closeReason as AlertCloseReason | null,
suppressedUntil: row.suppressedUntil,
firstSeen: row.firstSeen,
lastSeen: row.lastSeen,
closedAt: row.closedAt,
comments: comments.map((c) => ({
id: c.id,
body: c.body,
author: c.author as "user" | "system",
createdAt: c.createdAt,
})),
};
}
function getCommentsForAlert(db: Database.Database, alertId: number): CommentRow[] {
return db.prepare(
"SELECT * FROM comments WHERE alertId = ? ORDER BY createdAt ASC, id ASC"
).all(alertId) as CommentRow[];
}
// ── Exported API ───────────────────────────────────────────────────────────────
export interface UpsertResult {
openCount: number;
/** Candidates that were newly created or reopened this run — used for notifications. */
newAlerts: AlertCandidate[];
}
/**
* Merge generated candidates into the store, then auto-resolve any open alerts
* whose condition is no longer present (key not in this run's candidate set).
*
* Auto-resolved alerts:
* - Are marked closed with closeReason = "resolved"
* - Have NO suppressedUntil — they can reopen immediately if the condition returns
*
* Manually closed alerts:
* - Have suppressedUntil set (cooldown per category)
* - Won't be re-opened by upsertAlerts until that cooldown expires
*
* Returns the count of open alerts after the merge.
* Returns the count of open alerts after the merge and the list of newly
* created or reopened alert candidates (for Discord notifications etc.).
*/
export function upsertAlerts(candidates: AlertCandidate[]): number {
const store = load();
export function upsertAlerts(candidates: AlertCandidate[]): UpsertResult {
const db = getDb();
const now = new Date();
const nowISO = now.toISOString();
const candidateKeys = new Set(candidates.map((c) => c.key));
// ── Step 1: upsert candidates ─────────────────────────────────────────────
for (const c of candidates) {
const existing = store.alerts[c.key];
const getByKey = db.prepare<[string], AlertRow>(
"SELECT * FROM alerts WHERE key = ?"
);
const updateAlert = db.prepare(`
UPDATE alerts SET
status = @status, closeReason = @closeReason, closedAt = @closedAt,
suppressedUntil = @suppressedUntil, lastSeen = @lastSeen,
title = @title, description = @description,
userName = COALESCE(@userName, userName),
mediaTitle = COALESCE(@mediaTitle, mediaTitle),
mediaUrl = COALESCE(@mediaUrl, mediaUrl),
requesterIds = COALESCE(@requesterIds, requesterIds),
seerrMediaUrl = COALESCE(@seerrMediaUrl, seerrMediaUrl)
WHERE key = @key
`);
const insertAlert = db.prepare(`
INSERT INTO alerts
(key, category, severity, title, description, userId, userName,
mediaId, mediaType, mediaTitle, mediaUrl, status, closeReason,
suppressedUntil, firstSeen, lastSeen, closedAt, requesterIds, seerrMediaUrl)
VALUES
(@key, @category, @severity, @title, @description, @userId, @userName,
@mediaId, @mediaType, @mediaTitle, @mediaUrl, 'open', NULL, NULL,
@firstSeen, @lastSeen, NULL, @requesterIds, @seerrMediaUrl)
`);
const insertComment = db.prepare(`
INSERT INTO comments (alertId, body, author, createdAt)
VALUES (@alertId, @body, @author, @createdAt)
`);
const getOpenAlerts = db.prepare<[], AlertRow>(
"SELECT * FROM alerts WHERE status = 'open'"
);
if (existing) {
const isSuppressed =
existing.status === "closed" &&
existing.suppressedUntil !== null &&
new Date(existing.suppressedUntil) > now;
const newAlerts: AlertCandidate[] = [];
if (isSuppressed) continue;
db.transaction(() => {
// ── Step 1: upsert candidates ───────────────────────────────────────────
for (const c of candidates) {
const existing = getByKey.get(c.key);
// Re-open if previously closed (manually or resolved) and not suppressed.
// Preserve firstSeen and comments — this is the same incident continuing.
if (existing.status === "closed") {
existing.status = "open";
existing.closeReason = null;
existing.closedAt = null;
existing.suppressedUntil = null;
existing.comments.push({
id: store.nextCommentId++,
body: "Alert reopened — condition is still active.",
createdAt: nowISO,
author: "system",
if (existing) {
const isSuppressed =
existing.status === "closed" &&
existing.suppressedUntil !== null &&
new Date(existing.suppressedUntil) > now;
if (isSuppressed) continue;
const requesterIds = c.requesterIds?.length ? JSON.stringify(c.requesterIds) : null;
const seerrMediaUrl = c.seerrMediaUrl ?? null;
if (existing.status === "closed") {
// Reopen — notify
updateAlert.run({
key: c.key,
status: "open",
closeReason: null,
closedAt: null,
suppressedUntil: null,
lastSeen: nowISO,
title: c.title,
description: c.description,
userName: c.userName ?? null,
mediaTitle: c.mediaTitle ?? null,
mediaUrl: c.mediaUrl ?? null,
requesterIds,
seerrMediaUrl,
});
insertComment.run({
alertId: existing.id,
body: "Alert reopened — condition is still active.",
author: "system",
createdAt: nowISO,
});
newAlerts.push(c);
} else {
// Refresh content — already open, no notification
updateAlert.run({
key: c.key,
status: "open",
closeReason: null,
closedAt: null,
suppressedUntil: null,
lastSeen: nowISO,
title: c.title,
description: c.description,
userName: c.userName ?? null,
mediaTitle: c.mediaTitle ?? null,
mediaUrl: c.mediaUrl ?? null,
requesterIds,
seerrMediaUrl,
});
}
} else {
// New alert — notify
insertAlert.run({
key: c.key,
category: c.category,
severity: c.severity,
title: c.title,
description: c.description,
userId: c.userId ?? null,
userName: c.userName ?? null,
mediaId: c.mediaId ?? null,
mediaType: c.mediaType ?? null,
mediaTitle: c.mediaTitle ?? null,
mediaUrl: c.mediaUrl ?? null,
firstSeen: nowISO,
lastSeen: nowISO,
requesterIds: c.requesterIds?.length ? JSON.stringify(c.requesterIds) : null,
seerrMediaUrl: c.seerrMediaUrl ?? null,
});
newAlerts.push(c);
}
// Refresh content and lastSeen
existing.lastSeen = nowISO;
existing.title = c.title;
existing.description = c.description;
if (c.userName) existing.userName = c.userName;
if (c.mediaTitle) existing.mediaTitle = c.mediaTitle;
if (c.mediaUrl) existing.mediaUrl = c.mediaUrl;
} else {
store.alerts[c.key] = {
id: store.nextId++,
key: c.key,
category: c.category,
severity: c.severity,
title: c.title,
description: c.description,
userId: c.userId,
userName: c.userName,
mediaId: c.mediaId,
mediaType: c.mediaType,
mediaTitle: c.mediaTitle,
mediaUrl: c.mediaUrl,
status: "open",
closeReason: null,
suppressedUntil: null,
firstSeen: nowISO,
lastSeen: nowISO,
closedAt: null,
comments: [],
};
}
}
// ── Step 2: auto-resolve alerts whose condition is gone ───────────────────
for (const alert of Object.values(store.alerts)) {
if (alert.status !== "open") continue;
if (candidateKeys.has(alert.key)) continue;
// ── Step 2: auto-resolve alerts whose condition is gone ─────────────────
const openAlerts = getOpenAlerts.all();
for (const a of openAlerts) {
if (candidateKeys.has(a.key)) continue;
db.prepare(`
UPDATE alerts SET status = 'closed', closeReason = 'resolved',
closedAt = ?, suppressedUntil = NULL WHERE id = ?
`).run(nowISO, a.id);
insertComment.run({
alertId: a.id,
body: "Condition resolved — alert closed automatically.",
author: "system",
createdAt: nowISO,
});
}
})();
// Condition no longer exists — resolve it automatically, no cooldown
alert.status = "closed";
alert.closeReason = "resolved";
alert.closedAt = nowISO;
alert.suppressedUntil = null;
alert.comments.push({
id: store.nextCommentId++,
body: "Condition resolved — alert closed automatically.",
createdAt: nowISO,
author: "system",
});
}
save(store);
return Object.values(store.alerts).filter((a) => a.status === "open").length;
const { n } = db.prepare(
"SELECT COUNT(*) as n FROM alerts WHERE status = 'open'"
).get() as { n: number };
return { openCount: n, newAlerts };
}
export function getAllAlerts(): Alert[] {
const store = load();
return Object.values(store.alerts)
.sort((a, b) => {
if (a.status !== b.status) return a.status === "open" ? -1 : 1;
return new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime();
})
.map(toAlert);
const db = getDb();
const rows = db.prepare<[], AlertRow>(`
SELECT * FROM alerts
ORDER BY
CASE status WHEN 'open' THEN 0 ELSE 1 END ASC,
lastSeen DESC
`).all();
return rows.map((row) => rowToAlert(row, getCommentsForAlert(db, row.id)));
}
export function getAlertById(id: number): Alert | null {
const store = load();
const found = Object.values(store.alerts).find((a) => a.id === id);
return found ? toAlert(found) : null;
const db = getDb();
const row = db.prepare<[number], AlertRow>(
"SELECT * FROM alerts WHERE id = ?"
).get(id);
if (!row) return null;
return rowToAlert(row, getCommentsForAlert(db, id));
}
export function closeAlert(id: number): Alert | null {
const store = load();
const alert = Object.values(store.alerts).find((a) => a.id === id);
if (!alert) return null;
const db = getDb();
const row = db.prepare<[number], AlertRow>(
"SELECT * FROM alerts WHERE id = ?"
).get(id);
if (!row) return null;
const cooldownDays = COOLDOWN[alert.category] ?? DEFAULT_COOLDOWN;
const cooldownDays = COOLDOWN[row.category] ?? DEFAULT_COOLDOWN;
let suppressedUntil: string | null = null;
if (cooldownDays > 0) {
const d = new Date();
@@ -227,50 +424,66 @@ export function closeAlert(id: number): Alert | null {
suppressedUntil = d.toISOString();
}
const now = new Date().toISOString();
alert.status = "closed";
alert.closeReason = "manual";
alert.closedAt = now;
alert.suppressedUntil = suppressedUntil;
alert.comments.push({
id: store.nextCommentId++,
body: "Manually closed.",
createdAt: now,
author: "system",
});
save(store);
return toAlert(alert);
const nowISO = new Date().toISOString();
db.transaction(() => {
db.prepare(`
UPDATE alerts SET status = 'closed', closeReason = 'manual',
closedAt = ?, suppressedUntil = ? WHERE id = ?
`).run(nowISO, suppressedUntil, id);
db.prepare(`
INSERT INTO comments (alertId, body, author, createdAt)
VALUES (?, ?, 'system', ?)
`).run(id, "Manually closed.", nowISO);
})();
return getAlertById(id);
}
export function reopenAlert(id: number): Alert | null {
const store = load();
const alert = Object.values(store.alerts).find((a) => a.id === id);
if (!alert) return null;
alert.status = "open";
alert.closeReason = null;
alert.closedAt = null;
alert.suppressedUntil = null;
alert.comments.push({
id: store.nextCommentId++,
body: "Manually reopened.",
createdAt: new Date().toISOString(),
author: "system",
});
save(store);
return toAlert(alert);
const db = getDb();
const row = db.prepare<[number], AlertRow>(
"SELECT * FROM alerts WHERE id = ?"
).get(id);
if (!row) return null;
const nowISO = new Date().toISOString();
db.transaction(() => {
db.prepare(`
UPDATE alerts SET status = 'open', closeReason = NULL,
closedAt = NULL, suppressedUntil = NULL WHERE id = ?
`).run(id);
db.prepare(`
INSERT INTO comments (alertId, body, author, createdAt)
VALUES (?, 'Manually reopened.', 'system', ?)
`).run(id, nowISO);
})();
return getAlertById(id);
}
export function addComment(alertId: number, body: string, author: "user" | "system" = "user"): AlertComment | null {
const store = load();
const alert = Object.values(store.alerts).find((a) => a.id === alertId);
if (!alert) return null;
const comment: AlertComment = {
id: store.nextCommentId++,
export function addComment(
alertId: number,
body: string,
author: "user" | "system" = "user"
): AlertComment | null {
const db = getDb();
const exists = db.prepare<[number], { id: number }>(
"SELECT id FROM alerts WHERE id = ?"
).get(alertId);
if (!exists) return null;
const nowISO = new Date().toISOString();
const info = db.prepare(`
INSERT INTO comments (alertId, body, author, createdAt)
VALUES (?, ?, ?, ?)
`).run(alertId, body, author, nowISO);
return {
id: info.lastInsertRowid as number,
body,
createdAt: new Date().toISOString(),
author,
createdAt: nowISO,
};
alert.comments.push(comment);
save(store);
return comment;
}

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

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

View File

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

View File

@@ -1,8 +1,10 @@
import { RadarrMovie, MediaEntry } from "@/lib/types";
import { getSettings } from "@/lib/settings";
export async function buildRadarrMap(): Promise<Map<number, MediaEntry>> {
const res = await fetch(`${process.env.RADARR_URL}/api/v3/movie`, {
headers: { "X-Api-Key": process.env.RADARR_API! },
const { radarr } = getSettings();
const res = await fetch(`${radarr.url}/api/v3/movie`, {
headers: { "X-Api-Key": radarr.apiKey },
});
if (!res.ok) {

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

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

View File

@@ -1,8 +1,10 @@
import { SonarrSeries, MediaEntry } from "@/lib/types";
import { getSettings } from "@/lib/settings";
export async function buildSonarrMap(): Promise<Map<number, MediaEntry>> {
const res = await fetch(`${process.env.SONARR_URL}/api/v3/series`, {
headers: { "X-Api-Key": process.env.SONARR_API! },
const { sonarr } = getSettings();
const res = await fetch(`${sonarr.url}/api/v3/series`, {
headers: { "X-Api-Key": sonarr.apiKey },
});
if (!res.ok) {

View File

@@ -1,4 +1,5 @@
import { TautulliUser } from "@/lib/types";
import { getSettings } from "@/lib/settings";
interface TautulliRow {
friendly_name: string;
@@ -22,8 +23,9 @@ interface TautulliResponse {
* Returns null if TAUTULLI_URL/TAUTULLI_API are not set.
*/
export async function buildTautulliMap(): Promise<Map<string, TautulliUser> | null> {
const url = process.env.TAUTULLI_URL;
const key = process.env.TAUTULLI_API;
const { tautulli } = getSettings();
const url = tautulli.url;
const key = tautulli.apiKey;
if (!url || !key) return null;

View File

@@ -115,7 +115,10 @@ export interface AlertCandidate {
mediaId?: number;
mediaType?: "movie" | "tv";
mediaTitle?: string;
mediaUrl?: string; // direct link to the item in Radarr/Sonarr
mediaUrl?: string; // direct link to the item in Radarr/Sonarr
seerrMediaUrl?: string; // link to the item's page in Overseerr/Jellyseerr
// Ordered list of Overseerr user IDs who triggered this alert (content alerts)
requesterIds?: number[];
}
export interface AlertComment {