diff --git a/README.md b/README.md index 387b34f..0a94567 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package-lock.json b/package-lock.json index cab72b9..b9722b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d83ce09..bb7227e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/alerts/[id]/AlertDetail.tsx b/src/app/alerts/[id]/AlertDetail.tsx index d569fba..4929cad 100644 --- a/src/app/alerts/[id]/AlertDetail.tsx +++ b/src/app/alerts/[id]/AlertDetail.tsx @@ -393,7 +393,7 @@ export default function AlertDetail({ initialAlert, radarrUrl, sonarrUrl, seerrU {/* Back */} + + + + {/* Raw / Relative — metrics mode only */} + {viewMode === "metrics" && ( + + )} + + +
+ {(["1W", "1M", "3M", "1Y"] as Timeframe[]).map((t) => ( + + ))} +
+ + + {/* Series toggles — metrics mode only */} + {viewMode === "metrics" && ( +
+ + + {hasWatch && ( + + )} +
+ )} + + {/* Load mode explainer */} + {viewMode === "load" && ( +

+ GB requested ÷ watch hours per period. Lower is healthier — a well-watched library stays near your average. + {overallLoad !== null && ( + <> Your overall average is {overallLoad} GB/hr. + )} +

+ )} + + {/* Chart */} + + + + + + {viewMode === "load" && ( + `${v}G/h`} width={44} /> + )} + {viewMode === "metrics" && normalized && ( + `${v}%`} width={40} /> + )} + {viewMode === "metrics" && !normalized && ( + + )} + {viewMode === "metrics" && !normalized && ( + `${v}G`} width={40} /> + )} + + { + 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]; + }} + /> + + + {viewMode === "metrics" && normalized && ( + + )} + {viewMode === "load" && overallLoad !== null && ( + + )} + + {viewMode === "load" && ( + + )} + {viewMode === "metrics" && showRequests && ( + + )} + {viewMode === "metrics" && showStorage && ( + + )} + {viewMode === "metrics" && hasWatch && showWatchHours && ( + + )} + + + + {chartData.every((p) => p.requests === 0 && p.gb === 0 && p.watchHours === 0) && ( +

No activity in this period

+ )} + + + {/* Request history */} +
+

+ Request History + {sortedByDate.length} total +

+ + {sortedByDate.length === 0 ? ( +
+ No requests yet +
+ ) : ( + <> +
+ + + + + + + + + + + + {displayedRequests.map((req) => ( + + + + + + + + ))} + +
TitleTypeStatusSizeRequested
{req.title} + + {req.type === "movie" ? "Movie" : "TV"} + + + + + {req.sizeGB > 0 ? formatGB(req.sizeGB) : } + + {new Date(req.createdAt).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })} +
+
+ + {sortedByDate.length > 20 && ( + + )} + + )} +
+ + {/* Open alerts */} + {openAlerts.length > 0 && ( +
+

+ Open Alerts + + {openAlerts.length} + +

+
+ {openAlerts.map((alert) => ( + +
+
+ + {alert.category} + +

{alert.title}

+

{alert.description}

+
+ + + +
+ + ))} +
+
+ )} + + + ); +} diff --git a/src/app/users/[id]/page.tsx b/src/app/users/[id]/page.tsx new file mode 100644 index 0000000..e61ff7d --- /dev/null +++ b/src/app/users/[id]/page.tsx @@ -0,0 +1,10 @@ +import UserDetail from "./UserDetail"; + +export default async function UserPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + return ; +} diff --git a/src/components/LeaderboardTable.tsx b/src/components/LeaderboardTable.tsx index 52bf380..3a4483e 100644 --- a/src/components/LeaderboardTable.tsx +++ b/src/components/LeaderboardTable.tsx @@ -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 */} -
{user.displayName}
-
{user.email}
+ +
+ {user.displayName} +
+
{user.email}
+ {/* Requests */} diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..b6d2ce1 --- /dev/null +++ b/src/instrumentation.ts @@ -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(); +} diff --git a/src/lib/statsBuilder.ts b/src/lib/statsBuilder.ts new file mode 100644 index 0000000..29c4d65 --- /dev/null +++ b/src/lib/statsBuilder.ts @@ -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; + sonarrMap: Map; + allRequests: Map; + tautulliMap: Map | null; +} + +let rawCache: RawCache | null = null; + +export function getRawCache(): RawCache | null { + return rawCache; +} + +// ── Core build function ─────────────────────────────────────────────────────── + +async function buildStats(): Promise { + const [radarrMap, sonarrMap, users, tautulliMap] = await Promise.all([ + buildRadarrMap(), + buildSonarrMap(), + fetchAllUsers(), + buildTautulliMap(), + ]); + + const allRequests = new Map(); + 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 { + 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); +} diff --git a/src/lib/tautulli.ts b/src/lib/tautulli.ts index 84e064f..05938ca 100644 --- a/src/lib/tautulli.ts +++ b/src/lib/tautulli.ts @@ -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 | 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 { + 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(); + + 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)); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index a0f324f..4631fa7 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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)