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>
This commit is contained in:
2026-04-12 14:57:07 -04:00
parent 2374bad7ba
commit 641a7fd096
20 changed files with 2191 additions and 302 deletions
+89
View File
@@ -0,0 +1,89 @@
/**
* Persistent settings store.
*
* Settings are read from data/settings.json when present, with process.env
* values used as fallbacks. This means existing .env.local setups keep working
* with no changes; the UI just provides an alternative way to configure them.
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
import { join } from "path";
const DATA_DIR = join(process.cwd(), "data");
const SETTINGS_PATH = join(DATA_DIR, "settings.json");
export interface ServiceConfig {
url: string;
apiKey: string;
}
export interface DiscordConfig {
webhookUrl: string;
}
export interface AppSettings {
radarr: ServiceConfig;
sonarr: ServiceConfig;
seerr: ServiceConfig;
tautulli: ServiceConfig;
discord: DiscordConfig;
}
interface StoredSettings {
radarr?: Partial<ServiceConfig>;
sonarr?: Partial<ServiceConfig>;
seerr?: Partial<ServiceConfig>;
tautulli?: Partial<ServiceConfig>;
discord?: Partial<DiscordConfig>;
}
function readFile(): StoredSettings {
try {
return JSON.parse(readFileSync(SETTINGS_PATH, "utf-8")) as StoredSettings;
} catch {
return {};
}
}
/** Returns the merged settings (file values override env vars). */
export function getSettings(): AppSettings {
const f = readFile();
return {
radarr: {
url: f.radarr?.url ?? process.env.RADARR_URL ?? "",
apiKey: f.radarr?.apiKey ?? process.env.RADARR_API ?? "",
},
sonarr: {
url: f.sonarr?.url ?? process.env.SONARR_URL ?? "",
apiKey: f.sonarr?.apiKey ?? process.env.SONARR_API ?? "",
},
seerr: {
url: f.seerr?.url ?? process.env.SEERR_URL ?? "",
apiKey: f.seerr?.apiKey ?? process.env.SEERR_API ?? "",
},
tautulli: {
url: f.tautulli?.url ?? process.env.TAUTULLI_URL ?? "",
apiKey: f.tautulli?.apiKey ?? process.env.TAUTULLI_API ?? "",
},
discord: {
webhookUrl: f.discord?.webhookUrl ?? process.env.DISCORD_WEBHOOK ?? "",
},
};
}
/** Saves the provided settings to disk and returns the merged result. */
export function saveSettings(settings: AppSettings): AppSettings {
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
// Strip trailing slashes from URLs for consistency
const clean: StoredSettings = {
radarr: { url: settings.radarr.url.replace(/\/+$/, ""), apiKey: settings.radarr.apiKey },
sonarr: { url: settings.sonarr.url.replace(/\/+$/, ""), apiKey: settings.sonarr.apiKey },
seerr: { url: settings.seerr.url.replace(/\/+$/, ""), apiKey: settings.seerr.apiKey },
tautulli: { url: settings.tautulli.url.replace(/\/+$/, ""), apiKey: settings.tautulli.apiKey },
discord: { webhookUrl: settings.discord.webhookUrl.trim() },
};
writeFileSync(SETTINGS_PATH, JSON.stringify(clean, null, 2), "utf-8");
return getSettings();
}