Files
OverSnitch/src/lib/tautulli.ts
Josh Wright 641a7fd096 Add settings UI, Discord notifications, and alert detail improvements
- Settings modal (gear icon) lets you configure all service URLs and API
  keys from the dashboard; values persist to data/settings.json with
  process.env as fallback so existing .env.local setups keep working
- Per-service Test button hits each service's status endpoint and reports
  the version on success
- Discord webhook support: structured embeds per alert category (requesters,
  approval age, episode progress, watch-rate stats) sent on new/reopened
  alerts only — already-open alerts don't re-notify
- Alert detail page restructured: prose descriptions replaced with labelled
  fields, episode progress bar for partial TV, watch-rate stat block,
  View in Radarr/Sonarr/Seerr action buttons, requester names link to
  Overseerr profiles, timestamps moved inline with status
- Tab state is pure client state (no ?tab= in URL); router.back() used
  on alert detail for clean browser history

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 14:57:07 -04:00

81 lines
1.9 KiB
TypeScript

import { TautulliUser } from "@/lib/types";
import { getSettings } from "@/lib/settings";
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 { tautulli } = getSettings();
const url = tautulli.url;
const key = tautulli.apiKey;
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
);
}