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:
@@ -9,6 +9,8 @@ Built with Next.js 16, TypeScript, and Tailwind CSS.
|
|||||||
## Features
|
## 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
|
- **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
|
- **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
|
- **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
|
- **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
413
package-lock.json
generated
@@ -11,7 +11,8 @@
|
|||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"recharts": "^3.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -756,6 +757,54 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -1058,6 +1107,69 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.39",
|
"version": "20.19.39",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||||
@@ -1072,7 +1184,7 @@
|
|||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -1088,6 +1200,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@@ -1210,11 +1328,147 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/decompress-response": {
|
"node_modules/decompress-response": {
|
||||||
@@ -1273,6 +1527,22 @@
|
|||||||
"node": ">=10.13.0"
|
"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": {
|
"node_modules/expand-template": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||||
@@ -1327,6 +1597,16 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@@ -1339,6 +1619,15 @@
|
|||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/jiti": {
|
||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
@@ -1893,6 +2182,36 @@
|
|||||||
"react": "^19.2.4"
|
"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": {
|
"node_modules/readable-stream": {
|
||||||
"version": "3.6.2",
|
"version": "3.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
@@ -1907,6 +2226,57 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -2134,6 +2504,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
@@ -2173,12 +2549,43 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"recharts": "^3.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ export default function AlertDetail({ initialAlert, radarrUrl, sonarrUrl, seerrU
|
|||||||
{/* Back */}
|
{/* Back */}
|
||||||
<button
|
<button
|
||||||
onClick={() => router.back()}
|
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}>
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
|||||||
@@ -1,63 +1,9 @@
|
|||||||
import { buildRadarrMap } from "@/lib/radarr";
|
import { getStats } from "@/lib/statsBuilder";
|
||||||
import { buildSonarrMap } from "@/lib/sonarr";
|
|
||||||
import { fetchAllUsers, fetchUserRequests } from "@/lib/overseerr";
|
|
||||||
import { buildTautulliMap } from "@/lib/tautulli";
|
|
||||||
import { computeStats } from "@/lib/aggregate";
|
|
||||||
import { DashboardStats, OverseerrRequest } from "@/lib/types";
|
|
||||||
|
|
||||||
const BATCH_SIZE = 5;
|
|
||||||
|
|
||||||
// ── Server-side SWR cache ────────────────────────────────────────────────────
|
|
||||||
// Persists in the Node.js process between requests.
|
|
||||||
// Background-refreshes after STALE_MS so reads always return instantly.
|
|
||||||
|
|
||||||
const STALE_MS = 5 * 60 * 1000; // start background refresh after 5 min
|
|
||||||
let cache: { stats: DashboardStats; at: number } | null = null;
|
|
||||||
let refreshing = false;
|
|
||||||
|
|
||||||
async function buildStats(): Promise<DashboardStats> {
|
|
||||||
const [radarrMap, sonarrMap, users, tautulliMap] = await Promise.all([
|
|
||||||
buildRadarrMap(),
|
|
||||||
buildSonarrMap(),
|
|
||||||
fetchAllUsers(),
|
|
||||||
buildTautulliMap(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const allRequests = new Map<number, OverseerrRequest[]>();
|
|
||||||
|
|
||||||
for (let i = 0; i < users.length; i += BATCH_SIZE) {
|
|
||||||
const chunk = users.slice(i, i + BATCH_SIZE);
|
|
||||||
const results = await Promise.all(
|
|
||||||
chunk.map((u) => fetchUserRequests(u.id))
|
|
||||||
);
|
|
||||||
chunk.forEach((u, idx) => allRequests.set(u.id, results[idx]));
|
|
||||||
}
|
|
||||||
|
|
||||||
return computeStats(users, allRequests, radarrMap, sonarrMap, tautulliMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const force = new URL(req.url).searchParams.has("force");
|
const force = new URL(req.url).searchParams.has("force");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Force (Refresh button) or cold start: wait for fresh data
|
return Response.json(await getStats(force));
|
||||||
if (force || !cache) {
|
|
||||||
const stats = await buildStats();
|
|
||||||
cache = { stats, at: Date.now() };
|
|
||||||
return Response.json(cache.stats);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stale: kick off background refresh, return cache immediately
|
|
||||||
const age = Date.now() - cache.at;
|
|
||||||
if (age > STALE_MS && !refreshing) {
|
|
||||||
refreshing = true;
|
|
||||||
buildStats()
|
|
||||||
.then((stats) => { cache = { stats, at: Date.now() }; })
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => { refreshing = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json(cache.stats);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
return Response.json({ error: message }, { status: 500 });
|
return Response.json({ error: message }, { status: 500 });
|
||||||
|
|||||||
92
src/app/api/users/[id]/route.ts
Normal file
92
src/app/api/users/[id]/route.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { getStats, getRawCache } from "@/lib/statsBuilder";
|
||||||
|
import { lookupTautulliUser, fetchUserWatchHistory } from "@/lib/tautulli";
|
||||||
|
import { getAllAlerts } from "@/lib/db";
|
||||||
|
import { bytesToGB } from "@/lib/aggregate";
|
||||||
|
import { EnrichedRequest, UserPageData } from "@/lib/types";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
const userId = parseInt(id, 10);
|
||||||
|
if (isNaN(userId)) {
|
||||||
|
return Response.json({ error: "Invalid user ID" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find the user in the cached stats (triggers a build if cache is cold)
|
||||||
|
const stats = await getStats();
|
||||||
|
const stat = stats.users.find((u) => u.userId === userId);
|
||||||
|
if (!stat) {
|
||||||
|
return Response.json({ error: "User not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich requests with resolved title + size from cached media maps
|
||||||
|
const raw = getRawCache();
|
||||||
|
const userRequests = raw?.allRequests.get(userId) ?? [];
|
||||||
|
|
||||||
|
const enrichedRequests: EnrichedRequest[] = userRequests.map((req) => {
|
||||||
|
let sizeOnDisk = 0;
|
||||||
|
let title = req.media.title ?? "";
|
||||||
|
|
||||||
|
if (req.type === "movie") {
|
||||||
|
const entry = raw?.radarrMap.get(req.media.tmdbId);
|
||||||
|
sizeOnDisk = entry?.sizeOnDisk ?? 0;
|
||||||
|
if (entry?.title) title = entry.title;
|
||||||
|
} else if (req.type === "tv" && req.media.tvdbId) {
|
||||||
|
const entry = raw?.sonarrMap.get(req.media.tvdbId);
|
||||||
|
sizeOnDisk = entry?.sizeOnDisk ?? 0;
|
||||||
|
if (entry?.title) title = entry.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
title = req.type === "movie"
|
||||||
|
? `Movie #${req.media.tmdbId}`
|
||||||
|
: `Show #${req.media.tmdbId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: req.id,
|
||||||
|
type: req.type,
|
||||||
|
status: req.status,
|
||||||
|
createdAt: req.createdAt,
|
||||||
|
mediaId: req.type === "movie" ? req.media.tmdbId : (req.media.tvdbId ?? 0),
|
||||||
|
title,
|
||||||
|
sizeOnDisk,
|
||||||
|
sizeGB: bytesToGB(sizeOnDisk),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch watch history from Tautulli (if available)
|
||||||
|
let tautulliUserId: number | null = null;
|
||||||
|
if (raw?.tautulliMap) {
|
||||||
|
const tu = lookupTautulliUser(raw.tautulliMap, stat.email, stat.displayName);
|
||||||
|
tautulliUserId = tu?.user_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchHistory = tautulliUserId
|
||||||
|
? await fetchUserWatchHistory(tautulliUserId)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Open alerts involving this user
|
||||||
|
const allAlerts = getAllAlerts();
|
||||||
|
const openAlerts = allAlerts.filter(
|
||||||
|
(a) =>
|
||||||
|
a.status === "open" &&
|
||||||
|
(a.userId === userId || a.requesterIds?.includes(userId))
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: UserPageData = {
|
||||||
|
stat,
|
||||||
|
enrichedRequests,
|
||||||
|
watchHistory,
|
||||||
|
openAlerts,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Response.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return Response.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,13 @@ export default function Page() {
|
|||||||
load();
|
load();
|
||||||
}, [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 hasTautulli = data?.summary.totalWatchHours !== null;
|
||||||
const openAlertCount = data?.summary.openAlertCount ?? 0;
|
const openAlertCount = data?.summary.openAlertCount ?? 0;
|
||||||
const generatedAt = data?.generatedAt ?? null;
|
const generatedAt = data?.generatedAt ?? null;
|
||||||
@@ -152,7 +159,7 @@ export default function Page() {
|
|||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTab(t)}
|
onClick={() => setTab(t)}
|
||||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors cursor-pointer ${
|
||||||
tab === t
|
tab === t
|
||||||
? "border-yellow-400 text-white"
|
? "border-yellow-400 text-white"
|
||||||
: "border-transparent text-slate-500 hover:text-slate-300"
|
: "border-transparent text-slate-500 hover:text-slate-300"
|
||||||
|
|||||||
614
src/app/users/[id]/UserDetail.tsx
Normal file
614
src/app/users/[id]/UserDetail.tsx
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
ReferenceLine,
|
||||||
|
} from "recharts";
|
||||||
|
import {
|
||||||
|
EnrichedRequest,
|
||||||
|
WatchDataPoint,
|
||||||
|
UserPageData,
|
||||||
|
AlertSeverity,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatGB(gb: number): string {
|
||||||
|
if (gb === 0) return "—";
|
||||||
|
return gb >= 1000 ? `${(gb / 1000).toFixed(2)} TB` : `${gb.toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHours(h: number): string {
|
||||||
|
if (h >= 1000) return `${(h / 1000).toFixed(1)}k h`;
|
||||||
|
return `${h.toFixed(0)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return "Never";
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
const mins = Math.floor(diff / 60_000);
|
||||||
|
if (mins < 1) return "just now";
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs < 24) return `${hrs}h ago`;
|
||||||
|
const days = Math.floor(hrs / 24);
|
||||||
|
if (days < 30) return `${days}d ago`;
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, { month: "short", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function unixTimeAgo(ts: number | null): string {
|
||||||
|
if (ts === null) return "Never";
|
||||||
|
return timeAgo(new Date(ts * 1000).toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status badge ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Overseerr media status codes (media.status, not request approval status):
|
||||||
|
// 1=Unknown, 2=Pending, 3=Processing, 4=Partially Available, 5=Available
|
||||||
|
const STATUS_LABEL: Record<number, string> = {
|
||||||
|
1: "Unknown",
|
||||||
|
2: "Pending",
|
||||||
|
3: "Processing",
|
||||||
|
4: "Partial",
|
||||||
|
5: "Available",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<number, string> = {
|
||||||
|
1: "bg-slate-700/30 text-slate-500 border-slate-600/40",
|
||||||
|
2: "bg-yellow-500/15 text-yellow-400 border-yellow-700/40",
|
||||||
|
3: "bg-blue-500/15 text-blue-400 border-blue-700/40",
|
||||||
|
4: "bg-cyan-500/15 text-cyan-400 border-cyan-700/40",
|
||||||
|
5: "bg-green-500/15 text-green-400 border-green-700/40",
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: number }) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium ${STATUS_COLOR[status] ?? "bg-slate-700 text-slate-400 border-slate-600"}`}>
|
||||||
|
{STATUS_LABEL[status] ?? `Status ${status}`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rank chip ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RankChip({ rank, total }: { rank: number | null; total: number }) {
|
||||||
|
if (rank === null) return null;
|
||||||
|
return (
|
||||||
|
<span className="text-xs font-mono text-slate-500">
|
||||||
|
#{rank}<span className="text-slate-700">/{total}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stat cards ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StatCard({ label, value, rank, total, highlight }: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
rank?: number | null;
|
||||||
|
total?: number;
|
||||||
|
highlight?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 rounded-xl border border-slate-700/60 bg-slate-800/60 px-4 py-3">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">{label}</span>
|
||||||
|
<span className={`text-2xl font-bold tabular-nums ${highlight ? "text-yellow-300" : "text-white"}`}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{rank !== undefined && rank !== null && total !== undefined && (
|
||||||
|
<RankChip rank={rank} total={total} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chart ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Timeframe = "1W" | "1M" | "3M" | "1Y";
|
||||||
|
|
||||||
|
const TF_CONFIG: Record<Timeframe, { rangeDays: number; bucketDays: number }> = {
|
||||||
|
"1W": { rangeDays: 7, bucketDays: 1 },
|
||||||
|
"1M": { rangeDays: 30, bucketDays: 1 },
|
||||||
|
"3M": { rangeDays: 91, bucketDays: 7 },
|
||||||
|
"1Y": { rangeDays: 365, bucketDays: 30 },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ChartPoint {
|
||||||
|
label: string;
|
||||||
|
requests: number;
|
||||||
|
gb: number;
|
||||||
|
plays: number;
|
||||||
|
watchHours: number;
|
||||||
|
/** GB requested ÷ watch hours. null when watchHours = 0 (no denominator). */
|
||||||
|
load: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChartPoints(
|
||||||
|
enrichedRequests: EnrichedRequest[],
|
||||||
|
watchHistory: WatchDataPoint[],
|
||||||
|
tf: Timeframe
|
||||||
|
): ChartPoint[] {
|
||||||
|
const now = Date.now();
|
||||||
|
const MS = 86_400_000;
|
||||||
|
const { rangeDays, bucketDays } = TF_CONFIG[tf];
|
||||||
|
const numBuckets = Math.ceil(rangeDays / bucketDays);
|
||||||
|
|
||||||
|
const points: ChartPoint[] = Array.from({ length: numBuckets }, (_, i) => {
|
||||||
|
const midMs = now - (numBuckets - 1 - i + 0.5) * bucketDays * MS;
|
||||||
|
const d = new Date(midMs);
|
||||||
|
const label =
|
||||||
|
bucketDays >= 28
|
||||||
|
? d.toLocaleDateString(undefined, { month: "short", year: "2-digit" })
|
||||||
|
: d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
|
return { label, requests: 0, gb: 0, plays: 0, watchHours: 0, load: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const req of enrichedRequests) {
|
||||||
|
const ageMs = now - new Date(req.createdAt).getTime();
|
||||||
|
if (ageMs < 0 || ageMs > rangeDays * MS) continue;
|
||||||
|
const idx = numBuckets - 1 - Math.floor(ageMs / (bucketDays * MS));
|
||||||
|
if (idx >= 0 && idx < numBuckets) {
|
||||||
|
points[idx].requests += 1;
|
||||||
|
points[idx].gb = Math.round((points[idx].gb + req.sizeGB) * 10) / 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const wh of watchHistory) {
|
||||||
|
const ageMs = now - new Date(wh.date + "T12:00:00").getTime();
|
||||||
|
if (ageMs < 0 || ageMs > rangeDays * MS) continue;
|
||||||
|
const idx = numBuckets - 1 - Math.floor(ageMs / (bucketDays * MS));
|
||||||
|
if (idx >= 0 && idx < numBuckets) {
|
||||||
|
points[idx].plays += wh.plays;
|
||||||
|
points[idx].watchHours = Math.round((points[idx].watchHours + wh.durationHours) * 10) / 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of points) {
|
||||||
|
p.load = p.watchHours > 0 ? Math.round((p.gb / p.watchHours) * 10) / 10 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize each series to % of its own period average.
|
||||||
|
* A value equal to the mean shows as 100; double the mean shows as 200.
|
||||||
|
* Series with a mean of 0 stay at 0. load is a ratio — not normalized.
|
||||||
|
*/
|
||||||
|
function normalizeData(points: ChartPoint[]): ChartPoint[] {
|
||||||
|
if (points.length === 0) return points;
|
||||||
|
const n = points.length;
|
||||||
|
const meanReq = points.reduce((s, p) => s + p.requests, 0) / n;
|
||||||
|
const meanGb = points.reduce((s, p) => s + p.gb, 0) / n;
|
||||||
|
const meanWh = points.reduce((s, p) => s + p.watchHours, 0) / n;
|
||||||
|
|
||||||
|
return points.map((p) => ({
|
||||||
|
label: p.label,
|
||||||
|
requests: meanReq > 0 ? Math.round((p.requests / meanReq) * 100) : 0,
|
||||||
|
gb: meanGb > 0 ? Math.round((p.gb / meanGb) * 100) : 0,
|
||||||
|
plays: p.plays,
|
||||||
|
watchHours: meanWh > 0 ? Math.round((p.watchHours / meanWh) * 100) : 0,
|
||||||
|
load: p.load,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Alert severity helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SEV_COLOR: Record<AlertSeverity, string> = {
|
||||||
|
danger: "border-l-red-500 bg-red-950/20",
|
||||||
|
warning: "border-l-yellow-500 bg-yellow-950/10",
|
||||||
|
info: "border-l-blue-500 bg-blue-950/20",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEV_TEXT: Record<AlertSeverity, string> = {
|
||||||
|
danger: "text-red-400",
|
||||||
|
warning: "text-yellow-400",
|
||||||
|
info: "text-blue-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Main component ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function UserDetail({ userId }: { userId: number }) {
|
||||||
|
const [data, setData] = useState<UserPageData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [tf, setTf] = useState<Timeframe>("1M");
|
||||||
|
const [viewMode, setViewMode] = useState<"metrics" | "load">("metrics");
|
||||||
|
const [normalized, setNormalized] = useState(false);
|
||||||
|
const [showRequests, setShowRequests] = useState(true);
|
||||||
|
const [showStorage, setShowStorage] = useState(true);
|
||||||
|
const [showWatchHours, setShowWatchHours] = useState(true);
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
fetch(`/api/users/${userId}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((json) => {
|
||||||
|
if (json.error) throw new Error(json.error);
|
||||||
|
setData(json as UserPageData);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
return buildChartPoints(data.enrichedRequests, data.watchHistory, tf);
|
||||||
|
}, [data, tf]);
|
||||||
|
|
||||||
|
const displayData = useMemo(
|
||||||
|
() => (normalized && viewMode === "metrics" ? normalizeData(chartData) : chartData),
|
||||||
|
[chartData, normalized, viewMode]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasWatch = (data?.watchHistory.length ?? 0) > 0;
|
||||||
|
|
||||||
|
// Request history sorted newest first
|
||||||
|
const sortedByDate = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
return [...data.enrichedRequests].sort(
|
||||||
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const displayedRequests = showAll ? sortedByDate : sortedByDate.slice(0, 20);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-6xl px-4 py-8">
|
||||||
|
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||||
|
<svg className="animate-spin h-8 w-8 text-yellow-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-slate-400 text-sm">Loading user data…</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-6xl px-4 py-8 space-y-4">
|
||||||
|
<Link href="/" className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors cursor-pointer">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||||
|
</svg>
|
||||||
|
All Users
|
||||||
|
</Link>
|
||||||
|
<div className="rounded-xl border border-red-800 bg-red-950/40 px-5 py-4 text-sm">
|
||||||
|
<span className="font-semibold text-red-400">Error: </span>
|
||||||
|
<span className="text-red-300">{error ?? "User not found"}</span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stat, openAlerts } = data;
|
||||||
|
const hasTautulli = stat.plays !== null;
|
||||||
|
|
||||||
|
// User's overall average load (GB requested per watch hour, all time)
|
||||||
|
const overallLoad =
|
||||||
|
hasTautulli && stat.watchHours && stat.watchHours > 0
|
||||||
|
? Math.round((stat.totalGB / stat.watchHours) * 10) / 10
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const statCols = hasTautulli ? "sm:grid-cols-5" : "sm:grid-cols-3";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-6xl px-4 py-8 space-y-6">
|
||||||
|
|
||||||
|
{/* Back */}
|
||||||
|
<Link href="/" className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors cursor-pointer">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||||
|
</svg>
|
||||||
|
All Users
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-white">{stat.displayName}</h1>
|
||||||
|
{stat.requestCount === 0 && (
|
||||||
|
<span className="rounded-full border border-slate-700 bg-slate-800 px-2 py-0.5 text-xs text-slate-500">
|
||||||
|
No requests
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{stat.email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
{hasTautulli && (
|
||||||
|
<span className="text-xs text-slate-600">
|
||||||
|
Last seen: <span className="text-slate-400">{unixTimeAgo(stat.tautulliLastSeen)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-slate-600">
|
||||||
|
#{stat.storageRank} of {stat.totalUsers} by storage
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stat cards */}
|
||||||
|
<div className={`grid grid-cols-2 gap-3 ${statCols}`}>
|
||||||
|
<StatCard label="Requests" value={stat.requestCount.toLocaleString()} rank={stat.requestRank} total={stat.totalUsers} />
|
||||||
|
<StatCard label="Storage" value={formatGB(stat.totalGB)} rank={stat.storageRank} total={stat.totalUsers} highlight />
|
||||||
|
<StatCard label="Avg / Req" value={stat.requestCount > 0 ? formatGB(stat.avgGB) : "—"} />
|
||||||
|
{hasTautulli && (
|
||||||
|
<StatCard label="Plays" value={(stat.plays ?? 0).toLocaleString()} rank={stat.playsRank} total={stat.totalUsers} />
|
||||||
|
)}
|
||||||
|
{hasTautulli && (
|
||||||
|
<StatCard
|
||||||
|
label="Watch Time"
|
||||||
|
value={stat.watchHours !== null && stat.watchHours > 0 ? formatHours(stat.watchHours) : "0h"}
|
||||||
|
rank={stat.watchRank}
|
||||||
|
total={stat.totalUsers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity chart */}
|
||||||
|
<div className="rounded-xl border border-slate-700/60 bg-slate-800/40 p-5 space-y-4">
|
||||||
|
|
||||||
|
{/* Chart header */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-base font-semibold text-white">Activity</h2>
|
||||||
|
|
||||||
|
{/* Metrics / Storage Load mode */}
|
||||||
|
<div className="flex rounded-lg border border-slate-700/60 overflow-hidden text-xs font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("metrics")}
|
||||||
|
className={`cursor-pointer px-2.5 py-1 transition-colors ${viewMode === "metrics" ? "bg-slate-700 text-white" : "text-slate-500 hover:text-slate-300"}`}
|
||||||
|
>
|
||||||
|
Metrics
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("load")}
|
||||||
|
title="GB requested ÷ watch hours — how much server storage each hour of viewing costs"
|
||||||
|
className={`cursor-pointer px-2.5 py-1 transition-colors border-l border-slate-700/60 ${viewMode === "load" ? "bg-orange-500/20 text-orange-300" : "text-slate-500 hover:text-slate-300"}`}
|
||||||
|
>
|
||||||
|
Storage Load
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Raw / Relative — metrics mode only */}
|
||||||
|
{viewMode === "metrics" && (
|
||||||
|
<button
|
||||||
|
onClick={() => setNormalized((v) => !v)}
|
||||||
|
title={normalized ? "Switch to raw values" : "Normalize to % of period average"}
|
||||||
|
className={`cursor-pointer rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors ${normalized ? "border-violet-700/60 bg-violet-500/15 text-violet-400" : "border-slate-700/40 bg-slate-800/40 text-slate-500 hover:text-slate-300"}`}
|
||||||
|
>
|
||||||
|
{normalized ? "Relative" : "Raw"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{(["1W", "1M", "3M", "1Y"] as Timeframe[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTf(t)}
|
||||||
|
className={`cursor-pointer rounded px-2.5 py-1 text-xs font-medium transition-colors ${tf === t ? "bg-yellow-400/20 text-yellow-300" : "text-slate-500 hover:text-slate-300"}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Series toggles — metrics mode only */}
|
||||||
|
{viewMode === "metrics" && (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRequests((v) => !v)}
|
||||||
|
className={`cursor-pointer inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity ${showRequests ? "border-blue-700/60 bg-blue-500/10 text-blue-400" : "border-slate-700/40 bg-slate-800/40 text-slate-600 opacity-50"}`}
|
||||||
|
>
|
||||||
|
<span className="h-2 w-2 rounded-full bg-blue-500 inline-block" />
|
||||||
|
Requests
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowStorage((v) => !v)}
|
||||||
|
className={`cursor-pointer inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity ${showStorage ? "border-yellow-700/60 bg-yellow-500/10 text-yellow-400" : "border-slate-700/40 bg-slate-800/40 text-slate-600 opacity-50"}`}
|
||||||
|
>
|
||||||
|
<span className="h-2 w-2 rounded-full bg-yellow-400 inline-block" />
|
||||||
|
Storage (GB)
|
||||||
|
</button>
|
||||||
|
{hasWatch && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowWatchHours((v) => !v)}
|
||||||
|
className={`cursor-pointer inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity ${showWatchHours ? "border-green-700/60 bg-green-500/10 text-green-400" : "border-slate-700/40 bg-slate-800/40 text-slate-600 opacity-50"}`}
|
||||||
|
>
|
||||||
|
<span className="h-2 w-2 rounded-full bg-green-400 inline-block" />
|
||||||
|
Watch Hours
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Load mode explainer */}
|
||||||
|
{viewMode === "load" && (
|
||||||
|
<p className="text-xs text-slate-600">
|
||||||
|
GB requested ÷ watch hours per period. Lower is healthier — a well-watched library stays near your average.
|
||||||
|
{overallLoad !== null && (
|
||||||
|
<> Your overall average is <span className="text-slate-400">{overallLoad} GB/hr</span>.</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<LineChart
|
||||||
|
data={displayData}
|
||||||
|
margin={{ top: 4, right: viewMode === "load" || normalized ? 8 : 16, left: -8, bottom: 4 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||||
|
<XAxis dataKey="label" tick={{ fill: "#475569", fontSize: 11 }} axisLine={{ stroke: "#334155" }} tickLine={false} />
|
||||||
|
|
||||||
|
{viewMode === "load" && (
|
||||||
|
<YAxis yAxisId="left" orientation="left" tick={{ fill: "#475569", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `${v}G/h`} width={44} />
|
||||||
|
)}
|
||||||
|
{viewMode === "metrics" && normalized && (
|
||||||
|
<YAxis yAxisId="left" orientation="left" tick={{ fill: "#475569", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `${v}%`} width={40} />
|
||||||
|
)}
|
||||||
|
{viewMode === "metrics" && !normalized && (
|
||||||
|
<YAxis yAxisId="counts" orientation="left" tick={{ fill: "#475569", fontSize: 11 }} axisLine={false} tickLine={false} width={32} />
|
||||||
|
)}
|
||||||
|
{viewMode === "metrics" && !normalized && (
|
||||||
|
<YAxis yAxisId="gb" orientation="right" tick={{ fill: "#475569", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `${v}G`} width={40} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: "#0f172a", border: "1px solid #334155", borderRadius: "8px", fontSize: "12px" }}
|
||||||
|
labelStyle={{ color: "#94a3b8", marginBottom: "4px" }}
|
||||||
|
itemStyle={{ color: "#e2e8f0" }}
|
||||||
|
formatter={(value, name) => {
|
||||||
|
const num = typeof value === "number" ? value : Number(value);
|
||||||
|
const label = String(name ?? "");
|
||||||
|
if (viewMode === "load") return [`${num} GB/hr`, label];
|
||||||
|
if (normalized) return [`${num}%`, label];
|
||||||
|
if (label === "Storage (GB)") return [formatGB(num), label];
|
||||||
|
if (label === "Watch Hours") return [`${num}h`, label];
|
||||||
|
return [num, label];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ display: "none" }} />
|
||||||
|
|
||||||
|
{viewMode === "metrics" && normalized && (
|
||||||
|
<ReferenceLine yAxisId="left" y={100} stroke="#334155" strokeDasharray="4 3" label={{ value: "avg", position: "insideTopRight", fill: "#475569", fontSize: 10 }} />
|
||||||
|
)}
|
||||||
|
{viewMode === "load" && overallLoad !== null && (
|
||||||
|
<ReferenceLine yAxisId="left" y={overallLoad} stroke="#f97316" strokeOpacity={0.4} strokeDasharray="4 3" label={{ value: "avg", position: "insideTopRight", fill: "#f97316", fontSize: 10 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === "load" && (
|
||||||
|
<Line yAxisId="left" type="monotone" dataKey="load" stroke="#f97316" strokeWidth={2} dot={false} activeDot={{ r: 4 }} connectNulls={false} name="GB / Watch Hr" />
|
||||||
|
)}
|
||||||
|
{viewMode === "metrics" && showRequests && (
|
||||||
|
<Line yAxisId={normalized ? "left" : "counts"} type="monotone" dataKey="requests" stroke="#3b82f6" strokeWidth={2} dot={false} activeDot={{ r: 4 }} name="Requests" />
|
||||||
|
)}
|
||||||
|
{viewMode === "metrics" && showStorage && (
|
||||||
|
<Line yAxisId={normalized ? "left" : "gb"} type="monotone" dataKey="gb" stroke="#facc15" strokeWidth={2} dot={false} activeDot={{ r: 4 }} name="Storage (GB)" />
|
||||||
|
)}
|
||||||
|
{viewMode === "metrics" && hasWatch && showWatchHours && (
|
||||||
|
<Line yAxisId={normalized ? "left" : "counts"} type="monotone" dataKey="watchHours" stroke="#4ade80" strokeWidth={2} dot={false} activeDot={{ r: 4 }} name="Watch Hours" />
|
||||||
|
)}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
|
||||||
|
{chartData.every((p) => p.requests === 0 && p.gb === 0 && p.watchHours === 0) && (
|
||||||
|
<p className="text-center text-sm text-slate-600 py-2">No activity in this period</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Request history */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-base font-semibold text-white">
|
||||||
|
Request History
|
||||||
|
<span className="ml-2 text-xs font-normal text-slate-600">{sortedByDate.length} total</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{sortedByDate.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-slate-700/40 bg-slate-800/20 px-5 py-6 text-center text-sm text-slate-600">
|
||||||
|
No requests yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-slate-700/60">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-800/80 border-b border-slate-700/60">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2.5 px-4 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">Title</th>
|
||||||
|
<th className="py-2.5 px-4 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">Type</th>
|
||||||
|
<th className="py-2.5 px-4 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">Status</th>
|
||||||
|
<th className="py-2.5 px-4 text-right text-xs font-semibold uppercase tracking-wider text-slate-500">Size</th>
|
||||||
|
<th className="py-2.5 px-4 text-right text-xs font-semibold uppercase tracking-wider text-slate-500">Requested</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-700/30">
|
||||||
|
{displayedRequests.map((req) => (
|
||||||
|
<tr key={req.id} className="bg-slate-900 hover:bg-slate-800/50 transition-colors">
|
||||||
|
<td className="py-2.5 px-4 font-medium text-white max-w-xs truncate">{req.title}</td>
|
||||||
|
<td className="py-2.5 px-4">
|
||||||
|
<span className={`rounded border px-1.5 py-0.5 text-xs ${req.type === "movie" ? "border-purple-700/40 bg-purple-500/10 text-purple-400" : "border-cyan-700/40 bg-cyan-500/10 text-cyan-400"}`}>
|
||||||
|
{req.type === "movie" ? "Movie" : "TV"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-4">
|
||||||
|
<StatusBadge status={req.status} />
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-4 text-right font-mono text-xs tabular-nums text-slate-400">
|
||||||
|
{req.sizeGB > 0 ? formatGB(req.sizeGB) : <span className="text-slate-700">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-4 text-right text-xs text-slate-600">
|
||||||
|
{new Date(req.createdAt).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sortedByDate.length > 20 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAll((v) => !v)}
|
||||||
|
className="cursor-pointer w-full rounded-lg border border-slate-700/40 bg-slate-800/20 py-2 text-sm text-slate-500 hover:text-slate-300 hover:bg-slate-800/40 transition-colors"
|
||||||
|
>
|
||||||
|
{showAll ? "Show recent 20" : `Show all ${sortedByDate.length} requests`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Open alerts */}
|
||||||
|
{openAlerts.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-base font-semibold text-white">
|
||||||
|
Open Alerts
|
||||||
|
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-yellow-500 text-black text-xs font-bold px-1.5 py-0.5 leading-none">
|
||||||
|
{openAlerts.length}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{openAlerts.map((alert) => (
|
||||||
|
<Link
|
||||||
|
key={alert.id}
|
||||||
|
href={`/alerts/${alert.id}`}
|
||||||
|
className={`block rounded-xl border border-slate-700/60 border-l-4 px-4 py-3.5 transition-colors hover:bg-slate-800/60 cursor-pointer ${SEV_COLOR[alert.severity]}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className={`text-xs font-semibold uppercase tracking-wider ${SEV_TEXT[alert.severity]}`}>
|
||||||
|
{alert.category}
|
||||||
|
</span>
|
||||||
|
<p className="mt-0.5 text-sm font-medium text-white leading-snug">{alert.title}</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500 line-clamp-2">{alert.description}</p>
|
||||||
|
</div>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4 shrink-0 text-slate-600 mt-0.5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/app/users/[id]/page.tsx
Normal file
10
src/app/users/[id]/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import UserDetail from "./UserDetail";
|
||||||
|
|
||||||
|
export default async function UserPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
return <UserDetail userId={Number(id)} />;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { UserStat } from "@/lib/types";
|
import { UserStat } from "@/lib/types";
|
||||||
|
|
||||||
type SortKey = "totalBytes" | "requestCount" | "avgGB" | "plays" | "watchHours";
|
type SortKey = "totalBytes" | "requestCount" | "avgGB" | "plays" | "watchHours";
|
||||||
@@ -124,8 +125,15 @@ export default function LeaderboardTable({
|
|||||||
|
|
||||||
{/* User */}
|
{/* User */}
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<div className="font-medium text-white leading-snug">{user.displayName}</div>
|
<Link
|
||||||
|
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>
|
<div className="text-xs text-slate-600 mt-0.5">{user.email}</div>
|
||||||
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Requests */}
|
{/* Requests */}
|
||||||
|
|||||||
14
src/instrumentation.ts
Normal file
14
src/instrumentation.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Next.js instrumentation hook — runs once when the server starts.
|
||||||
|
* Used to kick off the background stats poller so alerts are generated
|
||||||
|
* and Discord notifications are sent even when no client is connected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function register() {
|
||||||
|
// Only run in the Node.js runtime (not Edge).
|
||||||
|
// better-sqlite3 and the fetch clients require Node.js APIs.
|
||||||
|
if (process.env.NEXT_RUNTIME === "edge") return;
|
||||||
|
|
||||||
|
const { startBackgroundPoller } = await import("@/lib/statsBuilder");
|
||||||
|
startBackgroundPoller();
|
||||||
|
}
|
||||||
111
src/lib/statsBuilder.ts
Normal file
111
src/lib/statsBuilder.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Shared stats-build logic and server-side SWR cache.
|
||||||
|
*
|
||||||
|
* Imported by both the /api/stats route handler (for on-demand fetches) and
|
||||||
|
* instrumentation.ts (for the background poller that runs independent of
|
||||||
|
* client activity).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { buildRadarrMap } from "@/lib/radarr";
|
||||||
|
import { buildSonarrMap } from "@/lib/sonarr";
|
||||||
|
import { fetchAllUsers, fetchUserRequests } from "@/lib/overseerr";
|
||||||
|
import { buildTautulliMap } from "@/lib/tautulli";
|
||||||
|
import { computeStats } from "@/lib/aggregate";
|
||||||
|
import { DashboardStats, MediaEntry, OverseerrRequest, TautulliUser } from "@/lib/types";
|
||||||
|
|
||||||
|
const BATCH_SIZE = 5;
|
||||||
|
const STALE_MS = 5 * 60 * 1000;
|
||||||
|
const POLL_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// ── Encapsulated cache ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let cache: { stats: DashboardStats; at: number } | null = null;
|
||||||
|
let refreshing = false;
|
||||||
|
|
||||||
|
/** Raw data cached alongside stats for use by the user-page API route. */
|
||||||
|
export interface RawCache {
|
||||||
|
radarrMap: Map<number, MediaEntry>;
|
||||||
|
sonarrMap: Map<number, MediaEntry>;
|
||||||
|
allRequests: Map<number, OverseerrRequest[]>;
|
||||||
|
tautulliMap: Map<string, TautulliUser> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawCache: RawCache | null = null;
|
||||||
|
|
||||||
|
export function getRawCache(): RawCache | null {
|
||||||
|
return rawCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core build function ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function buildStats(): Promise<DashboardStats> {
|
||||||
|
const [radarrMap, sonarrMap, users, tautulliMap] = await Promise.all([
|
||||||
|
buildRadarrMap(),
|
||||||
|
buildSonarrMap(),
|
||||||
|
fetchAllUsers(),
|
||||||
|
buildTautulliMap(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allRequests = new Map<number, OverseerrRequest[]>();
|
||||||
|
for (let i = 0; i < users.length; i += BATCH_SIZE) {
|
||||||
|
const chunk = users.slice(i, i + BATCH_SIZE);
|
||||||
|
const results = await Promise.all(chunk.map((u) => fetchUserRequests(u.id)));
|
||||||
|
chunk.forEach((u, idx) => allRequests.set(u.id, results[idx]));
|
||||||
|
}
|
||||||
|
|
||||||
|
rawCache = { radarrMap, sonarrMap, allRequests, tautulliMap };
|
||||||
|
|
||||||
|
return computeStats(users, allRequests, radarrMap, sonarrMap, tautulliMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API used by the route handler ─────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns stats, using the in-process cache.
|
||||||
|
* - force=true: always fetches fresh data and waits for it
|
||||||
|
* - force=false: returns cache immediately; if stale, kicks a background refresh
|
||||||
|
*/
|
||||||
|
export async function getStats(force = false): Promise<DashboardStats> {
|
||||||
|
if (force || !cache) {
|
||||||
|
const stats = await buildStats();
|
||||||
|
cache = { stats, at: Date.now() };
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
const age = Date.now() - cache.at;
|
||||||
|
if (age > STALE_MS && !refreshing) {
|
||||||
|
refreshing = true;
|
||||||
|
buildStats()
|
||||||
|
.then((stats) => { cache = { stats, at: Date.now() }; })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => { refreshing = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache.stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Background poller ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
if (refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
|
try {
|
||||||
|
const stats = await buildStats();
|
||||||
|
cache = { stats, at: Date.now() };
|
||||||
|
console.log("[poller] Stats refreshed at", new Date().toISOString());
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[poller] Refresh failed:", err);
|
||||||
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the background poller. Called once from instrumentation.ts on server
|
||||||
|
* startup. Runs an initial fetch immediately, then repeats every 5 minutes.
|
||||||
|
*/
|
||||||
|
export function startBackgroundPoller() {
|
||||||
|
console.log("[poller] Starting (interval: 5 min)");
|
||||||
|
poll(); // immediate first run — no waiting for a client request
|
||||||
|
setInterval(poll, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { TautulliUser } from "@/lib/types";
|
import { TautulliUser, WatchDataPoint } from "@/lib/types";
|
||||||
import { getSettings } from "@/lib/settings";
|
import { getSettings } from "@/lib/settings";
|
||||||
|
|
||||||
interface TautulliRow {
|
interface TautulliRow {
|
||||||
|
user_id: number;
|
||||||
friendly_name: string;
|
friendly_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
plays: number;
|
plays: number;
|
||||||
@@ -48,6 +49,7 @@ export async function buildTautulliMap(): Promise<Map<string, TautulliUser> | nu
|
|||||||
|
|
||||||
for (const row of json.response.data.data) {
|
for (const row of json.response.data.data) {
|
||||||
const user: TautulliUser = {
|
const user: TautulliUser = {
|
||||||
|
user_id: row.user_id ?? 0,
|
||||||
friendly_name: row.friendly_name,
|
friendly_name: row.friendly_name,
|
||||||
email: row.email ?? "",
|
email: row.email ?? "",
|
||||||
plays: row.plays ?? 0,
|
plays: row.plays ?? 0,
|
||||||
@@ -78,3 +80,71 @@ export function lookupTautulliUser(
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TautulliHistoryRow {
|
||||||
|
date: number; // unix timestamp (session start)
|
||||||
|
duration: number; // seconds watched
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TautulliHistoryResponse {
|
||||||
|
response: {
|
||||||
|
result: string;
|
||||||
|
data: {
|
||||||
|
recordsFiltered: number;
|
||||||
|
recordsTotal: number;
|
||||||
|
data: TautulliHistoryRow[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches individual session history for a Tautulli user and aggregates by day.
|
||||||
|
* Returns an empty array if Tautulli is not configured or the call fails.
|
||||||
|
*/
|
||||||
|
export async function fetchUserWatchHistory(
|
||||||
|
tautulliUserId: number
|
||||||
|
): Promise<WatchDataPoint[]> {
|
||||||
|
const { tautulli } = getSettings();
|
||||||
|
const { url, apiKey } = tautulli;
|
||||||
|
if (!url || !apiKey || !tautulliUserId) return [];
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(
|
||||||
|
`${url}/api/v2?apikey=${apiKey}&cmd=get_history&user_id=${tautulliUserId}&length=10000&order_column=date&order_dir=asc`,
|
||||||
|
{ cache: "no-store" }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) return [];
|
||||||
|
|
||||||
|
let json: TautulliHistoryResponse;
|
||||||
|
try {
|
||||||
|
json = await res.json() as TautulliHistoryResponse;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json.response?.result !== "success") return [];
|
||||||
|
|
||||||
|
const byDate = new Map<string, { plays: number; durationSeconds: number }>();
|
||||||
|
|
||||||
|
for (const row of json.response.data.data ?? []) {
|
||||||
|
if (!row.date) continue;
|
||||||
|
const date = new Date(row.date * 1000).toISOString().slice(0, 10);
|
||||||
|
const existing = byDate.get(date) ?? { plays: 0, durationSeconds: 0 };
|
||||||
|
existing.plays += 1;
|
||||||
|
existing.durationSeconds += row.duration ?? 0;
|
||||||
|
byDate.set(date, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byDate.entries())
|
||||||
|
.map(([date, { plays, durationSeconds }]) => ({
|
||||||
|
date,
|
||||||
|
plays,
|
||||||
|
durationHours: Math.round((durationSeconds / 3600) * 10) / 10,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface OverseerrUser {
|
|||||||
export interface OverseerrRequest {
|
export interface OverseerrRequest {
|
||||||
id: number;
|
id: number;
|
||||||
type: "movie" | "tv";
|
type: "movie" | "tv";
|
||||||
status: number; // 1=pending, 2=approved, 3=declined, 4=available
|
status: number; // media status: 1=unknown, 2=pending, 3=processing, 4=partial, 5=available
|
||||||
createdAt: string; // ISO timestamp
|
createdAt: string; // ISO timestamp
|
||||||
media: {
|
media: {
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
@@ -42,6 +42,7 @@ export interface SonarrSeries {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TautulliUser {
|
export interface TautulliUser {
|
||||||
|
user_id: number;
|
||||||
friendly_name: string;
|
friendly_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
plays: number;
|
plays: number;
|
||||||
@@ -130,6 +131,34 @@ export interface AlertComment {
|
|||||||
|
|
||||||
export type AlertCloseReason = "manual" | "resolved";
|
export type AlertCloseReason = "manual" | "resolved";
|
||||||
|
|
||||||
|
// ─── User page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A single Overseerr request enriched with resolved media title and size */
|
||||||
|
export interface EnrichedRequest {
|
||||||
|
id: number;
|
||||||
|
type: "movie" | "tv";
|
||||||
|
status: number; // media status: 1=unknown, 2=pending, 3=processing, 4=partial, 5=available
|
||||||
|
createdAt: string;
|
||||||
|
mediaId: number; // tmdbId for movies, tvdbId for TV
|
||||||
|
title: string;
|
||||||
|
sizeOnDisk: number; // bytes
|
||||||
|
sizeGB: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One day of watch activity from Tautulli */
|
||||||
|
export interface WatchDataPoint {
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
plays: number;
|
||||||
|
durationHours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserPageData {
|
||||||
|
stat: UserStat;
|
||||||
|
enrichedRequests: EnrichedRequest[];
|
||||||
|
watchHistory: WatchDataPoint[]; // daily, sorted ascending; empty if Tautulli not configured
|
||||||
|
openAlerts: Alert[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Full persisted alert returned by the API */
|
/** Full persisted alert returned by the API */
|
||||||
export interface Alert extends AlertCandidate {
|
export interface Alert extends AlertCandidate {
|
||||||
id: number; // auto-increment DB id (used in URLs)
|
id: number; // auto-increment DB id (used in URLs)
|
||||||
|
|||||||
Reference in New Issue
Block a user