export type SetupStep = "administrator" | "apps" | "preferences" | "review"; export type SetupState = { completed: boolean; step: SetupStep; completed_at: string | null }; export type SetupStatus = { setup_required: boolean; needs_admin: boolean }; export type Setting = { key: string; value: unknown; sensitive: boolean; isSet: boolean }; export type Values = Record; export type Field = { key: string; label: string; type?: "password" | "url" | "number" | "checkbox" | "email" | "time" | "textarea"; hint?: string; placeholder?: string; min?: number; max?: number; }; export type AppDefinition = { id: string; name: string; description: string; fields: Field[] }; const connection = (prefix: string, placeholder: string): Field[] => [ { key: `${prefix}_base_url`, label: "Server URL", type: "url", placeholder, hint: "Use an address reachable from the Magent server, not your browser.", }, { key: `${prefix}_api_key`, label: "API key", type: "password" }, ]; const collector = (prefix: string): Field[] => [ { key: `${prefix}_quality_profile_id`, label: "Quality profile ID", type: "number", min: 1, hint: "Save and test the connection to load available profiles.", }, { key: `${prefix}_root_folder`, label: "Root folder", hint: "The library path as seen by this app, for example /tv or /movies.", }, { key: `${prefix}_qbittorrent_category`, label: "Download category", hint: "Match the category configured in the app's download client.", }, ]; export const APPS: AppDefinition[] = [ { id: "jellyfin", name: "Jellyfin", description: "Playback, library availability and Jellyfin sign-in.", fields: [ ...connection("jellyfin", "http://jellyfin:8096"), { key: "jellyfin_public_url", label: "Public playback URL", type: "url", hint: "The address your users open to watch media.", }, { key: "jellyfin_sync_to_arr", label: "Sync Jellyfin library into Sonarr / Radarr", type: "checkbox", hint: "Optional automation. Only enable if you want Magent to reconcile these libraries.", }, ], }, { id: "seerr", name: "Seerr", description: "Requests, approvals and request history (including Jellyseerr).", fields: connection("jellyseerr", "http://seerr:5055"), }, { id: "sonarr", name: "Sonarr", description: "TV requests, seasons and collection progress.", fields: [...connection("sonarr", "http://sonarr:8989"), ...collector("sonarr")], }, { id: "radarr", name: "Radarr", description: "Movie requests and collection progress.", fields: [...connection("radarr", "http://radarr:7878"), ...collector("radarr")], }, { id: "prowlarr", name: "Prowlarr", description: "Indexer searches and release discovery.", fields: connection("prowlarr", "http://prowlarr:9696"), }, { id: "qbittorrent", name: "qBittorrent", description: "Download progress and recovery actions.", fields: [ { key: "qbittorrent_base_url", label: "Web UI URL", type: "url", placeholder: "http://qbittorrent:8080" }, { key: "qbittorrent_username", label: "Username" }, { key: "qbittorrent_password", label: "Password", type: "password" }, ], }, { id: "bazarr", name: "Bazarr", description: "Optional subtitle searches and repairs.", fields: [ ...connection("bazarr", "http://bazarr:6767"), { key: "bazarr_default_language", label: "Default subtitle language", placeholder: "en" }, ], }, { id: "jellystat", name: "Jellystat", description: "Optional personal viewing statistics.", fields: connection("jellystat", "http://jellystat:3000"), }, ]; export const PREFERENCES: { title: string; fields: Field[] }[] = [ { title: "Site & access", fields: [ { key: "magent_application_url", label: "Public Magent URL", type: "url", hint: "Used in invite and notification links. Set CORS_ALLOW_ORIGIN in your environment to the same origin; changing this field does not change CORS.", }, { key: "site_login_message", label: "Login page message", type: "textarea" }, { key: "site_login_show_local_login", label: "Show Magent account sign-in", type: "checkbox", hint: "Keep this enabled for local administrator access.", }, { key: "site_login_show_jellyfin_login", label: "Show Jellyfin sign-in", type: "checkbox" }, { key: "site_login_show_signup_link", label: "Show invite signup link", type: "checkbox", hint: "Account creation still requires a valid invite. This does not open public registration.", }, ], }, { title: "Request updates", fields: [ { key: "requests_poll_interval_seconds", label: "Request polling interval (seconds)", type: "number", min: 1 }, { key: "requests_delta_sync_interval_minutes", label: "Incremental sync interval (minutes)", type: "number", min: 1, }, { key: "requests_full_sync_time", label: "Daily full sync time (server timezone)", type: "time" }, { key: "requests_cleanup_days", label: "History retention (days)", type: "number", min: 1 }, ], }, { title: "Email (optional)", fields: [ { key: "magent_notify_enabled", label: "Enable notifications", type: "checkbox" }, { key: "magent_notify_email_enabled", label: "Enable email delivery", type: "checkbox", hint: "Used for invites, password resets and issue updates. Configure SMTP before enabling.", }, { key: "magent_notify_email_smtp_host", label: "SMTP hostname" }, { key: "magent_notify_email_smtp_port", label: "SMTP port", type: "number", min: 1, max: 65535 }, { key: "magent_notify_email_smtp_username", label: "SMTP username" }, { key: "magent_notify_email_smtp_password", label: "SMTP password", type: "password" }, { key: "magent_notify_email_from_address", label: "Sender email", type: "email" }, { key: "magent_notify_email_from_name", label: "Sender name" }, { key: "magent_notify_email_use_tls", label: "Use STARTTLS (usually port 587)", type: "checkbox" }, { key: "magent_notify_email_use_ssl", label: "Use implicit TLS (usually port 465)", type: "checkbox", hint: "Choose either STARTTLS or implicit TLS, not both.", }, ], }, ]; export const ALL_FIELDS = [...APPS.flatMap((app) => app.fields), ...PREFERENCES.flatMap((group) => group.fields)]; export function settingsValues(settings: Setting[]): Values { const values: Values = {}; for (const field of ALL_FIELDS) { const setting = settings.find((candidate) => candidate.key === field.key); if (!setting) continue; values[field.key] = field.type === "password" || setting.sensitive ? "" : field.type === "checkbox" ? setting.value === true || setting.value === "true" || setting.value === "1" : String(setting.value ?? ""); } return values; } // Only explicitly edited fields are sent. A blank password never clears a saved // secret (masked values from the settings endpoint are not actual credentials). export function settingsPayload( draft: Values, fields: Field[] = ALL_FIELDS, ): Record { const payload: Record = {}; for (const field of fields) { const value = draft[field.key]; if (value === undefined || (field.type === "password" && !String(value).trim())) continue; if (field.type === "url" || field.type === "email" || field.type === "time") { const text = String(value).trim(); if (text && field.type === "url") { let url: URL; try { url = new URL(text); } catch { throw new Error(`${field.label} must be a full HTTP or HTTPS URL.`); } if ( !/^https?:\/\//i.test(text) || !["http:", "https:"].includes(url.protocol) || !url.hostname || /\s/.test(text) ) { throw new Error(`${field.label} must be a full HTTP or HTTPS URL.`); } if (url.username || url.password) throw new Error(`${field.label} must not include a username or password. Use the credential fields instead.`); } if ( text && field.type === "email" && !/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test( text, ) ) { throw new Error(`${field.label} must be a valid email address.`); } if (text && field.type === "time" && !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(text)) { throw new Error(`${field.label} must be a valid time in HH:MM format.`); } payload[field.key] = text; } else if (field.type === "number" && value !== "") { const number = Number(value); if (!Number.isInteger(number) || number < (field.min ?? 0) || number > (field.max ?? Number.MAX_SAFE_INTEGER)) { throw new Error( `${field.label} must be a whole number between ${field.min ?? 0} and ${field.max ?? Number.MAX_SAFE_INTEGER}.`, ); } payload[field.key] = number; } else payload[field.key] = value; } return payload; } export function configuredApp(app: AppDefinition, settings: Setting[]): boolean { return app.fields .filter((field) => field.key.endsWith("_base_url") || field.type === "password") .every((field) => settings.some((setting) => setting.key === field.key && setting.isSet)); }