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:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user