import { MediaServerType } from '@server/constants/server'; import { Permission } from '@server/lib/permissions'; import { runMigrations } from '@server/lib/settings/migrator'; import { randomUUID } from 'crypto'; import fs from 'fs/promises'; import { merge } from 'lodash'; import path from 'path'; import webpush from 'web-push'; export interface Library { id: string; name: string; enabled: boolean; type: 'show' | 'movie'; lastScan?: number; } export interface Region { iso_3166_1: string; english_name: string; name?: string; } export interface Language { iso_639_1: string; english_name: string; name: string; } export interface PlexSettings { name: string; machineId?: string; ip: string; port: number; useSsl?: boolean; libraries: Library[]; webAppUrl?: string; } export interface JellyfinSettings { name: string; ip: string; port: number; useSsl?: boolean; urlBase?: string; externalHostname?: string; jellyfinForgotPasswordUrl?: string; libraries: Library[]; serverId: string; apiKey: string; } export interface TautulliSettings { hostname?: string; port?: number; useSsl?: boolean; urlBase?: string; apiKey?: string; externalUrl?: string; } export interface DVRSettings { id: number; name: string; hostname: string; port: number; apiKey: string; useSsl: boolean; baseUrl?: string; activeProfileId: number; activeProfileName: string; activeDirectory: string; tags: number[]; is4k: boolean; isDefault: boolean; externalUrl?: string; syncEnabled: boolean; preventSearch: boolean; tagRequests: boolean; overrideRule: number[]; } export interface RadarrSettings extends DVRSettings { minimumAvailability: string; } export interface SonarrSettings extends DVRSettings { seriesType: 'standard' | 'daily' | 'anime'; animeSeriesType: 'standard' | 'daily' | 'anime'; activeAnimeProfileId?: number; activeAnimeProfileName?: string; activeAnimeDirectory?: string; activeAnimeLanguageProfileId?: number; activeLanguageProfileId?: number; animeTags?: number[]; enableSeasonFolders: boolean; } interface Quota { quotaLimit?: number; quotaDays?: number; } export enum MetadataProviderType { TMDB = 'tmdb', TVDB = 'tvdb', } export interface MetadataSettings { tv: MetadataProviderType; anime: MetadataProviderType; } export interface ProxySettings { enabled: boolean; hostname: string; port: number; useSsl: boolean; user: string; password: string; bypassFilter: string; bypassLocalAddresses: boolean; } export interface MainSettings { apiKey: string; applicationTitle: string; applicationUrl: string; cacheImages: boolean; defaultPermissions: number; defaultQuotas: { movie: Quota; tv: Quota; }; hideAvailable: boolean; hideBlacklisted: boolean; localLogin: boolean; mediaServerLogin: boolean; newPlexLogin: boolean; discoverRegion: string; streamingRegion: string; originalLanguage: string; blacklistedTags: string; blacklistedTagsLimit: number; mediaServerType: number; partialRequestsEnabled: boolean; enableSpecialEpisodes: boolean; locale: string; youtubeUrl: string; } export interface ProxySettings { enabled: boolean; hostname: string; port: number; useSsl: boolean; user: string; password: string; bypassFilter: string; bypassLocalAddresses: boolean; } export interface DnsCacheSettings { enabled: boolean; forceMinTtl?: number; forceMaxTtl?: number; } export interface NetworkSettings { csrfProtection: boolean; forceIpv4First: boolean; trustProxy: boolean; proxy: ProxySettings; dnsCache: DnsCacheSettings; } interface PublicSettings { initialized: boolean; } interface FullPublicSettings extends PublicSettings { applicationTitle: string; applicationUrl: string; hideAvailable: boolean; hideBlacklisted: boolean; localLogin: boolean; mediaServerLogin: boolean; movie4kEnabled: boolean; series4kEnabled: boolean; discoverRegion: string; streamingRegion: string; originalLanguage: string; mediaServerType: number; jellyfinExternalHost?: string; jellyfinForgotPasswordUrl?: string; jellyfinServerName?: string; partialRequestsEnabled: boolean; enableSpecialEpisodes: boolean; cacheImages: boolean; vapidPublic: string; enablePushRegistration: boolean; locale: string; emailEnabled: boolean; userEmailRequired: boolean; newPlexLogin: boolean; youtubeUrl: string; } export interface NotificationAgentConfig { enabled: boolean; embedPoster: boolean; types?: number; options: Record; } export interface NotificationAgentDiscord extends NotificationAgentConfig { options: { botUsername?: string; botAvatarUrl?: string; webhookUrl: string; webhookRoleId?: string; enableMentions: boolean; }; } export interface NotificationAgentSlack extends NotificationAgentConfig { options: { webhookUrl: string; }; } export interface NotificationAgentEmail extends NotificationAgentConfig { options: { userEmailRequired: boolean; emailFrom: string; smtpHost: string; smtpPort: number; secure: boolean; ignoreTls: boolean; requireTls: boolean; authUser?: string; authPass?: string; allowSelfSigned: boolean; senderName: string; pgpPrivateKey?: string; pgpPassword?: string; }; } export interface NotificationAgentTelegram extends NotificationAgentConfig { options: { botUsername?: string; botAPI: string; chatId: string; messageThreadId: string; sendSilently: boolean; }; } export interface NotificationAgentPushbullet extends NotificationAgentConfig { options: { accessToken: string; channelTag?: string; }; } export interface NotificationAgentPushover extends NotificationAgentConfig { options: { accessToken: string; userToken: string; sound: string; }; } export interface NotificationAgentWebhook extends NotificationAgentConfig { options: { webhookUrl: string; jsonPayload: string; authHeader?: string; supportVariables?: boolean; }; } export interface NotificationAgentGotify extends NotificationAgentConfig { options: { url: string; token: string; priority: number; }; } export interface NotificationAgentNtfy extends NotificationAgentConfig { options: { url: string; topic: string; authMethodUsernamePassword?: boolean; username?: string; password?: string; authMethodToken?: boolean; token?: string; }; } export enum NotificationAgentKey { DISCORD = 'discord', EMAIL = 'email', GOTIFY = 'gotify', NTFY = 'ntfy', PUSHBULLET = 'pushbullet', PUSHOVER = 'pushover', SLACK = 'slack', TELEGRAM = 'telegram', WEBHOOK = 'webhook', WEBPUSH = 'webpush', } interface NotificationAgents { discord: NotificationAgentDiscord; email: NotificationAgentEmail; gotify: NotificationAgentGotify; ntfy: NotificationAgentNtfy; pushbullet: NotificationAgentPushbullet; pushover: NotificationAgentPushover; slack: NotificationAgentSlack; telegram: NotificationAgentTelegram; webhook: NotificationAgentWebhook; webpush: NotificationAgentConfig; } interface NotificationSettings { agents: NotificationAgents; } interface JobSettings { schedule: string; } export type JobId = | 'plex-recently-added-scan' | 'plex-full-scan' | 'plex-watchlist-sync' | 'plex-refresh-token' | 'radarr-scan' | 'sonarr-scan' | 'download-sync' | 'download-sync-reset' | 'jellyfin-recently-added-scan' | 'jellyfin-full-scan' | 'image-cache-cleanup' | 'availability-sync' | 'process-blacklisted-tags'; export interface AllSettings { clientId: string; vapidPublic: string; vapidPrivate: string; main: MainSettings; plex: PlexSettings; jellyfin: JellyfinSettings; tautulli: TautulliSettings; radarr: RadarrSettings[]; sonarr: SonarrSettings[]; public: PublicSettings; notifications: NotificationSettings; jobs: Record; network: NetworkSettings; metadataSettings: MetadataSettings; migrations: string[]; } const SETTINGS_PATH = process.env.CONFIG_DIRECTORY ? `${process.env.CONFIG_DIRECTORY}/settings.json` : path.join(__dirname, '../../../config/settings.json'); class Settings { private data: AllSettings; constructor(initialSettings?: AllSettings) { this.data = { clientId: randomUUID(), vapidPrivate: '', vapidPublic: '', main: { apiKey: '', applicationTitle: 'Seerr', applicationUrl: '', cacheImages: false, defaultPermissions: Permission.REQUEST, defaultQuotas: { movie: {}, tv: {}, }, hideAvailable: false, hideBlacklisted: false, localLogin: true, mediaServerLogin: true, newPlexLogin: true, discoverRegion: '', streamingRegion: '', originalLanguage: '', blacklistedTags: '', blacklistedTagsLimit: 50, mediaServerType: MediaServerType.NOT_CONFIGURED, partialRequestsEnabled: true, enableSpecialEpisodes: false, locale: 'en', youtubeUrl: '', }, plex: { name: '', ip: '', port: 32400, useSsl: false, libraries: [], }, jellyfin: { name: '', ip: '', port: 8096, useSsl: false, urlBase: '', externalHostname: '', jellyfinForgotPasswordUrl: '', libraries: [], serverId: '', apiKey: '', }, tautulli: {}, metadataSettings: { tv: MetadataProviderType.TMDB, anime: MetadataProviderType.TMDB, }, radarr: [], sonarr: [], public: { initialized: false, }, notifications: { agents: { email: { enabled: false, embedPoster: true, options: { userEmailRequired: false, emailFrom: '', smtpHost: '', smtpPort: 587, secure: false, ignoreTls: false, requireTls: false, allowSelfSigned: false, senderName: 'Seerr', }, }, discord: { enabled: false, embedPoster: true, types: 0, options: { webhookUrl: '', webhookRoleId: '', enableMentions: true, }, }, slack: { enabled: false, embedPoster: true, types: 0, options: { webhookUrl: '', }, }, telegram: { enabled: false, embedPoster: true, types: 0, options: { botAPI: '', chatId: '', messageThreadId: '', sendSilently: false, }, }, pushbullet: { enabled: false, embedPoster: false, types: 0, options: { accessToken: '', }, }, pushover: { enabled: false, embedPoster: true, types: 0, options: { accessToken: '', userToken: '', sound: '', }, }, webhook: { enabled: false, embedPoster: true, types: 0, options: { webhookUrl: '', jsonPayload: 'IntcbiAgXCJub3RpZmljYXRpb25fdHlwZVwiOiBcInt7bm90aWZpY2F0aW9uX3R5cGV9fVwiLFxuICBcImV2ZW50XCI6IFwie3tldmVudH19XCIsXG4gIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gIFwibWVzc2FnZVwiOiBcInt7bWVzc2FnZX19XCIsXG4gIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgXCJ7e21lZGlhfX1cIjoge1xuICAgIFwibWVkaWFfdHlwZVwiOiBcInt7bWVkaWFfdHlwZX19XCIsXG4gICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgXCJzdGF0dXM0a1wiOiBcInt7bWVkaWFfc3RhdHVzNGt9fVwiXG4gIH0sXG4gIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgXCJyZXF1ZXN0ZWRCeV9lbWFpbFwiOiBcInt7cmVxdWVzdGVkQnlfZW1haWx9fVwiLFxuICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVxdWVzdGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tyZXF1ZXN0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2lzc3VlfX1cIjoge1xuICAgIFwiaXNzdWVfaWRcIjogXCJ7e2lzc3VlX2lkfX1cIixcbiAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgIFwiaXNzdWVfc3RhdHVzXCI6IFwie3tpc3N1ZV9zdGF0dXN9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9lbWFpbFwiOiBcInt7cmVwb3J0ZWRCeV9lbWFpbH19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcG9ydGVkQnlfYXZhdGFyXCI6IFwie3tyZXBvcnRlZEJ5X2F2YXRhcn19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc19kaXNjb3JkSWR9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2NvbW1lbnR9fVwiOiB7XG4gICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgXCJjb21tZW50ZWRCeV9lbWFpbFwiOiBcInt7Y29tbWVudGVkQnlfZW1haWx9fVwiLFxuICAgIFwiY29tbWVudGVkQnlfdXNlcm5hbWVcIjogXCJ7e2NvbW1lbnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7Y29tbWVudGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tjb21tZW50ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2V4dHJhfX1cIjogW11cbn0i', }, }, webpush: { enabled: false, embedPoster: true, options: {}, }, gotify: { enabled: false, embedPoster: false, types: 0, options: { url: '', token: '', priority: 0, }, }, ntfy: { enabled: false, embedPoster: true, types: 0, options: { url: '', topic: '', }, }, }, }, jobs: { 'plex-recently-added-scan': { schedule: '0 */5 * * * *', }, 'plex-full-scan': { schedule: '0 0 3 * * *', }, 'plex-watchlist-sync': { schedule: '0 */3 * * * *', }, 'plex-refresh-token': { schedule: '0 0 5 * * *', }, 'radarr-scan': { schedule: '0 0 4 * * *', }, 'sonarr-scan': { schedule: '0 30 4 * * *', }, 'availability-sync': { schedule: '0 0 5 * * *', }, 'download-sync': { schedule: '0 * * * * *', }, 'download-sync-reset': { schedule: '0 0 1 * * *', }, 'jellyfin-recently-added-scan': { schedule: '0 */5 * * * *', }, 'jellyfin-full-scan': { schedule: '0 0 3 * * *', }, 'image-cache-cleanup': { schedule: '0 0 5 * * *', }, 'process-blacklisted-tags': { schedule: '0 30 1 */7 * *', }, }, network: { csrfProtection: false, forceIpv4First: false, trustProxy: false, proxy: { enabled: false, hostname: '', port: 8080, useSsl: false, user: '', password: '', bypassFilter: '', bypassLocalAddresses: true, }, dnsCache: { enabled: false, forceMinTtl: 0, forceMaxTtl: -1, }, }, migrations: [], }; if (initialSettings) { this.data = merge(this.data, initialSettings); } } get main(): MainSettings { return this.data.main; } set main(data: MainSettings) { this.data.main = data; } get plex(): PlexSettings { return this.data.plex; } set plex(data: PlexSettings) { this.data.plex = data; } get jellyfin(): JellyfinSettings { return this.data.jellyfin; } set jellyfin(data: JellyfinSettings) { this.data.jellyfin = data; } get tautulli(): TautulliSettings { return this.data.tautulli; } set tautulli(data: TautulliSettings) { this.data.tautulli = data; } get metadataSettings(): MetadataSettings { return this.data.metadataSettings; } set metadataSettings(data: MetadataSettings) { this.data.metadataSettings = data; } get radarr(): RadarrSettings[] { return this.data.radarr; } set radarr(data: RadarrSettings[]) { this.data.radarr = data; } get sonarr(): SonarrSettings[] { return this.data.sonarr; } set sonarr(data: SonarrSettings[]) { this.data.sonarr = data; } get public(): PublicSettings { return this.data.public; } set public(data: PublicSettings) { this.data.public = data; } get fullPublicSettings(): FullPublicSettings { return { ...this.data.public, applicationTitle: this.data.main.applicationTitle, applicationUrl: this.data.main.applicationUrl, hideAvailable: this.data.main.hideAvailable, hideBlacklisted: this.data.main.hideBlacklisted, localLogin: this.data.main.localLogin, mediaServerLogin: this.data.main.mediaServerLogin, jellyfinExternalHost: this.data.jellyfin.externalHostname, jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl, movie4kEnabled: this.data.radarr.some( (radarr) => radarr.is4k && radarr.isDefault ), series4kEnabled: this.data.sonarr.some( (sonarr) => sonarr.is4k && sonarr.isDefault ), discoverRegion: this.data.main.discoverRegion, streamingRegion: this.data.main.streamingRegion, originalLanguage: this.data.main.originalLanguage, mediaServerType: this.main.mediaServerType, partialRequestsEnabled: this.data.main.partialRequestsEnabled, enableSpecialEpisodes: this.data.main.enableSpecialEpisodes, cacheImages: this.data.main.cacheImages, vapidPublic: this.vapidPublic, enablePushRegistration: this.data.notifications.agents.webpush.enabled, locale: this.data.main.locale, emailEnabled: this.data.notifications.agents.email.enabled, userEmailRequired: this.data.notifications.agents.email.options.userEmailRequired, newPlexLogin: this.data.main.newPlexLogin, youtubeUrl: this.data.main.youtubeUrl, }; } get notifications(): NotificationSettings { return this.data.notifications; } set notifications(data: NotificationSettings) { this.data.notifications = data; } get jobs(): Record { return this.data.jobs; } set jobs(data: Record) { this.data.jobs = data; } get network(): NetworkSettings { return this.data.network; } set network(data: NetworkSettings) { this.data.network = data; } get migrations(): string[] { return this.data.migrations; } set migrations(data: string[]) { this.data.migrations = data; } get clientId(): string { return this.data.clientId; } get vapidPublic(): string { return this.data.vapidPublic; } get vapidPrivate(): string { return this.data.vapidPrivate; } public async regenerateApiKey(): Promise { this.main.apiKey = this.generateApiKey(); await this.save(); return this.main; } private generateApiKey(): string { if (process.env.API_KEY) { return process.env.API_KEY; } else { return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64'); } } /** * Settings Load * * This will load settings from file unless an optional argument of the object structure * is passed in. * @param overrideSettings If passed in, will override all existing settings with these * @param raw If true, will load the settings without running migrations or generating missing * values */ public async load( overrideSettings?: AllSettings, raw = false ): Promise { if (overrideSettings) { this.data = overrideSettings; return this; } let data; try { data = await fs.readFile(SETTINGS_PATH, 'utf-8'); } catch { await this.save(); } if (data && !raw) { const parsedJson = JSON.parse(data); const migratedData = await runMigrations(parsedJson, SETTINGS_PATH); this.data = merge(this.data, migratedData); } else if (data) { this.data = JSON.parse(data); } // generate keys and ids if it's missing let change = false; if (!this.data.main.apiKey) { this.data.main.apiKey = this.generateApiKey(); change = true; } else if (process.env.API_KEY) { if (this.main.apiKey != process.env.API_KEY) { this.main.apiKey = process.env.API_KEY; } } if (!this.data.clientId) { this.data.clientId = randomUUID(); change = true; } if (!this.data.vapidPublic || !this.data.vapidPrivate) { const vapidKeys = webpush.generateVAPIDKeys(); this.data.vapidPrivate = vapidKeys.privateKey; this.data.vapidPublic = vapidKeys.publicKey; change = true; } if (change) { await this.save(); } return this; } public async save(): Promise { const tmp = SETTINGS_PATH + '.tmp'; await fs.writeFile(tmp, JSON.stringify(this.data, undefined, ' ')); await fs.rename(tmp, SETTINGS_PATH); } } let settings: Settings | undefined; export const getSettings = (initialSettings?: AllSettings): Settings => { if (!settings) { settings = new Settings(initialSettings); } return settings; }; export default Settings;