Build OverSnitch dashboard
Full implementation on top of the Next.js scaffold: - Leaderboard with per-user request count, storage, avg GB/req, and optional Tautulli watch stats (plays, watch hours), each with dense per-metric rank (#N/total) - SWR cache on /api/stats (5-min stale, force-refresh via button); client-side localStorage seed so the UI is instant on return visits - Alerting system: content-centric alerts (unfulfilled downloads, partial TV downloads, stale pending requests) and user-behavior alerts (ghost requester, low watch rate, declined streak) - Partial TV detection: flags ended series with <90% of episodes on disk - Alert persistence in data/alerts.json with open/closed state, auto-resolve when condition clears, manual close with per-category cooldown, and per-alert notes - Alert detail page rendered as a server component for instant load - Dark UI with Tailwind v4, severity-colored left borders, summary cards with icons, sortable leaderboard table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
78
src/lib/tautulli.ts
Normal file
78
src/lib/tautulli.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { TautulliUser } from "@/lib/types";
|
||||
|
||||
interface TautulliRow {
|
||||
friendly_name: string;
|
||||
email: string;
|
||||
plays: number;
|
||||
duration: number;
|
||||
last_seen: number | null;
|
||||
}
|
||||
|
||||
interface TautulliResponse {
|
||||
response: {
|
||||
result: string;
|
||||
data: {
|
||||
data: TautulliRow[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Map<lowercaseEmail, TautulliUser>.
|
||||
* Returns null if TAUTULLI_URL/TAUTULLI_API are not set.
|
||||
*/
|
||||
export async function buildTautulliMap(): Promise<Map<string, TautulliUser> | null> {
|
||||
const url = process.env.TAUTULLI_URL;
|
||||
const key = process.env.TAUTULLI_API;
|
||||
|
||||
if (!url || !key) return null;
|
||||
|
||||
const res = await fetch(
|
||||
`${url}/api/v2?apikey=${key}&cmd=get_users_table&length=1000&order_column=friendly_name&order_dir=asc`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Tautulli API error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const json: TautulliResponse = await res.json();
|
||||
|
||||
if (json.response.result !== "success") {
|
||||
throw new Error(`Tautulli API returned non-success result`);
|
||||
}
|
||||
|
||||
const map = new Map<string, TautulliUser>();
|
||||
|
||||
for (const row of json.response.data.data) {
|
||||
const user: TautulliUser = {
|
||||
friendly_name: row.friendly_name,
|
||||
email: row.email ?? "",
|
||||
plays: row.plays ?? 0,
|
||||
duration: row.duration ?? 0,
|
||||
last_seen: row.last_seen ?? null,
|
||||
};
|
||||
|
||||
if (user.email) {
|
||||
map.set(user.email.toLowerCase(), user);
|
||||
}
|
||||
// Also index by friendly_name as fallback key
|
||||
if (user.friendly_name) {
|
||||
map.set(`name:${user.friendly_name.toLowerCase()}`, user);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export function lookupTautulliUser(
|
||||
tautulliMap: Map<string, TautulliUser>,
|
||||
email: string,
|
||||
displayName: string
|
||||
): TautulliUser | null {
|
||||
return (
|
||||
tautulliMap.get(email.toLowerCase()) ??
|
||||
tautulliMap.get(`name:${displayName.toLowerCase()}`) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user