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:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
|
||||
|
||||
46
README.md
46
README.md
@@ -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
445
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef, useEffect, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, AlertSeverity, AlertComment } from "@/lib/types";
|
||||
|
||||
const severityAccent: Record<AlertSeverity, string> = {
|
||||
@@ -45,7 +46,7 @@ function shortDate(iso: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Meta chip ─────────────────────────────────────────────────────────────────
|
||||
// ── Chip ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Chip({ label, dim }: { label: string; dim?: boolean }) {
|
||||
return (
|
||||
@@ -59,6 +60,170 @@ function Chip({ label, dim }: { label: string; dim?: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── External link icon ────────────────────────────────────────────────────────
|
||||
|
||||
function ExternalIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3 w-3 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Episode progress bar ──────────────────────────────────────────────────────
|
||||
|
||||
function EpisodeBar({ downloaded, total }: { downloaded: number; total: number }) {
|
||||
const pct = Math.round((downloaded / total) * 100);
|
||||
return (
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs font-medium text-slate-400">Episodes downloaded</span>
|
||||
<span className="text-xs tabular-nums text-slate-500">{downloaded} / {total} ({pct}%)</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-slate-700 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-yellow-500/80 transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Structured description for content alerts ────────────────────────────────
|
||||
|
||||
interface DescRow { label: string; value?: string; chips?: ReactNode }
|
||||
|
||||
function DescriptionTable({ rows }: { rows: DescRow[] }) {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{rows.map(({ label, value, chips }) => (
|
||||
<div key={label} className="flex gap-3 text-sm">
|
||||
<span className="w-28 shrink-0 text-slate-600">{label}</span>
|
||||
{chips ?? <span className="text-slate-300">{value}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ContentDescriptionProps {
|
||||
description: string;
|
||||
category: string;
|
||||
requesterIds?: number[];
|
||||
seerrUrl?: string;
|
||||
}
|
||||
|
||||
function RequesterChips({
|
||||
names,
|
||||
requesterIds,
|
||||
seerrUrl,
|
||||
}: {
|
||||
names: string[];
|
||||
requesterIds?: number[];
|
||||
seerrUrl?: string;
|
||||
}) {
|
||||
return (
|
||||
<span>
|
||||
{names.map((name, i) => {
|
||||
const uid = requesterIds?.[i];
|
||||
const href = uid && seerrUrl ? `${seerrUrl}/users/${uid}` : null;
|
||||
return (
|
||||
<span key={i}>
|
||||
{i > 0 && <span className="text-slate-600">, </span>}
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-slate-300">{name}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentDescription({ description, category, requesterIds, seerrUrl }: ContentDescriptionProps) {
|
||||
if (category === "unfulfilled") {
|
||||
// Partial TV: "Only X% of episodes downloaded (A/B). Approved N ago. Requested by Y."
|
||||
const partial = description.match(/^Only .+?\. Approved (.+?) ago\. Requested by (.+?)\.?$/);
|
||||
if (partial) {
|
||||
const [, age, reqStr] = partial;
|
||||
const names = reqStr.split(", ").filter(Boolean);
|
||||
return (
|
||||
<DescriptionTable rows={[
|
||||
{ label: "Requested by", chips: <RequesterChips names={names} requesterIds={requesterIds} seerrUrl={seerrUrl} /> },
|
||||
{ label: "Approved", value: `${age} ago` },
|
||||
]} />
|
||||
);
|
||||
}
|
||||
// Complete miss: "Approved N ago but no file found in Radarr/Sonarr. Requested by Y."
|
||||
const complete = description.match(/^Approved (.+?) ago but (.+?)\. Requested by (.+?)\.?$/);
|
||||
if (complete) {
|
||||
const [, age, detail, reqStr] = complete;
|
||||
const names = reqStr.split(", ").filter(Boolean);
|
||||
return (
|
||||
<DescriptionTable rows={[
|
||||
{ label: "Requested by", chips: <RequesterChips names={names} requesterIds={requesterIds} seerrUrl={seerrUrl} /> },
|
||||
{ label: "Approved", value: `${age} ago` },
|
||||
{ label: "Details", value: detail.charAt(0).toUpperCase() + detail.slice(1) },
|
||||
]} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (category === "pending") {
|
||||
// "Awaiting approval for N days. Requested by Y."
|
||||
const m = description.match(/^Awaiting approval for (.+?)\. Requested by (.+?)\.?$/);
|
||||
if (m) {
|
||||
const [, age, reqStr] = m;
|
||||
const names = reqStr.split(", ").filter(Boolean);
|
||||
return (
|
||||
<DescriptionTable rows={[
|
||||
{ label: "Requested by", chips: <RequesterChips names={names} requesterIds={requesterIds} seerrUrl={seerrUrl} /> },
|
||||
{ label: "Waiting", value: age },
|
||||
]} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: plain prose (ghost, watchrate handled separately, tautulli-no-matches)
|
||||
return <p className="text-sm text-slate-400 leading-relaxed">{description}</p>;
|
||||
}
|
||||
|
||||
// ── Watch rate stat block ─────────────────────────────────────────────────────
|
||||
|
||||
function WatchrateBlock({ plays, requests, pct }: { plays: number; requests: number; pct: number }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-700/60 bg-slate-900/50 px-4 py-3">
|
||||
<div className="flex items-end gap-4 flex-wrap">
|
||||
<div>
|
||||
<div className="text-xl font-bold tabular-nums text-white">{plays.toLocaleString()}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">plays</div>
|
||||
</div>
|
||||
<div className="text-slate-700 pb-4 text-base">/</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold tabular-nums text-white">{requests}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">requests</div>
|
||||
</div>
|
||||
<div className="text-slate-700 pb-4 text-base">=</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold tabular-nums text-blue-400">{pct}%</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">watch rate</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-600">Alert threshold: <20%</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Comment row ───────────────────────────────────────────────────────────────
|
||||
|
||||
function CommentRow({ comment }: { comment: AlertComment }) {
|
||||
@@ -70,7 +235,7 @@ function CommentRow({ comment }: { comment: AlertComment }) {
|
||||
<div className="h-px flex-1 bg-slate-800" />
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-600 shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-3 w-3">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.43l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
<span className="italic">{comment.body}</span>
|
||||
@@ -102,9 +267,33 @@ function CommentRow({ comment }: { comment: AlertComment }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers: parse structured data from description prose ─────────────────────
|
||||
|
||||
function parseEpisodeCounts(desc: string): { downloaded: number; total: number } | null {
|
||||
const m = desc.match(/\((\d+)\/(\d+)\)/);
|
||||
if (!m) return null;
|
||||
return { downloaded: parseInt(m[1]), total: parseInt(m[2]) };
|
||||
}
|
||||
|
||||
function parseWatchrateStats(desc: string): { plays: number; requests: number; pct: number } | null {
|
||||
const pctM = desc.match(/~(\d+)%/);
|
||||
const playsM = desc.match(/\((\d+) plays/);
|
||||
const reqM = desc.match(/plays, (\d+) requests\)/);
|
||||
if (!pctM || !playsM || !reqM) return null;
|
||||
return { pct: parseInt(pctM[1]), plays: parseInt(playsM[1]), requests: parseInt(reqM[1]) };
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
|
||||
interface Props {
|
||||
initialAlert: Alert;
|
||||
radarrUrl?: string;
|
||||
sonarrUrl?: string;
|
||||
seerrUrl?: string;
|
||||
}
|
||||
|
||||
export default function AlertDetail({ initialAlert, radarrUrl, sonarrUrl, seerrUrl }: Props) {
|
||||
const router = useRouter();
|
||||
const [alert, setAlert] = useState<Alert>(initialAlert);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [commentText, setCommentText] = useState("");
|
||||
@@ -130,8 +319,6 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
|
||||
const updated: Alert = await res.json();
|
||||
setAlert(updated);
|
||||
|
||||
// Keep the cached dashboard count in sync so the badge and summary
|
||||
// card reflect the change immediately when navigating back.
|
||||
try {
|
||||
const raw = localStorage.getItem("oversnitch_stats");
|
||||
if (raw) {
|
||||
@@ -172,21 +359,47 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
|
||||
|
||||
const isOpen = alert.status === "open";
|
||||
const isResolved = alert.closeReason === "resolved";
|
||||
const statusTime = isOpen ? alert.firstSeen : (alert.closedAt ?? alert.firstSeen);
|
||||
|
||||
// ── Derived data ────────────────────────────────────────────────────────────
|
||||
|
||||
// Search fallback when media exists in description but not yet in *arr
|
||||
const searchUrl =
|
||||
!alert.mediaUrl && alert.mediaTitle
|
||||
? alert.mediaType === "movie" && radarrUrl
|
||||
? `${radarrUrl}/add/new?term=${encodeURIComponent(alert.mediaTitle)}`
|
||||
: alert.mediaType === "tv" && sonarrUrl
|
||||
? `${sonarrUrl}/add/new?term=${encodeURIComponent(alert.mediaTitle)}`
|
||||
: null
|
||||
: null;
|
||||
|
||||
// User dashboard link for behavior alerts
|
||||
const userLink =
|
||||
(alert.category === "ghost" || alert.category === "watchrate") && alert.userId
|
||||
? `/?tab=leaderboard`
|
||||
: null;
|
||||
|
||||
// Category-specific parsed data
|
||||
const episodeCounts =
|
||||
alert.category === "unfulfilled" && alert.mediaType === "tv"
|
||||
? parseEpisodeCounts(alert.description)
|
||||
: null;
|
||||
|
||||
const watchrateStats =
|
||||
alert.category === "watchrate" ? parseWatchrateStats(alert.description) : null;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-8 space-y-6">
|
||||
|
||||
{/* Back */}
|
||||
<Link
|
||||
href="/?tab=alerts"
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
All Alerts
|
||||
</Link>
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-800 bg-red-950/30 px-4 py-3 text-red-300 text-sm">
|
||||
@@ -196,10 +409,11 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
|
||||
|
||||
{/* ── Alert overview ──────────────────────────────────────────────── */}
|
||||
<div className={`rounded-xl bg-slate-800/40 border border-slate-700/60 border-l-4 overflow-hidden ${severityAccent[alert.severity]}`}>
|
||||
<div className="px-6 py-6 space-y-5">
|
||||
<div className="px-6 py-6 space-y-4">
|
||||
|
||||
{/* Top row: status + action */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
{/* Top row: severity + status | action buttons */}
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
{/* Left: severity + status + timestamps */}
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<span className={`text-xs font-bold uppercase tracking-widest ${severityText[alert.severity]}`}>
|
||||
{severityLabel[alert.severity]}
|
||||
@@ -212,9 +426,53 @@ 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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Primary: view in Radarr/Sonarr */}
|
||||
{alert.mediaUrl && (
|
||||
<a
|
||||
href={alert.mediaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-600 bg-slate-700/60 hover:bg-slate-700 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
{alert.mediaType === "movie" ? "View in Radarr" : "View in Sonarr"}
|
||||
<ExternalIcon />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* View in Seerr */}
|
||||
{alert.seerrMediaUrl && (
|
||||
<a
|
||||
href={alert.seerrMediaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-600 bg-slate-700/60 hover:bg-slate-700 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
View in Seerr
|
||||
<ExternalIcon />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Close / Reopen */}
|
||||
<button
|
||||
onClick={toggleStatus}
|
||||
disabled={actionLoading}
|
||||
@@ -227,42 +485,64 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
|
||||
{actionLoading ? "…" : isOpen ? "Close" : "Reopen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title + description */}
|
||||
<div className="space-y-2">
|
||||
{/* Title */}
|
||||
<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>
|
||||
|
||||
{/* Metadata row */}
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-slate-700/30">
|
||||
{/* Category body */}
|
||||
<div className="space-y-3">
|
||||
|
||||
{/* Dates */}
|
||||
<Chip label={`Opened ${fullDate(alert.firstSeen)}`} dim />
|
||||
{!isOpen && alert.closedAt && (
|
||||
<Chip label={`${isResolved ? "Resolved" : "Closed"} ${fullDate(alert.closedAt)}`} dim />
|
||||
{/* Description — structured for content alerts, prose for others */}
|
||||
{alert.category !== "watchrate" && (
|
||||
<ContentDescription
|
||||
description={alert.description}
|
||||
category={alert.category}
|
||||
requesterIds={alert.requesterIds}
|
||||
seerrUrl={seerrUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* User (for user-behavior alerts) */}
|
||||
{alert.userName && !alert.mediaTitle && (
|
||||
<Chip label={alert.userName} />
|
||||
{/* Watchrate structured stat block */}
|
||||
{watchrateStats && (
|
||||
<WatchrateBlock
|
||||
plays={watchrateStats.plays}
|
||||
requests={watchrateStats.requests}
|
||||
pct={watchrateStats.pct}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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"
|
||||
{/* 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.mediaType === "movie" ? "View in Radarr" : "View in Sonarr"}
|
||||
{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 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" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata footer — closed/resolved date only */}
|
||||
{!isOpen && alert.closedAt && (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-slate-700/30">
|
||||
<Chip label={`${isResolved ? "Resolved" : "Closed"} ${fullDate(alert.closedAt)}`} dim />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -296,7 +576,8 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
|
||||
rows={3}
|
||||
className="w-full rounded-lg bg-slate-800/40 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-700 px-4 py-3 focus:outline-none resize-none transition-colors"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-700">⌘↵ to submit</span>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={commentLoading || !commentText.trim()}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getAlertById } from "@/lib/db";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { notFound } from "next/navigation";
|
||||
import AlertDetail from "./AlertDetail";
|
||||
|
||||
@@ -10,5 +11,13 @@ export default async function AlertPage({
|
||||
const { id } = await params;
|
||||
const alert = getAlertById(Number(id));
|
||||
if (!alert) notFound();
|
||||
return <AlertDetail initialAlert={alert} />;
|
||||
const { radarr, sonarr, seerr } = getSettings();
|
||||
return (
|
||||
<AlertDetail
|
||||
initialAlert={alert}
|
||||
radarrUrl={radarr.url || undefined}
|
||||
sonarrUrl={sonarr.url || undefined}
|
||||
seerrUrl={seerr.url || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
16
src/app/api/settings/route.ts
Normal file
16
src/app/api/settings/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getSettings, saveSettings, AppSettings } from "@/lib/settings";
|
||||
|
||||
export async function GET() {
|
||||
return Response.json(getSettings());
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as AppSettings;
|
||||
const saved = saveSettings(body);
|
||||
return Response.json(saved);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return Response.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
121
src/app/api/settings/test/route.ts
Normal file
121
src/app/api/settings/test/route.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* POST /api/settings/test
|
||||
* Tests connectivity to a service using the provided URL + API key.
|
||||
* Does NOT save anything — purely a connectivity check.
|
||||
*/
|
||||
|
||||
import { sendDiscordTestNotification } from "@/lib/discord";
|
||||
|
||||
interface TestBody {
|
||||
service: "radarr" | "sonarr" | "seerr" | "tautulli" | "discord";
|
||||
url: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const TIMEOUT_MS = 10_000;
|
||||
|
||||
async function testRadarr(url: string, apiKey: string): Promise<TestResult> {
|
||||
const res = await fetch(`${url}/api/v3/system/status`, {
|
||||
headers: { "X-Api-Key": apiKey },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return { ok: false, message: `HTTP ${res.status} ${res.statusText}` };
|
||||
const data = await res.json() as { version?: string };
|
||||
return { ok: true, message: `Connected${data.version ? ` (v${data.version})` : ""}` };
|
||||
}
|
||||
|
||||
async function testSonarr(url: string, apiKey: string): Promise<TestResult> {
|
||||
const res = await fetch(`${url}/api/v3/system/status`, {
|
||||
headers: { "X-Api-Key": apiKey },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return { ok: false, message: `HTTP ${res.status} ${res.statusText}` };
|
||||
const data = await res.json() as { version?: string };
|
||||
return { ok: true, message: `Connected${data.version ? ` (v${data.version})` : ""}` };
|
||||
}
|
||||
|
||||
async function testSeerr(url: string, apiKey: string): Promise<TestResult> {
|
||||
const res = await fetch(`${url}/api/v1/status`, {
|
||||
headers: { "X-Api-Key": apiKey },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return { ok: false, message: `HTTP ${res.status} ${res.statusText}` };
|
||||
const data = await res.json() as { version?: string };
|
||||
return { ok: true, message: `Connected${data.version ? ` (v${data.version})` : ""}` };
|
||||
}
|
||||
|
||||
async function testTautulli(url: string, apiKey: string): Promise<TestResult> {
|
||||
const res = await fetch(
|
||||
`${url}/api/v2?apikey=${encodeURIComponent(apiKey)}&cmd=get_server_info`,
|
||||
{ signal: AbortSignal.timeout(TIMEOUT_MS) }
|
||||
);
|
||||
if (!res.ok) return { ok: false, message: `HTTP ${res.status} ${res.statusText}` };
|
||||
const data = await res.json() as { response?: { result?: string; data?: { pms_version?: string } } };
|
||||
if (data.response?.result !== "success") {
|
||||
return { ok: false, message: "Tautulli returned a non-success result" };
|
||||
}
|
||||
const ver = data.response.data?.pms_version;
|
||||
return { ok: true, message: `Connected${ver ? ` (Plex ${ver})` : ""}` };
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { service, url, apiKey } = await req.json() as TestBody;
|
||||
|
||||
// Discord only needs the webhook URL (passed as `url`)
|
||||
if (service === "discord") {
|
||||
if (!url) {
|
||||
return Response.json({ ok: false, message: "Webhook URL is required" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
return Response.json({ ok: false, message: "Invalid URL" }, { status: 400 });
|
||||
}
|
||||
await sendDiscordTestNotification(url.trim());
|
||||
return Response.json({ ok: true, message: "Test message sent — check your channel" });
|
||||
}
|
||||
|
||||
if (!url || !apiKey) {
|
||||
return Response.json({ ok: false, message: "URL and API key are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Basic URL validation
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
return Response.json({ ok: false, message: "URL must use http or https" }, { status: 400 });
|
||||
}
|
||||
} catch {
|
||||
return Response.json({ ok: false, message: "Invalid URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
const trimmed = url.replace(/\/+$/, "");
|
||||
|
||||
let result: TestResult;
|
||||
switch (service) {
|
||||
case "radarr": result = await testRadarr(trimmed, apiKey); break;
|
||||
case "sonarr": result = await testSonarr(trimmed, apiKey); break;
|
||||
case "seerr": result = await testSeerr(trimmed, apiKey); break;
|
||||
case "tautulli": result = await testTautulli(trimmed, apiKey); break;
|
||||
default:
|
||||
return Response.json({ ok: false, message: "Unknown service" }, { status: 400 });
|
||||
}
|
||||
|
||||
return Response.json(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Provide friendlier messages for common failures
|
||||
const friendly = message.includes("fetch failed") || message.includes("ECONNREFUSED")
|
||||
? "Connection refused — check the URL and that the service is running"
|
||||
: message.includes("TimeoutError") || message.includes("abort")
|
||||
? "Connection timed out"
|
||||
: message;
|
||||
return Response.json({ ok: false, message: friendly });
|
||||
}
|
||||
}
|
||||
@@ -1,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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
428
src/components/SettingsModal.tsx
Normal file
428
src/components/SettingsModal.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { AppSettings, ServiceConfig, DiscordConfig } from "@/lib/settings";
|
||||
|
||||
// ── Icons ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function EyeIcon({ open }: { open: boolean }) {
|
||||
return open ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Per-service section ───────────────────────────────────────────────────────
|
||||
|
||||
type ServiceKey = "radarr" | "sonarr" | "seerr" | "tautulli";
|
||||
|
||||
interface SectionProps {
|
||||
id: ServiceKey;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
optional?: boolean;
|
||||
config: ServiceConfig;
|
||||
onChange: (patch: Partial<ServiceConfig>) => void;
|
||||
}
|
||||
|
||||
function ServiceSection({ id, label, placeholder, optional, config, onChange }: SectionProps) {
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ service: id, url: config.url.trim(), apiKey: config.apiKey.trim() }),
|
||||
});
|
||||
const data = await res.json() as { ok: boolean; message: string };
|
||||
setTestResult(data);
|
||||
} catch {
|
||||
setTestResult({ ok: false, message: "Network error" });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear test result when inputs change
|
||||
function handleUrlChange(v: string) {
|
||||
setTestResult(null);
|
||||
onChange({ url: v });
|
||||
}
|
||||
function handleKeyChange(v: string) {
|
||||
setTestResult(null);
|
||||
onChange({ apiKey: v });
|
||||
}
|
||||
|
||||
const canTest = config.url.trim().length > 0 && config.apiKey.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-200">{label}</h3>
|
||||
{optional && (
|
||||
<span className="text-xs text-slate-600 font-normal">optional</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* URL */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.url}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">API Key</label>
|
||||
<div className="flex flex-1 gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showKey ? "text" : "password"}
|
||||
value={config.apiKey}
|
||||
onChange={(e) => handleKeyChange(e.target.value)}
|
||||
placeholder="••••••••••••••••••••••••••••••••"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-700 px-3 py-2 pr-9 focus:outline-none transition-colors font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey((s) => !s)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-600 hover:text-slate-400 transition-colors"
|
||||
tabIndex={-1}
|
||||
aria-label={showKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
<EyeIcon open={showKey} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={!canTest || testing}
|
||||
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
|
||||
>
|
||||
{testing ? "Testing…" : "Test"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
|
||||
{testResult.ok ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Discord section (webhook URL only, no API key) ────────────────────────────
|
||||
|
||||
interface DiscordSectionProps {
|
||||
config: DiscordConfig;
|
||||
onChange: (patch: Partial<DiscordConfig>) => void;
|
||||
}
|
||||
|
||||
function DiscordSection({ config, onChange }: DiscordSectionProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ service: "discord", url: config.webhookUrl.trim(), apiKey: "" }),
|
||||
});
|
||||
const data = await res.json() as { ok: boolean; message: string };
|
||||
setTestResult(data);
|
||||
} catch {
|
||||
setTestResult({ ok: false, message: "Network error" });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(v: string) {
|
||||
setTestResult(null);
|
||||
onChange({ webhookUrl: v });
|
||||
}
|
||||
|
||||
const canTest = config.webhookUrl.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-200">Discord</h3>
|
||||
<span className="text-xs text-slate-600 font-normal">optional</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">Webhook</label>
|
||||
<div className="flex flex-1 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={config.webhookUrl}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="https://discord.com/api/webhooks/…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={!canTest || testing}
|
||||
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
|
||||
>
|
||||
{testing ? "Sending…" : "Test"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
|
||||
{testResult.ok ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Modal ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
const EMPTY_CONFIG: ServiceConfig = { url: "", apiKey: "" };
|
||||
const EMPTY_SETTINGS: AppSettings = {
|
||||
radarr: EMPTY_CONFIG,
|
||||
sonarr: EMPTY_CONFIG,
|
||||
seerr: EMPTY_CONFIG,
|
||||
tautulli: EMPTY_CONFIG,
|
||||
discord: { webhookUrl: "" },
|
||||
};
|
||||
|
||||
export default function SettingsModal({ open, onClose, onSaved }: Props) {
|
||||
const [settings, setSettings] = useState<AppSettings>(EMPTY_SETTINGS);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveResult, setSaveResult] = useState<"saved" | "error" | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load current settings when modal opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSaveResult(null);
|
||||
setLoading(true);
|
||||
fetch("/api/settings")
|
||||
.then((r) => r.json())
|
||||
.then((data: AppSettings) => setSettings(data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [open]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Close on backdrop click
|
||||
function handleBackdrop(e: React.MouseEvent) {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function patch(service: "radarr" | "sonarr" | "seerr" | "tautulli", partial: Partial<ServiceConfig>) {
|
||||
setSaveResult(null);
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
[service]: { ...prev[service], ...partial },
|
||||
}));
|
||||
}
|
||||
|
||||
function patchDiscord(partial: Partial<DiscordConfig>) {
|
||||
setSaveResult(null);
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
discord: { ...prev.discord, ...partial },
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setSaveResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setSaveResult("saved");
|
||||
onSaved?.();
|
||||
} catch {
|
||||
setSaveResult("error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleBackdrop}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="relative w-full max-w-lg rounded-2xl bg-slate-900 border border-slate-700/60 shadow-2xl flex flex-col max-h-[90vh]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-slate-800 shrink-0">
|
||||
<h2 className="text-base font-semibold text-white">Settings</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-500 hover:text-slate-300 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1 px-6 py-5">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<svg className="animate-spin h-5 w-5 text-slate-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<ServiceSection
|
||||
id="radarr"
|
||||
label="Radarr"
|
||||
placeholder="http://radarr:7878"
|
||||
config={settings.radarr}
|
||||
onChange={(p) => patch("radarr", p)}
|
||||
/>
|
||||
<ServiceSection
|
||||
id="sonarr"
|
||||
label="Sonarr"
|
||||
placeholder="http://sonarr:8989"
|
||||
config={settings.sonarr}
|
||||
onChange={(p) => patch("sonarr", p)}
|
||||
/>
|
||||
<ServiceSection
|
||||
id="seerr"
|
||||
label="Overseerr / Jellyseerr"
|
||||
placeholder="http://overseerr:5055"
|
||||
config={settings.seerr}
|
||||
onChange={(p) => patch("seerr", p)}
|
||||
/>
|
||||
<ServiceSection
|
||||
id="tautulli"
|
||||
label="Tautulli"
|
||||
placeholder="http://tautulli:8181"
|
||||
optional
|
||||
config={settings.tautulli}
|
||||
onChange={(p) => patch("tautulli", p)}
|
||||
/>
|
||||
<DiscordSection
|
||||
config={settings.discord}
|
||||
onChange={(p) => patchDiscord(p)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-slate-800 shrink-0 gap-3">
|
||||
<div className="text-xs">
|
||||
{saveResult === "saved" && (
|
||||
<span className="text-green-400">Saved — click Refresh to reload data</span>
|
||||
)}
|
||||
{saveResult === "error" && (
|
||||
<span className="text-red-400">Save failed — check the console</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-slate-700 bg-slate-800/60 hover:bg-slate-700 px-4 py-2 text-sm font-medium text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading}
|
||||
className="rounded-lg bg-yellow-500 hover:bg-yellow-400 disabled:opacity-40 disabled:cursor-not-allowed px-4 py-2 text-sm font-semibold text-black transition-colors"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
553
src/lib/db.ts
553
src/lib/db.ts
@@ -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,95 +36,258 @@ 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;
|
||||
}
|
||||
return JSON.parse(readFileSync(DB_PATH, "utf-8")) as Store;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function save(store: Store) {
|
||||
writeFileSync(DB_PATH, JSON.stringify(store, null, 2));
|
||||
// ── 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 {}
|
||||
|
||||
}
|
||||
|
||||
function toAlert(s: StoredAlert): Alert {
|
||||
// ── 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
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 ─────────────────────────────────────────────
|
||||
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'"
|
||||
);
|
||||
|
||||
const newAlerts: AlertCandidate[] = [];
|
||||
|
||||
db.transaction(() => {
|
||||
// ── Step 1: upsert candidates ───────────────────────────────────────────
|
||||
for (const c of candidates) {
|
||||
const existing = store.alerts[c.key];
|
||||
const existing = getByKey.get(c.key);
|
||||
|
||||
if (existing) {
|
||||
const isSuppressed =
|
||||
@@ -128,98 +297,126 @@ export function upsertAlerts(candidates: AlertCandidate[]): number {
|
||||
|
||||
if (isSuppressed) continue;
|
||||
|
||||
// Re-open if previously closed (manually or resolved) and not suppressed.
|
||||
// Preserve firstSeen and comments — this is the same incident continuing.
|
||||
const requesterIds = c.requesterIds?.length ? JSON.stringify(c.requesterIds) : null;
|
||||
const seerrMediaUrl = c.seerrMediaUrl ?? null;
|
||||
|
||||
if (existing.status === "closed") {
|
||||
existing.status = "open";
|
||||
existing.closeReason = null;
|
||||
existing.closedAt = null;
|
||||
existing.suppressedUntil = null;
|
||||
existing.comments.push({
|
||||
id: store.nextCommentId++,
|
||||
// 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.",
|
||||
createdAt: nowISO,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// 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++,
|
||||
// New alert — notify
|
||||
insertAlert.run({
|
||||
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,
|
||||
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,
|
||||
closedAt: null,
|
||||
comments: [],
|
||||
};
|
||||
requesterIds: c.requesterIds?.length ? JSON.stringify(c.requesterIds) : null,
|
||||
seerrMediaUrl: c.seerrMediaUrl ?? null,
|
||||
});
|
||||
newAlerts.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
|
||||
// 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++,
|
||||
// ── 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.",
|
||||
createdAt: nowISO,
|
||||
author: "system",
|
||||
createdAt: nowISO,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
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
208
src/lib/discord.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Discord webhook notifications.
|
||||
* Fired on newly opened or reopened alerts. Batches up to 10 embeds per
|
||||
* message to stay within Discord's limits.
|
||||
*/
|
||||
|
||||
import { AlertCandidate } from "@/lib/types";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
|
||||
// Discord embed colors per severity
|
||||
const SEVERITY_COLOR: Record<string, number> = {
|
||||
danger: 0xef4444, // red-500
|
||||
warning: 0xeab308, // yellow-500
|
||||
info: 0x3b82f6, // blue-500
|
||||
};
|
||||
|
||||
const SEVERITY_LABEL: Record<string, string> = {
|
||||
danger: "Critical",
|
||||
warning: "Warning",
|
||||
info: "Info",
|
||||
};
|
||||
|
||||
type EmbedField = { name: string; value: string; inline: boolean };
|
||||
|
||||
interface DiscordEmbed {
|
||||
title: string;
|
||||
description?: string;
|
||||
color: number;
|
||||
url?: string;
|
||||
fields: EmbedField[];
|
||||
footer: { text: string };
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// ── Description parsers (mirror AlertDetail.tsx regex patterns) ───────────────
|
||||
|
||||
function parseUnfulfilledComplete(desc: string) {
|
||||
const m = desc.match(/^Approved (.+?) ago but (.+?)\. Requested by (.+?)\.?$/);
|
||||
if (!m) return null;
|
||||
return { age: m[1], detail: m[2], requesters: m[3] };
|
||||
}
|
||||
|
||||
function parseUnfulfilledPartial(desc: string) {
|
||||
// "Only X% of episodes downloaded (A/B). Approved N ago. Requested by Y."
|
||||
const m = desc.match(/^Only .+?\. Approved (.+?) ago\. Requested by (.+?)\.?$/);
|
||||
if (!m) return null;
|
||||
const eps = desc.match(/\((\d+)\/(\d+)\)/);
|
||||
return {
|
||||
age: m[1],
|
||||
requesters: m[2],
|
||||
downloaded: eps ? parseInt(eps[1]) : null,
|
||||
total: eps ? parseInt(eps[2]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePending(desc: string) {
|
||||
const m = desc.match(/^Awaiting approval for (.+?)\. Requested by (.+?)\.?$/);
|
||||
if (!m) return null;
|
||||
return { age: m[1], requesters: m[2] };
|
||||
}
|
||||
|
||||
function parseWatchrate(desc: string) {
|
||||
const pctM = desc.match(/~(\d+)%/);
|
||||
const playsM = desc.match(/\((\d+) plays/);
|
||||
const reqM = desc.match(/plays, (\d+) requests\)/);
|
||||
if (!pctM || !playsM || !reqM) return null;
|
||||
return { pct: pctM[1], plays: playsM[1], requests: reqM[1] };
|
||||
}
|
||||
|
||||
// ── Embed builder ─────────────────────────────────────────────────────────────
|
||||
|
||||
function buildEmbed(alert: AlertCandidate): DiscordEmbed {
|
||||
const color = SEVERITY_COLOR[alert.severity] ?? SEVERITY_COLOR.info;
|
||||
const footer = { text: `OverSnitch · ${SEVERITY_LABEL[alert.severity] ?? alert.severity}` };
|
||||
const timestamp = new Date().toISOString();
|
||||
// Title links to Seerr media page if available, otherwise *arr
|
||||
const url = alert.seerrMediaUrl ?? alert.mediaUrl ?? undefined;
|
||||
const fields: EmbedField[] = [];
|
||||
|
||||
// ── unfulfilled ────────────────────────────────────────────────────────────
|
||||
if (alert.category === "unfulfilled") {
|
||||
const partial = parseUnfulfilledPartial(alert.description);
|
||||
if (partial) {
|
||||
fields.push({ name: "Requested by", value: partial.requesters, inline: true });
|
||||
fields.push({ name: "Approved", value: `${partial.age} ago`, inline: true });
|
||||
if (partial.downloaded !== null && partial.total !== null) {
|
||||
const pct = Math.round((partial.downloaded / partial.total) * 100);
|
||||
fields.push({
|
||||
name: "Downloaded",
|
||||
value: `${partial.downloaded} / ${partial.total} episodes (${pct}%)`,
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
return { title: alert.title, color, url, fields, footer, timestamp };
|
||||
}
|
||||
|
||||
const complete = parseUnfulfilledComplete(alert.description);
|
||||
if (complete) {
|
||||
fields.push({ name: "Requested by", value: complete.requesters, inline: true });
|
||||
fields.push({ name: "Approved", value: `${complete.age} ago`, inline: true });
|
||||
// Capitalise "no file found in Radarr" → "No file found in Radarr"
|
||||
const detail = complete.detail.charAt(0).toUpperCase() + complete.detail.slice(1);
|
||||
fields.push({ name: "Status", value: detail, inline: false });
|
||||
return { title: alert.title, color, url, fields, footer, timestamp };
|
||||
}
|
||||
}
|
||||
|
||||
// ── pending ────────────────────────────────────────────────────────────────
|
||||
if (alert.category === "pending") {
|
||||
const p = parsePending(alert.description);
|
||||
if (p) {
|
||||
fields.push({ name: "Requested by", value: p.requesters, inline: true });
|
||||
fields.push({ name: "Waiting", value: p.age, inline: true });
|
||||
return { title: alert.title, color, url, fields, footer, timestamp };
|
||||
}
|
||||
}
|
||||
|
||||
// ── ghost ──────────────────────────────────────────────────────────────────
|
||||
if (alert.category === "ghost" && alert.userName) {
|
||||
fields.push({ name: "User", value: alert.userName, inline: false });
|
||||
// Trim the redundant name prefix from the description
|
||||
// "Peri Wright has made 8 requests but hasn't watched…"
|
||||
// → "Has made 8 requests but hasn't watched…"
|
||||
const desc = alert.description.replace(
|
||||
new RegExp(`^${alert.userName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s+`),
|
||||
""
|
||||
);
|
||||
const sentence = desc.charAt(0).toUpperCase() + desc.slice(1);
|
||||
return { title: alert.title, description: sentence, color, url, fields, footer, timestamp };
|
||||
}
|
||||
|
||||
// ── watchrate ──────────────────────────────────────────────────────────────
|
||||
if (alert.category === "watchrate") {
|
||||
const w = parseWatchrate(alert.description);
|
||||
if (w && alert.userName) {
|
||||
fields.push({ name: "User", value: alert.userName, inline: true });
|
||||
fields.push({ name: "Watch rate", value: `~${w.pct}%`, inline: true });
|
||||
fields.push({ name: "Plays", value: w.plays, inline: true });
|
||||
fields.push({ name: "Requests", value: w.requests, inline: true });
|
||||
return { title: alert.title, color, url, fields, footer, timestamp };
|
||||
}
|
||||
}
|
||||
|
||||
// ── fallback: plain description ────────────────────────────────────────────
|
||||
return {
|
||||
title: alert.title,
|
||||
description: alert.description,
|
||||
color,
|
||||
url,
|
||||
fields,
|
||||
footer,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Transport ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function postToWebhook(webhookUrl: string, embeds: DiscordEmbed[]): Promise<void> {
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "OverSnitch", embeds }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Discord webhook error ${res.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one Discord message per batch of up to 10 alerts.
|
||||
* Silently no-ops if no webhook URL is configured.
|
||||
* Errors are logged but never thrown.
|
||||
*/
|
||||
export async function sendDiscordNotifications(alerts: AlertCandidate[]): Promise<void> {
|
||||
if (alerts.length === 0) return;
|
||||
|
||||
const { discord } = getSettings();
|
||||
if (!discord.webhookUrl) return;
|
||||
|
||||
const embeds = alerts.map(buildEmbed);
|
||||
|
||||
// Discord allows up to 10 embeds per message
|
||||
for (let i = 0; i < embeds.length; i += 10) {
|
||||
try {
|
||||
await postToWebhook(discord.webhookUrl, embeds.slice(i, i + 10));
|
||||
} catch (err) {
|
||||
console.error("[discord] Failed to send notification:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a single test embed. Used by the settings test endpoint.
|
||||
*/
|
||||
export async function sendDiscordTestNotification(webhookUrl: string): Promise<void> {
|
||||
await postToWebhook(webhookUrl, [
|
||||
{
|
||||
title: "OverSnitch — Test Notification",
|
||||
description: "Your Discord webhook is configured correctly. You'll receive alerts here when new issues are detected.",
|
||||
color: SEVERITY_COLOR.info,
|
||||
fields: [],
|
||||
footer: { text: "OverSnitch" },
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { OverseerrUser, OverseerrRequest } from "@/lib/types";
|
||||
import { 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) {
|
||||
|
||||
@@ -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
89
src/lib/settings.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Persistent settings store.
|
||||
*
|
||||
* Settings are read from data/settings.json when present, with process.env
|
||||
* values used as fallbacks. This means existing .env.local setups keep working
|
||||
* with no changes; the UI just provides an alternative way to configure them.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const DATA_DIR = join(process.cwd(), "data");
|
||||
const SETTINGS_PATH = join(DATA_DIR, "settings.json");
|
||||
|
||||
export interface ServiceConfig {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export interface DiscordConfig {
|
||||
webhookUrl: string;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
radarr: ServiceConfig;
|
||||
sonarr: ServiceConfig;
|
||||
seerr: ServiceConfig;
|
||||
tautulli: ServiceConfig;
|
||||
discord: DiscordConfig;
|
||||
}
|
||||
|
||||
interface StoredSettings {
|
||||
radarr?: Partial<ServiceConfig>;
|
||||
sonarr?: Partial<ServiceConfig>;
|
||||
seerr?: Partial<ServiceConfig>;
|
||||
tautulli?: Partial<ServiceConfig>;
|
||||
discord?: Partial<DiscordConfig>;
|
||||
}
|
||||
|
||||
function readFile(): StoredSettings {
|
||||
try {
|
||||
return JSON.parse(readFileSync(SETTINGS_PATH, "utf-8")) as StoredSettings;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the merged settings (file values override env vars). */
|
||||
export function getSettings(): AppSettings {
|
||||
const f = readFile();
|
||||
return {
|
||||
radarr: {
|
||||
url: f.radarr?.url ?? process.env.RADARR_URL ?? "",
|
||||
apiKey: f.radarr?.apiKey ?? process.env.RADARR_API ?? "",
|
||||
},
|
||||
sonarr: {
|
||||
url: f.sonarr?.url ?? process.env.SONARR_URL ?? "",
|
||||
apiKey: f.sonarr?.apiKey ?? process.env.SONARR_API ?? "",
|
||||
},
|
||||
seerr: {
|
||||
url: f.seerr?.url ?? process.env.SEERR_URL ?? "",
|
||||
apiKey: f.seerr?.apiKey ?? process.env.SEERR_API ?? "",
|
||||
},
|
||||
tautulli: {
|
||||
url: f.tautulli?.url ?? process.env.TAUTULLI_URL ?? "",
|
||||
apiKey: f.tautulli?.apiKey ?? process.env.TAUTULLI_API ?? "",
|
||||
},
|
||||
discord: {
|
||||
webhookUrl: f.discord?.webhookUrl ?? process.env.DISCORD_WEBHOOK ?? "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Saves the provided settings to disk and returns the merged result. */
|
||||
export function saveSettings(settings: AppSettings): AppSettings {
|
||||
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
// Strip trailing slashes from URLs for consistency
|
||||
const clean: StoredSettings = {
|
||||
radarr: { url: settings.radarr.url.replace(/\/+$/, ""), apiKey: settings.radarr.apiKey },
|
||||
sonarr: { url: settings.sonarr.url.replace(/\/+$/, ""), apiKey: settings.sonarr.apiKey },
|
||||
seerr: { url: settings.seerr.url.replace(/\/+$/, ""), apiKey: settings.seerr.apiKey },
|
||||
tautulli: { url: settings.tautulli.url.replace(/\/+$/, ""), apiKey: settings.tautulli.apiKey },
|
||||
discord: { webhookUrl: settings.discord.webhookUrl.trim() },
|
||||
};
|
||||
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify(clean, null, 2), "utf-8");
|
||||
return getSettings();
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { SonarrSeries, MediaEntry } from "@/lib/types";
|
||||
import { 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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -116,6 +116,9 @@ export interface AlertCandidate {
|
||||
mediaType?: "movie" | "tv";
|
||||
mediaTitle?: string;
|
||||
mediaUrl?: string; // direct link to the item in Radarr/Sonarr
|
||||
seerrMediaUrl?: string; // link to the item's page in Overseerr/Jellyseerr
|
||||
// Ordered list of Overseerr user IDs who triggered this alert (content alerts)
|
||||
requesterIds?: number[];
|
||||
}
|
||||
|
||||
export interface AlertComment {
|
||||
|
||||
Reference in New Issue
Block a user