Add per-user detail pages with activity chart and request history

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 17:26:47 -04:00
parent 641a7fd096
commit b2c1642065
14 changed files with 1377 additions and 66 deletions

View File

@@ -9,6 +9,8 @@ Built with Next.js 16, TypeScript, and Tailwind CSS.
## Features
- **Leaderboard** — per-user request count, total storage, average GB per request, and optional Tautulli watch stats (plays, watch hours), each ranked against the full userbase
- **User detail pages** — click any user in the leaderboard to see their full profile: stat cards, an activity chart with 1W/1M/3M/1Y timeframes, and a complete request history sorted newest-first
- **Activity chart** — two modes: *Metrics* (requests, storage GB, watch hours as separate toggleable lines, with a Raw/Relative normalization toggle) and *Storage Load* (GB requested ÷ watch hours per bucket, with an all-time average reference line)
- **Alerting** — automatic alerts for stalled downloads, neglected requesters, and abusive patterns, with open/close state, notes, and auto-resolve when conditions clear
- **Discord notifications** — posts a structured embed to a webhook whenever a new alert opens or a resolved one returns
- **Settings UI** — configure all service URLs and API keys from the dashboard; no need to touch `.env.local` after initial setup

413
package-lock.json generated
View File

@@ -11,7 +11,8 @@
"better-sqlite3": "^12.8.0",
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"recharts": "^3.8.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -756,6 +757,54 @@
"node": ">= 10"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1058,6 +1107,69 @@
"@types/node": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
@@ -1072,7 +1184,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1088,6 +1200,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -1210,11 +1328,147 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/decompress-response": {
@@ -1273,6 +1527,22 @@
"node": ">=10.13.0"
}
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -1327,6 +1597,16 @@
],
"license": "BSD-3-Clause"
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -1339,6 +1619,15 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -1893,6 +2182,36 @@
"react": "^19.2.4"
}
},
"node_modules/react-is": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
"integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -1907,6 +2226,57 @@
"node": ">= 6"
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -2134,6 +2504,12 @@
"node": ">=6"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -2173,12 +2549,43 @@
"dev": true,
"license": "MIT"
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"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/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",

View File

@@ -11,7 +11,8 @@
"better-sqlite3": "^12.8.0",
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"recharts": "^3.8.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@@ -393,7 +393,7 @@ export default function AlertDetail({ initialAlert, radarrUrl, sonarrUrl, seerrU
{/* Back */}
<button
onClick={() => router.back()}
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors"
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors cursor-pointer"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />

View File

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

View File

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

View File

@@ -65,6 +65,13 @@ export default function Page() {
load();
}, [load]);
// Poll every 2 minutes to keep the UI fresh against the server cache.
// The server itself refreshes every 5 min via the background poller.
useEffect(() => {
const id = setInterval(() => load(), 2 * 60 * 1000);
return () => clearInterval(id);
}, [load]);
const hasTautulli = data?.summary.totalWatchHours !== null;
const openAlertCount = data?.summary.openAlertCount ?? 0;
const generatedAt = data?.generatedAt ?? null;
@@ -152,7 +159,7 @@ export default function Page() {
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors cursor-pointer ${
tab === t
? "border-yellow-400 text-white"
: "border-transparent text-slate-500 hover:text-slate-300"

View File

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

View File

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

View File

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

14
src/instrumentation.ts Normal file
View File

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

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

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

View File

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

View File

@@ -11,7 +11,7 @@ export interface OverseerrUser {
export interface OverseerrRequest {
id: number;
type: "movie" | "tv";
status: number; // 1=pending, 2=approved, 3=declined, 4=available
status: number; // media status: 1=unknown, 2=pending, 3=processing, 4=partial, 5=available
createdAt: string; // ISO timestamp
media: {
tmdbId: number;
@@ -42,6 +42,7 @@ export interface SonarrSeries {
}
export interface TautulliUser {
user_id: number;
friendly_name: string;
email: string;
plays: number;
@@ -130,6 +131,34 @@ export interface AlertComment {
export type AlertCloseReason = "manual" | "resolved";
// ─── User page ────────────────────────────────────────────────────────────────
/** A single Overseerr request enriched with resolved media title and size */
export interface EnrichedRequest {
id: number;
type: "movie" | "tv";
status: number; // media status: 1=unknown, 2=pending, 3=processing, 4=partial, 5=available
createdAt: string;
mediaId: number; // tmdbId for movies, tvdbId for TV
title: string;
sizeOnDisk: number; // bytes
sizeGB: number;
}
/** One day of watch activity from Tautulli */
export interface WatchDataPoint {
date: string; // YYYY-MM-DD
plays: number;
durationHours: number;
}
export interface UserPageData {
stat: UserStat;
enrichedRequests: EnrichedRequest[];
watchHistory: WatchDataPoint[]; // daily, sorted ascending; empty if Tautulli not configured
openAlerts: Alert[];
}
/** Full persisted alert returned by the API */
export interface Alert extends AlertCandidate {
id: number; // auto-increment DB id (used in URLs)