From 641a7fd096e1929db5beefc74b7fbf5617750370 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 12 Apr 2026 14:57:07 -0400 Subject: [PATCH] Add settings UI, Discord notifications, and alert detail improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 7 + README.md | 46 ++- package-lock.json | 445 ++++++++++++++++++++- package.json | 2 + src/app/alerts/[id]/AlertDetail.tsx | 395 +++++++++++++++--- src/app/alerts/[id]/page.tsx | 11 +- src/app/api/settings/route.ts | 16 + src/app/api/settings/test/route.ts | 121 ++++++ src/app/page.tsx | 48 ++- src/components/SettingsModal.tsx | 428 ++++++++++++++++++++ src/lib/aggregate.ts | 6 +- src/lib/alerts.ts | 40 +- src/lib/db.ts | 597 +++++++++++++++++++--------- src/lib/discord.ts | 208 ++++++++++ src/lib/overseerr.ts | 11 +- src/lib/radarr.ts | 6 +- src/lib/settings.ts | 89 +++++ src/lib/sonarr.ts | 6 +- src/lib/tautulli.ts | 6 +- src/lib/types.ts | 5 +- 20 files changed, 2191 insertions(+), 302 deletions(-) create mode 100644 src/app/api/settings/route.ts create mode 100644 src/app/api/settings/test/route.ts create mode 100644 src/components/SettingsModal.tsx create mode 100644 src/lib/discord.ts create mode 100644 src/lib/settings.ts diff --git a/.gitignore b/.gitignore index b1a4d73..b20fe94 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index dada2a7..387b34f 100644 --- a/README.md +++ b/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 diff --git a/package-lock.json b/package-lock.json index 597e581..cab72b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } } } diff --git a/package.json b/package.json index 95a37ae..d83ce09 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/alerts/[id]/AlertDetail.tsx b/src/app/alerts/[id]/AlertDetail.tsx index ef9dbe5..d569fba 100644 --- a/src/app/alerts/[id]/AlertDetail.tsx +++ b/src/app/alerts/[id]/AlertDetail.tsx @@ -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 = { @@ -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 ( + + + + ); +} + +// ── Episode progress bar ────────────────────────────────────────────────────── + +function EpisodeBar({ downloaded, total }: { downloaded: number; total: number }) { + const pct = Math.round((downloaded / total) * 100); + return ( +
+
+ Episodes downloaded + {downloaded} / {total} ({pct}%) +
+
+
+
+
+ ); +} + +// ── Structured description for content alerts ──────────────────────────────── + +interface DescRow { label: string; value?: string; chips?: ReactNode } + +function DescriptionTable({ rows }: { rows: DescRow[] }) { + return ( +
+ {rows.map(({ label, value, chips }) => ( +
+ {label} + {chips ?? {value}} +
+ ))} +
+ ); +} + +interface ContentDescriptionProps { + description: string; + category: string; + requesterIds?: number[]; + seerrUrl?: string; +} + +function RequesterChips({ + names, + requesterIds, + seerrUrl, +}: { + names: string[]; + requesterIds?: number[]; + seerrUrl?: string; +}) { + return ( + + {names.map((name, i) => { + const uid = requesterIds?.[i]; + const href = uid && seerrUrl ? `${seerrUrl}/users/${uid}` : null; + return ( + + {i > 0 && , } + {href ? ( + + {name} + + ) : ( + {name} + )} + + ); + })} + + ); +} + +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 ( + }, + { 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 ( + }, + { 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 ( + }, + { label: "Waiting", value: age }, + ]} /> + ); + } + } + + // Fallback: plain prose (ghost, watchrate handled separately, tautulli-no-matches) + return

{description}

; +} + +// ── Watch rate stat block ───────────────────────────────────────────────────── + +function WatchrateBlock({ plays, requests, pct }: { plays: number; requests: number; pct: number }) { + return ( +
+
+
+
{plays.toLocaleString()}
+
plays
+
+
/
+
+
{requests}
+
requests
+
+
=
+
+
{pct}%
+
watch rate
+
+
+

Alert threshold: <20%

+
+ ); +} + // ── Comment row ─────────────────────────────────────────────────────────────── function CommentRow({ comment }: { comment: AlertComment }) { @@ -70,7 +235,7 @@ function CommentRow({ comment }: { comment: AlertComment }) {
- + {comment.body} @@ -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(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 (
{/* Back */} - router.back()} className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors" > All Alerts - + {error && (
@@ -196,10 +409,11 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) { {/* ── Alert overview ──────────────────────────────────────────────── */}
-
+
- {/* Top row: status + action */} -
+ {/* Top row: severity + status | action buttons */} +
+ {/* Left: severity + status + timestamps */}
{severityLabel[alert.severity]} @@ -212,57 +426,123 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) { {isOpen ? "Open" : isResolved ? "Auto-resolved" : "Closed"} · - {timeAgo(statusTime)} + Opened {timeAgo(alert.firstSeen)} + · + Checked {timeAgo(alert.lastSeen)}
- -
+ {/* Right: action buttons */} +
+ {/* Search fallback (not yet in *arr) */} + {searchUrl && ( + + {alert.mediaType === "movie" ? "Search in Radarr" : "Search in Sonarr"} + + + )} - {/* Title + description */} -
-

{alert.title}

-

{alert.description}

-
+ {/* Primary: view in Radarr/Sonarr */} + {alert.mediaUrl && ( + + {alert.mediaType === "movie" ? "View in Radarr" : "View in Sonarr"} + + + )} - {/* Metadata row */} -
+ {/* View in Seerr */} + {alert.seerrMediaUrl && ( + + View in Seerr + + + )} - {/* Dates */} - - {!isOpen && alert.closedAt && ( - - )} - - {/* User (for user-behavior alerts) */} - {alert.userName && !alert.mediaTitle && ( - - )} - - {/* View in Radarr/Sonarr */} - {alert.mediaUrl && ( - - {alert.mediaType === "movie" ? "View in Radarr" : "View in Sonarr"} - - - - + {actionLoading ? "…" : isOpen ? "Close" : "Reopen"} + +
+
+ + {/* Title */} +

{alert.title}

+ + {/* Category body */} +
+ + {/* Description — structured for content alerts, prose for others */} + {alert.category !== "watchrate" && ( + + )} + + {/* Watchrate structured stat block */} + {watchrateStats && ( + + )} + + {/* Episode progress bar for partial TV downloads */} + {episodeCounts && ( + + )} + + {/* User chip linking to dashboard for behavior alerts */} + {userLink && alert.userName && ( +
+ User + + {alert.userName} + + + + +
)}
+ + {/* Metadata footer — closed/resolved date only */} + {!isOpen && alert.closedAt && ( +
+ +
+ )}
@@ -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" /> -
+
+ ⌘↵ to submit + load(true)} loading={refreshing || loading} /> +
{generatedAt && ( {refreshing @@ -175,14 +181,12 @@ function DashboardContent() { {tab === "alerts" && } )} + + setSettingsOpen(false)} + onSaved={() => load(true)} + />
); } - -export default function Page() { - return ( - - - - ); -} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx new file mode 100644 index 0000000..c87892c --- /dev/null +++ b/src/components/SettingsModal.tsx @@ -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 ? ( + + + + + ) : ( + + + + ); +} + +function XIcon() { + return ( + + + + ); +} + +// ── Per-service section ─────────────────────────────────────────────────────── + +type ServiceKey = "radarr" | "sonarr" | "seerr" | "tautulli"; + +interface SectionProps { + id: ServiceKey; + label: string; + placeholder: string; + optional?: boolean; + config: ServiceConfig; + onChange: (patch: Partial) => 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 ( +
+
+

{label}

+ {optional && ( + optional + )} +
+ +
+ {/* URL */} +
+ + 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" + /> +
+ + {/* API Key */} +
+ +
+
+ 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" + /> + +
+ +
+
+
+ + {/* Test result */} + {testResult && ( +
+ {testResult.ok ? ( + + + + ) : ( + + + + )} + {testResult.message} +
+ )} +
+ ); +} + +// ── Discord section (webhook URL only, no API key) ──────────────────────────── + +interface DiscordSectionProps { + config: DiscordConfig; + onChange: (patch: Partial) => 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 ( +
+
+

Discord

+ optional +
+ +
+ +
+ 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" + /> + +
+
+ + {testResult && ( +
+ {testResult.ok ? ( + + + + ) : ( + + + + )} + {testResult.message} +
+ )} +
+ ); +} + +// ── 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(EMPTY_SETTINGS); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [saveResult, setSaveResult] = useState<"saved" | "error" | null>(null); + const panelRef = useRef(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) { + setSaveResult(null); + setSettings((prev) => ({ + ...prev, + [service]: { ...prev[service], ...partial }, + })); + } + + function patchDiscord(partial: Partial) { + 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 ( +
+
+ {/* Header */} +
+

Settings

+ +
+ + {/* Body */} +
+ {loading ? ( +
+ + + + +
+ ) : ( +
+ patch("radarr", p)} + /> + patch("sonarr", p)} + /> + patch("seerr", p)} + /> + patch("tautulli", p)} + /> + patchDiscord(p)} + /> +
+ )} +
+ + {/* Footer */} +
+
+ {saveResult === "saved" && ( + Saved — click Refresh to reload data + )} + {saveResult === "error" && ( + Save failed — check the console + )} +
+
+ + +
+
+
+
+ ); +} diff --git a/src/lib/aggregate.ts b/src/lib/aggregate.ts index 5dd57b6..46444a6 100644 --- a/src/lib/aggregate.ts +++ b/src/lib/aggregate.ts @@ -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( diff --git a/src/lib/alerts.ts b/src/lib/alerts.ts index 20a4d15..52834b9 100644 --- a/src/lib/alerts.ts +++ b/src/lib/alerts.ts @@ -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, }); } } diff --git a/src/lib/db.ts b/src/lib/db.ts index b75b3e1..48c28ed 100644 --- a/src/lib/db.ts +++ b/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,196 +36,387 @@ const COOLDOWN: Record = { }; const DEFAULT_COOLDOWN = 0; -interface Store { - nextId: number; - nextCommentId: number; - alerts: Record; // keyed by alert.key +// ── Singleton ────────────────────────────────────────────────────────────────── + +declare global { + // eslint-disable-next-line no-var + var __alertsDb: Database.Database | undefined; } -interface StoredAlert { - id: number; - key: string; - category: string; - severity: string; - title: string; - description: string; - userId?: number; - userName?: string; - mediaId?: number; - mediaType?: string; - mediaTitle?: string; - mediaUrl?: string; - status: AlertStatus; - closeReason: AlertCloseReason | null; - suppressedUntil: string | null; - firstSeen: string; - lastSeen: string; - closedAt: string | null; - comments: Array<{ id: number; body: string; createdAt: string; author: "user" | "system" }>; -} +function getDb(): Database.Database { + if (global.__alertsDb) return global.__alertsDb; -function load(): Store { if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true }); - if (!existsSync(DB_PATH)) { - const empty: Store = { nextId: 1, nextCommentId: 1, alerts: {} }; - writeFileSync(DB_PATH, JSON.stringify(empty, null, 2)); - return empty; + + const db = new Database(DB_PATH); + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + + initSchema(db); + maybeMigrateJson(db); + + global.__alertsDb = db; + return db; +} + +// ── Schema ───────────────────────────────────────────────────────────────────── + +function initSchema(db: Database.Database) { + db.exec(` + CREATE TABLE IF NOT EXISTS alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + category TEXT NOT NULL, + severity TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + userId INTEGER, + userName TEXT, + mediaId INTEGER, + mediaType TEXT, + mediaTitle TEXT, + mediaUrl TEXT, + status TEXT NOT NULL DEFAULT 'open', + closeReason TEXT, + suppressedUntil TEXT, + firstSeen TEXT NOT NULL, + lastSeen TEXT NOT NULL, + closedAt TEXT, + requesterIds TEXT, + seerrMediaUrl TEXT + ); + + CREATE TABLE IF NOT EXISTS comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + alertId INTEGER NOT NULL REFERENCES alerts(id) ON DELETE CASCADE, + body TEXT NOT NULL, + author TEXT NOT NULL DEFAULT 'user', + createdAt TEXT NOT NULL + ); + `); + + // Additive migrations for existing databases + try { db.exec("ALTER TABLE alerts ADD COLUMN requesterIds TEXT"); } catch {} + try { db.exec("ALTER TABLE alerts ADD COLUMN seerrMediaUrl TEXT"); } catch {} + +} + +// ── Legacy JSON migration ────────────────────────────────────────────────────── + +function maybeMigrateJson(db: Database.Database) { + if (!existsSync(LEGACY_JSON_PATH)) return; + + // Only migrate if the alerts table is empty + const count = (db.prepare("SELECT COUNT(*) as n FROM alerts").get() as { n: number }).n; + if (count > 0) { + // Table already has data — just remove the legacy file + renameSync(LEGACY_JSON_PATH, LEGACY_MIGRATED_PATH); + return; + } + + try { + interface LegacyComment { id: number; body: string; author: "user" | "system"; createdAt: string; } + interface LegacyAlert { + id: number; key: string; category: string; severity: string; + title: string; description: string; + userId?: number; userName?: string; + mediaId?: number; mediaType?: string; mediaTitle?: string; mediaUrl?: string; + status: string; closeReason: string | null; suppressedUntil: string | null; + firstSeen: string; lastSeen: string; closedAt: string | null; + comments: LegacyComment[]; + } + interface LegacyStore { alerts: Record; } + + const raw = readFileSync(LEGACY_JSON_PATH, "utf-8"); + const store: LegacyStore = JSON.parse(raw); + + const insertAlert = db.prepare(` + INSERT INTO alerts + (key, category, severity, title, description, userId, userName, + mediaId, mediaType, mediaTitle, mediaUrl, status, closeReason, + suppressedUntil, firstSeen, lastSeen, closedAt) + VALUES + (@key, @category, @severity, @title, @description, @userId, @userName, + @mediaId, @mediaType, @mediaTitle, @mediaUrl, @status, @closeReason, + @suppressedUntil, @firstSeen, @lastSeen, @closedAt) + `); + + const insertComment = db.prepare(` + INSERT INTO comments (alertId, body, author, createdAt) + VALUES (@alertId, @body, @author, @createdAt) + `); + + const migrate = db.transaction(() => { + for (const a of Object.values(store.alerts)) { + const info = insertAlert.run({ + key: a.key, category: a.category, severity: a.severity, + title: a.title, description: a.description, + userId: a.userId ?? null, userName: a.userName ?? null, + mediaId: a.mediaId ?? null, mediaType: a.mediaType ?? null, + mediaTitle: a.mediaTitle ?? null, mediaUrl: a.mediaUrl ?? null, + status: a.status, closeReason: a.closeReason ?? null, + suppressedUntil: a.suppressedUntil ?? null, + firstSeen: a.firstSeen, lastSeen: a.lastSeen, closedAt: a.closedAt ?? null, + }); + const alertId = info.lastInsertRowid as number; + for (const c of a.comments ?? []) { + insertComment.run({ alertId, body: c.body, author: c.author, createdAt: c.createdAt }); + } + } + }); + + migrate(); + renameSync(LEGACY_JSON_PATH, LEGACY_MIGRATED_PATH); + console.log("[db] Migrated alerts.json → SQLite"); + } catch (err) { + console.error("[db] Migration failed:", err); } - return JSON.parse(readFileSync(DB_PATH, "utf-8")) as Store; } -function save(store: Store) { - writeFileSync(DB_PATH, JSON.stringify(store, null, 2)); +// ── Row → Alert ──────────────────────────────────────────────────────────────── + +interface AlertRow { + id: number; key: string; category: string; severity: string; + title: string; description: string; + userId: number | null; userName: string | null; + mediaId: number | null; mediaType: string | null; + mediaTitle: string | null; mediaUrl: string | null; + status: string; closeReason: string | null; suppressedUntil: string | null; + firstSeen: string; lastSeen: string; closedAt: string | null; + requesterIds: string | null; // JSON-encoded number[] + seerrMediaUrl: string | null; } -function toAlert(s: StoredAlert): Alert { +interface CommentRow { + id: number; alertId: number; body: string; author: string; createdAt: string; +} + +function rowToAlert(row: AlertRow, comments: CommentRow[]): Alert { return { - id: s.id, - key: s.key, - category: s.category, - severity: s.severity as Alert["severity"], - title: s.title, - description: s.description, - userId: s.userId, - userName: s.userName, - mediaId: s.mediaId, - mediaType: s.mediaType as Alert["mediaType"], - mediaTitle: s.mediaTitle, - mediaUrl: s.mediaUrl, - status: s.status, - closeReason: s.closeReason ?? null, - suppressedUntil: s.suppressedUntil, - firstSeen: s.firstSeen, - lastSeen: s.lastSeen, - closedAt: s.closedAt, - comments: s.comments, + id: row.id, + key: row.key, + category: row.category, + severity: row.severity as Alert["severity"], + title: row.title, + description: row.description, + userId: row.userId ?? undefined, + userName: row.userName ?? undefined, + mediaId: row.mediaId ?? undefined, + mediaType: row.mediaType as Alert["mediaType"], + mediaTitle: row.mediaTitle ?? undefined, + mediaUrl: row.mediaUrl ?? undefined, + requesterIds: row.requesterIds ? (JSON.parse(row.requesterIds) as number[]) : undefined, + seerrMediaUrl: row.seerrMediaUrl ?? undefined, + status: row.status as AlertStatus, + closeReason: row.closeReason as AlertCloseReason | null, + suppressedUntil: row.suppressedUntil, + firstSeen: row.firstSeen, + lastSeen: row.lastSeen, + closedAt: row.closedAt, + comments: comments.map((c) => ({ + id: c.id, + body: c.body, + author: c.author as "user" | "system", + createdAt: c.createdAt, + })), }; } +function getCommentsForAlert(db: Database.Database, alertId: number): CommentRow[] { + return db.prepare( + "SELECT * FROM comments WHERE alertId = ? ORDER BY createdAt ASC, id ASC" + ).all(alertId) as CommentRow[]; +} + +// ── Exported API ─────────────────────────────────────────────────────────────── + +export interface UpsertResult { + openCount: number; + /** Candidates that were newly created or reopened this run — used for notifications. */ + newAlerts: AlertCandidate[]; +} + /** * Merge generated candidates into the store, then auto-resolve any open alerts * whose condition is no longer present (key not in this run's candidate set). * - * Auto-resolved alerts: - * - Are marked closed with closeReason = "resolved" - * - Have NO suppressedUntil — they can reopen immediately if the condition returns - * - * Manually closed alerts: - * - Have suppressedUntil set (cooldown per category) - * - Won't be re-opened by upsertAlerts until that cooldown expires - * - * Returns the count of open alerts after the merge. + * Returns the count of open alerts after the merge and the list of newly + * created or reopened alert candidates (for Discord notifications etc.). */ -export function upsertAlerts(candidates: AlertCandidate[]): number { - const store = load(); +export function upsertAlerts(candidates: AlertCandidate[]): UpsertResult { + const db = getDb(); const now = new Date(); const nowISO = now.toISOString(); const candidateKeys = new Set(candidates.map((c) => c.key)); - // ── Step 1: upsert candidates ───────────────────────────────────────────── - for (const c of candidates) { - const existing = store.alerts[c.key]; + const getByKey = db.prepare<[string], AlertRow>( + "SELECT * FROM alerts WHERE key = ?" + ); + const updateAlert = db.prepare(` + UPDATE alerts SET + status = @status, closeReason = @closeReason, closedAt = @closedAt, + suppressedUntil = @suppressedUntil, lastSeen = @lastSeen, + title = @title, description = @description, + userName = COALESCE(@userName, userName), + mediaTitle = COALESCE(@mediaTitle, mediaTitle), + mediaUrl = COALESCE(@mediaUrl, mediaUrl), + requesterIds = COALESCE(@requesterIds, requesterIds), + seerrMediaUrl = COALESCE(@seerrMediaUrl, seerrMediaUrl) + WHERE key = @key + `); + const insertAlert = db.prepare(` + INSERT INTO alerts + (key, category, severity, title, description, userId, userName, + mediaId, mediaType, mediaTitle, mediaUrl, status, closeReason, + suppressedUntil, firstSeen, lastSeen, closedAt, requesterIds, seerrMediaUrl) + VALUES + (@key, @category, @severity, @title, @description, @userId, @userName, + @mediaId, @mediaType, @mediaTitle, @mediaUrl, 'open', NULL, NULL, + @firstSeen, @lastSeen, NULL, @requesterIds, @seerrMediaUrl) + `); + const insertComment = db.prepare(` + INSERT INTO comments (alertId, body, author, createdAt) + VALUES (@alertId, @body, @author, @createdAt) + `); + const getOpenAlerts = db.prepare<[], AlertRow>( + "SELECT * FROM alerts WHERE status = 'open'" + ); - if (existing) { - const isSuppressed = - existing.status === "closed" && - existing.suppressedUntil !== null && - new Date(existing.suppressedUntil) > now; + const newAlerts: AlertCandidate[] = []; - if (isSuppressed) continue; + db.transaction(() => { + // ── Step 1: upsert candidates ─────────────────────────────────────────── + for (const c of candidates) { + const existing = getByKey.get(c.key); - // Re-open if previously closed (manually or resolved) and not suppressed. - // Preserve firstSeen and comments — this is the same incident continuing. - if (existing.status === "closed") { - existing.status = "open"; - existing.closeReason = null; - existing.closedAt = null; - existing.suppressedUntil = null; - existing.comments.push({ - id: store.nextCommentId++, - body: "Alert reopened — condition is still active.", - createdAt: nowISO, - author: "system", + if (existing) { + const isSuppressed = + existing.status === "closed" && + existing.suppressedUntil !== null && + new Date(existing.suppressedUntil) > now; + + if (isSuppressed) continue; + + const requesterIds = c.requesterIds?.length ? JSON.stringify(c.requesterIds) : null; + const seerrMediaUrl = c.seerrMediaUrl ?? null; + + if (existing.status === "closed") { + // Reopen — notify + updateAlert.run({ + key: c.key, + status: "open", + closeReason: null, + closedAt: null, + suppressedUntil: null, + lastSeen: nowISO, + title: c.title, + description: c.description, + userName: c.userName ?? null, + mediaTitle: c.mediaTitle ?? null, + mediaUrl: c.mediaUrl ?? null, + requesterIds, + seerrMediaUrl, + }); + insertComment.run({ + alertId: existing.id, + body: "Alert reopened — condition is still active.", + author: "system", + createdAt: nowISO, + }); + newAlerts.push(c); + } else { + // Refresh content — already open, no notification + updateAlert.run({ + key: c.key, + status: "open", + closeReason: null, + closedAt: null, + suppressedUntil: null, + lastSeen: nowISO, + title: c.title, + description: c.description, + userName: c.userName ?? null, + mediaTitle: c.mediaTitle ?? null, + mediaUrl: c.mediaUrl ?? null, + requesterIds, + seerrMediaUrl, + }); + } + } else { + // New alert — notify + insertAlert.run({ + key: c.key, + category: c.category, + severity: c.severity, + title: c.title, + description: c.description, + userId: c.userId ?? null, + userName: c.userName ?? null, + mediaId: c.mediaId ?? null, + mediaType: c.mediaType ?? null, + mediaTitle: c.mediaTitle ?? null, + mediaUrl: c.mediaUrl ?? null, + firstSeen: nowISO, + lastSeen: nowISO, + requesterIds: c.requesterIds?.length ? JSON.stringify(c.requesterIds) : null, + seerrMediaUrl: c.seerrMediaUrl ?? null, }); + newAlerts.push(c); } - - // Refresh content and lastSeen - existing.lastSeen = nowISO; - existing.title = c.title; - existing.description = c.description; - if (c.userName) existing.userName = c.userName; - if (c.mediaTitle) existing.mediaTitle = c.mediaTitle; - if (c.mediaUrl) existing.mediaUrl = c.mediaUrl; - } else { - store.alerts[c.key] = { - id: store.nextId++, - key: c.key, - category: c.category, - severity: c.severity, - title: c.title, - description: c.description, - userId: c.userId, - userName: c.userName, - mediaId: c.mediaId, - mediaType: c.mediaType, - mediaTitle: c.mediaTitle, - mediaUrl: c.mediaUrl, - status: "open", - closeReason: null, - suppressedUntil: null, - firstSeen: nowISO, - lastSeen: nowISO, - closedAt: null, - comments: [], - }; } - } - // ── Step 2: auto-resolve alerts whose condition is gone ─────────────────── - for (const alert of Object.values(store.alerts)) { - if (alert.status !== "open") continue; - if (candidateKeys.has(alert.key)) continue; + // ── Step 2: auto-resolve alerts whose condition is gone ───────────────── + const openAlerts = getOpenAlerts.all(); + for (const a of openAlerts) { + if (candidateKeys.has(a.key)) continue; + db.prepare(` + UPDATE alerts SET status = 'closed', closeReason = 'resolved', + closedAt = ?, suppressedUntil = NULL WHERE id = ? + `).run(nowISO, a.id); + insertComment.run({ + alertId: a.id, + body: "Condition resolved — alert closed automatically.", + author: "system", + createdAt: nowISO, + }); + } + })(); - // Condition no longer exists — resolve it automatically, no cooldown - alert.status = "closed"; - alert.closeReason = "resolved"; - alert.closedAt = nowISO; - alert.suppressedUntil = null; - alert.comments.push({ - id: store.nextCommentId++, - body: "Condition resolved — alert closed automatically.", - createdAt: nowISO, - author: "system", - }); - } - - save(store); - - return Object.values(store.alerts).filter((a) => a.status === "open").length; + const { n } = db.prepare( + "SELECT COUNT(*) as n FROM alerts WHERE status = 'open'" + ).get() as { n: number }; + return { openCount: n, newAlerts }; } export function getAllAlerts(): Alert[] { - const store = load(); - return Object.values(store.alerts) - .sort((a, b) => { - if (a.status !== b.status) return a.status === "open" ? -1 : 1; - return new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime(); - }) - .map(toAlert); + const db = getDb(); + const rows = db.prepare<[], AlertRow>(` + SELECT * FROM alerts + ORDER BY + CASE status WHEN 'open' THEN 0 ELSE 1 END ASC, + lastSeen DESC + `).all(); + + return rows.map((row) => rowToAlert(row, getCommentsForAlert(db, row.id))); } export function getAlertById(id: number): Alert | null { - const store = load(); - const found = Object.values(store.alerts).find((a) => a.id === id); - return found ? toAlert(found) : null; + const db = getDb(); + const row = db.prepare<[number], AlertRow>( + "SELECT * FROM alerts WHERE id = ?" + ).get(id); + if (!row) return null; + return rowToAlert(row, getCommentsForAlert(db, id)); } export function closeAlert(id: number): Alert | null { - const store = load(); - const alert = Object.values(store.alerts).find((a) => a.id === id); - if (!alert) return null; + const db = getDb(); + const row = db.prepare<[number], AlertRow>( + "SELECT * FROM alerts WHERE id = ?" + ).get(id); + if (!row) return null; - const cooldownDays = COOLDOWN[alert.category] ?? DEFAULT_COOLDOWN; + const cooldownDays = COOLDOWN[row.category] ?? DEFAULT_COOLDOWN; let suppressedUntil: string | null = null; if (cooldownDays > 0) { const d = new Date(); @@ -227,50 +424,66 @@ export function closeAlert(id: number): Alert | null { suppressedUntil = d.toISOString(); } - const now = new Date().toISOString(); - alert.status = "closed"; - alert.closeReason = "manual"; - alert.closedAt = now; - alert.suppressedUntil = suppressedUntil; - alert.comments.push({ - id: store.nextCommentId++, - body: "Manually closed.", - createdAt: now, - author: "system", - }); - save(store); - return toAlert(alert); + const nowISO = new Date().toISOString(); + + db.transaction(() => { + db.prepare(` + UPDATE alerts SET status = 'closed', closeReason = 'manual', + closedAt = ?, suppressedUntil = ? WHERE id = ? + `).run(nowISO, suppressedUntil, id); + db.prepare(` + INSERT INTO comments (alertId, body, author, createdAt) + VALUES (?, ?, 'system', ?) + `).run(id, "Manually closed.", nowISO); + })(); + + return getAlertById(id); } export function reopenAlert(id: number): Alert | null { - const store = load(); - const alert = Object.values(store.alerts).find((a) => a.id === id); - if (!alert) return null; - alert.status = "open"; - alert.closeReason = null; - alert.closedAt = null; - alert.suppressedUntil = null; - alert.comments.push({ - id: store.nextCommentId++, - body: "Manually reopened.", - createdAt: new Date().toISOString(), - author: "system", - }); - save(store); - return toAlert(alert); + const db = getDb(); + const row = db.prepare<[number], AlertRow>( + "SELECT * FROM alerts WHERE id = ?" + ).get(id); + if (!row) return null; + + const nowISO = new Date().toISOString(); + + db.transaction(() => { + db.prepare(` + UPDATE alerts SET status = 'open', closeReason = NULL, + closedAt = NULL, suppressedUntil = NULL WHERE id = ? + `).run(id); + db.prepare(` + INSERT INTO comments (alertId, body, author, createdAt) + VALUES (?, 'Manually reopened.', 'system', ?) + `).run(id, nowISO); + })(); + + return getAlertById(id); } -export function addComment(alertId: number, body: string, author: "user" | "system" = "user"): AlertComment | null { - const store = load(); - const alert = Object.values(store.alerts).find((a) => a.id === alertId); - if (!alert) return null; - const comment: AlertComment = { - id: store.nextCommentId++, +export function addComment( + alertId: number, + body: string, + author: "user" | "system" = "user" +): AlertComment | null { + const db = getDb(); + const exists = db.prepare<[number], { id: number }>( + "SELECT id FROM alerts WHERE id = ?" + ).get(alertId); + if (!exists) return null; + + const nowISO = new Date().toISOString(); + const info = db.prepare(` + INSERT INTO comments (alertId, body, author, createdAt) + VALUES (?, ?, ?, ?) + `).run(alertId, body, author, nowISO); + + return { + id: info.lastInsertRowid as number, body, - createdAt: new Date().toISOString(), author, + createdAt: nowISO, }; - alert.comments.push(comment); - save(store); - return comment; } diff --git a/src/lib/discord.ts b/src/lib/discord.ts new file mode 100644 index 0000000..fcc76d0 --- /dev/null +++ b/src/lib/discord.ts @@ -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 = { + danger: 0xef4444, // red-500 + warning: 0xeab308, // yellow-500 + info: 0x3b82f6, // blue-500 +}; + +const SEVERITY_LABEL: Record = { + 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 { + 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 { + 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 { + 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(), + }, + ]); +} diff --git a/src/lib/overseerr.ts b/src/lib/overseerr.ts index 45455d6..6b4adfd 100644 --- a/src/lib/overseerr.ts +++ b/src/lib/overseerr.ts @@ -1,15 +1,17 @@ import { OverseerrUser, OverseerrRequest } from "@/lib/types"; +import { getSettings } from "@/lib/settings"; const TAKE = 100; export async function fetchAllUsers(): Promise { + 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 { } export async function fetchUserRequests(userId: number): Promise { + 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) { diff --git a/src/lib/radarr.ts b/src/lib/radarr.ts index 220686e..3709607 100644 --- a/src/lib/radarr.ts +++ b/src/lib/radarr.ts @@ -1,8 +1,10 @@ import { RadarrMovie, MediaEntry } from "@/lib/types"; +import { getSettings } from "@/lib/settings"; export async function buildRadarrMap(): Promise> { - 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) { diff --git a/src/lib/settings.ts b/src/lib/settings.ts new file mode 100644 index 0000000..d29691e --- /dev/null +++ b/src/lib/settings.ts @@ -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; + sonarr?: Partial; + seerr?: Partial; + tautulli?: Partial; + discord?: Partial; +} + +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(); +} diff --git a/src/lib/sonarr.ts b/src/lib/sonarr.ts index af4b42e..d43230d 100644 --- a/src/lib/sonarr.ts +++ b/src/lib/sonarr.ts @@ -1,8 +1,10 @@ import { SonarrSeries, MediaEntry } from "@/lib/types"; +import { getSettings } from "@/lib/settings"; export async function buildSonarrMap(): Promise> { - 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) { diff --git a/src/lib/tautulli.ts b/src/lib/tautulli.ts index 84bfa2e..84e064f 100644 --- a/src/lib/tautulli.ts +++ b/src/lib/tautulli.ts @@ -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 | 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; diff --git a/src/lib/types.ts b/src/lib/types.ts index fe6931f..a0f324f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -115,7 +115,10 @@ export interface AlertCandidate { mediaId?: number; mediaType?: "movie" | "tv"; mediaTitle?: string; - mediaUrl?: string; // direct link to the item in Radarr/Sonarr + mediaUrl?: string; // direct link to the item in Radarr/Sonarr + seerrMediaUrl?: string; // link to the item's page in Overseerr/Jellyseerr + // Ordered list of Overseerr user IDs who triggered this alert (content alerts) + requesterIds?: number[]; } export interface AlertComment {