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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 17:26:47 -04:00
parent 641a7fd096
commit b2c1642065
14 changed files with 1377 additions and 66 deletions
+111
View File
@@ -0,0 +1,111 @@
/**
* Shared stats-build logic and server-side SWR cache.
*
* Imported by both the /api/stats route handler (for on-demand fetches) and
* instrumentation.ts (for the background poller that runs independent of
* client activity).
*/
import { buildRadarrMap } from "@/lib/radarr";
import { buildSonarrMap } from "@/lib/sonarr";
import { fetchAllUsers, fetchUserRequests } from "@/lib/overseerr";
import { buildTautulliMap } from "@/lib/tautulli";
import { computeStats } from "@/lib/aggregate";
import { DashboardStats, MediaEntry, OverseerrRequest, TautulliUser } from "@/lib/types";
const BATCH_SIZE = 5;
const STALE_MS = 5 * 60 * 1000;
const POLL_INTERVAL_MS = 5 * 60 * 1000;
// ── Encapsulated cache ────────────────────────────────────────────────────────
let cache: { stats: DashboardStats; at: number } | null = null;
let refreshing = false;
/** Raw data cached alongside stats for use by the user-page API route. */
export interface RawCache {
radarrMap: Map<number, MediaEntry>;
sonarrMap: Map<number, MediaEntry>;
allRequests: Map<number, OverseerrRequest[]>;
tautulliMap: Map<string, TautulliUser> | null;
}
let rawCache: RawCache | null = null;
export function getRawCache(): RawCache | null {
return rawCache;
}
// ── Core build function ───────────────────────────────────────────────────────
async function buildStats(): Promise<DashboardStats> {
const [radarrMap, sonarrMap, users, tautulliMap] = await Promise.all([
buildRadarrMap(),
buildSonarrMap(),
fetchAllUsers(),
buildTautulliMap(),
]);
const allRequests = new Map<number, OverseerrRequest[]>();
for (let i = 0; i < users.length; i += BATCH_SIZE) {
const chunk = users.slice(i, i + BATCH_SIZE);
const results = await Promise.all(chunk.map((u) => fetchUserRequests(u.id)));
chunk.forEach((u, idx) => allRequests.set(u.id, results[idx]));
}
rawCache = { radarrMap, sonarrMap, allRequests, tautulliMap };
return computeStats(users, allRequests, radarrMap, sonarrMap, tautulliMap);
}
// ── Public API used by the route handler ─────────────────────────────────────
/**
* Returns stats, using the in-process cache.
* - force=true: always fetches fresh data and waits for it
* - force=false: returns cache immediately; if stale, kicks a background refresh
*/
export async function getStats(force = false): Promise<DashboardStats> {
if (force || !cache) {
const stats = await buildStats();
cache = { stats, at: Date.now() };
return stats;
}
const age = Date.now() - cache.at;
if (age > STALE_MS && !refreshing) {
refreshing = true;
buildStats()
.then((stats) => { cache = { stats, at: Date.now() }; })
.catch(() => {})
.finally(() => { refreshing = false; });
}
return cache.stats;
}
// ── Background poller ─────────────────────────────────────────────────────────
async function poll() {
if (refreshing) return;
refreshing = true;
try {
const stats = await buildStats();
cache = { stats, at: Date.now() };
console.log("[poller] Stats refreshed at", new Date().toISOString());
} catch (err) {
console.error("[poller] Refresh failed:", err);
} finally {
refreshing = false;
}
}
/**
* Starts the background poller. Called once from instrumentation.ts on server
* startup. Runs an initial fetch immediately, then repeats every 5 minutes.
*/
export function startBackgroundPoller() {
console.log("[poller] Starting (interval: 5 min)");
poll(); // immediate first run — no waiting for a client request
setInterval(poll, POLL_INTERVAL_MS);
}
+71 -1
View File
@@ -1,7 +1,8 @@
import { TautulliUser } from "@/lib/types";
import { TautulliUser, WatchDataPoint } from "@/lib/types";
import { getSettings } from "@/lib/settings";
interface TautulliRow {
user_id: number;
friendly_name: string;
email: string;
plays: number;
@@ -48,6 +49,7 @@ export async function buildTautulliMap(): Promise<Map<string, TautulliUser> | nu
for (const row of json.response.data.data) {
const user: TautulliUser = {
user_id: row.user_id ?? 0,
friendly_name: row.friendly_name,
email: row.email ?? "",
plays: row.plays ?? 0,
@@ -78,3 +80,71 @@ export function lookupTautulliUser(
null
);
}
interface TautulliHistoryRow {
date: number; // unix timestamp (session start)
duration: number; // seconds watched
}
interface TautulliHistoryResponse {
response: {
result: string;
data: {
recordsFiltered: number;
recordsTotal: number;
data: TautulliHistoryRow[];
};
};
}
/**
* Fetches individual session history for a Tautulli user and aggregates by day.
* Returns an empty array if Tautulli is not configured or the call fails.
*/
export async function fetchUserWatchHistory(
tautulliUserId: number
): Promise<WatchDataPoint[]> {
const { tautulli } = getSettings();
const { url, apiKey } = tautulli;
if (!url || !apiKey || !tautulliUserId) return [];
let res: Response;
try {
res = await fetch(
`${url}/api/v2?apikey=${apiKey}&cmd=get_history&user_id=${tautulliUserId}&length=10000&order_column=date&order_dir=asc`,
{ cache: "no-store" }
);
} catch {
return [];
}
if (!res.ok) return [];
let json: TautulliHistoryResponse;
try {
json = await res.json() as TautulliHistoryResponse;
} catch {
return [];
}
if (json.response?.result !== "success") return [];
const byDate = new Map<string, { plays: number; durationSeconds: number }>();
for (const row of json.response.data.data ?? []) {
if (!row.date) continue;
const date = new Date(row.date * 1000).toISOString().slice(0, 10);
const existing = byDate.get(date) ?? { plays: 0, durationSeconds: 0 };
existing.plays += 1;
existing.durationSeconds += row.duration ?? 0;
byDate.set(date, existing);
}
return Array.from(byDate.entries())
.map(([date, { plays, durationSeconds }]) => ({
date,
plays,
durationHours: Math.round((durationSeconds / 3600) * 10) / 10,
}))
.sort((a, b) => a.date.localeCompare(b.date));
}
+30 -1
View File
@@ -11,7 +11,7 @@ export interface OverseerrUser {
export interface OverseerrRequest {
id: number;
type: "movie" | "tv";
status: number; // 1=pending, 2=approved, 3=declined, 4=available
status: number; // media status: 1=unknown, 2=pending, 3=processing, 4=partial, 5=available
createdAt: string; // ISO timestamp
media: {
tmdbId: number;
@@ -42,6 +42,7 @@ export interface SonarrSeries {
}
export interface TautulliUser {
user_id: number;
friendly_name: string;
email: string;
plays: number;
@@ -130,6 +131,34 @@ export interface AlertComment {
export type AlertCloseReason = "manual" | "resolved";
// ─── User page ────────────────────────────────────────────────────────────────
/** A single Overseerr request enriched with resolved media title and size */
export interface EnrichedRequest {
id: number;
type: "movie" | "tv";
status: number; // media status: 1=unknown, 2=pending, 3=processing, 4=partial, 5=available
createdAt: string;
mediaId: number; // tmdbId for movies, tvdbId for TV
title: string;
sizeOnDisk: number; // bytes
sizeGB: number;
}
/** One day of watch activity from Tautulli */
export interface WatchDataPoint {
date: string; // YYYY-MM-DD
plays: number;
durationHours: number;
}
export interface UserPageData {
stat: UserStat;
enrichedRequests: EnrichedRequest[];
watchHistory: WatchDataPoint[]; // daily, sorted ascending; empty if Tautulli not configured
openAlerts: Alert[];
}
/** Full persisted alert returned by the API */
export interface Alert extends AlertCandidate {
id: number; // auto-increment DB id (used in URLs)