Files
jellyseerr/server/lib/settings/index.ts
Ben Beauchamp 4a5ac3cc42 feat(blacklist): Automatically add media with blacklisted tags to the blacklist (#1306)
* feat(blacklist): add blacktag settings to main settings page

* feat(blacklist): create blacktag logic and infrastructure

* feat(blacklist): add scheduling for blacktags job

* feat(blacklist): create blacktag ui badge for blacklist

* docs(blacklist): document blacktags in using-jellyseerr

* fix(blacklist): batch blacklist and media db removes to avoid expression tree too large error

* feat(blacklist): allow easy import and export of blacktag configuration

* fix(settings): don't copy the API key every time you press enter on the main settings

* fix(blacklist): move filter inline with page title to match all the other pages

* feat(blacklist): allow filtering between manually blacklisted and automatically blacklisted entries

* docs(blacklist): reword blacktag documentation a little

* refactor(blacklist): remove blacktag settings from public settings interfaces

There's no reason for it to be there

* refactor(blacklist): remove unused variable from processResults in blacktagsProcessor

* refactor(blacklist): change all instances of blacktag to blacklistedTag and update doc to match

* docs(blacklist): update general documentation for blacklisted tag settings

* fix(blacklist): update setting use of "blacklisted tag" to match between modals

* perf(blacklist): remove media type constraint from existing blacklist entry query

Doesn't make sense to keep it because tmdbid has a unique constraint on it

* fix(blacklist): remove whitespace line causing prettier to fail in CI

* refactor(blacklist): swap out some != and == for !s and _s

* fix(blacklist): merge back CopyButton changes, disable button when there's nothing to copy

* refactor(blacklist): use axios instead of fetch for blacklisted tag queries

* style(blacklist): use templated axios types and remove redundant try-catches
2025-04-11 22:48:44 +08:00

748 lines
20 KiB
TypeScript

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 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;
localLogin: boolean;
mediaServerLogin: boolean;
newPlexLogin: boolean;
discoverRegion: string;
streamingRegion: string;
originalLanguage: string;
blacklistedTags: string;
blacklistedTagsLimit: number;
mediaServerType: number;
partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
locale: string;
}
export interface NetworkSettings {
csrfProtection: boolean;
trustProxy: boolean;
proxy: ProxySettings;
}
interface PublicSettings {
initialized: boolean;
}
interface FullPublicSettings extends PublicSettings {
applicationTitle: string;
applicationUrl: string;
hideAvailable: 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;
}
export interface NotificationAgentConfig {
enabled: boolean;
types?: number;
options: Record<string, unknown>;
}
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 NotificationAgentLunaSea extends NotificationAgentConfig {
options: {
webhookUrl: string;
profileName?: 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;
};
}
export interface NotificationAgentGotify extends NotificationAgentConfig {
options: {
url: string;
token: string;
priority: number;
};
}
export enum NotificationAgentKey {
DISCORD = 'discord',
EMAIL = 'email',
GOTIFY = 'gotify',
PUSHBULLET = 'pushbullet',
PUSHOVER = 'pushover',
SLACK = 'slack',
TELEGRAM = 'telegram',
WEBHOOK = 'webhook',
WEBPUSH = 'webpush',
}
interface NotificationAgents {
discord: NotificationAgentDiscord;
email: NotificationAgentEmail;
gotify: NotificationAgentGotify;
lunasea: NotificationAgentLunaSea;
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<JobId, JobSettings>;
network: NetworkSettings;
}
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: 'Jellyseerr',
applicationUrl: '',
cacheImages: false,
defaultPermissions: Permission.REQUEST,
defaultQuotas: {
movie: {},
tv: {},
},
hideAvailable: false,
localLogin: true,
mediaServerLogin: true,
newPlexLogin: true,
discoverRegion: '',
streamingRegion: '',
originalLanguage: '',
blacklistedTags: '',
blacklistedTagsLimit: 50,
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
enableSpecialEpisodes: false,
locale: 'en',
},
plex: {
name: '',
ip: '',
port: 32400,
useSsl: false,
libraries: [],
},
jellyfin: {
name: '',
ip: '',
port: 8096,
useSsl: false,
urlBase: '',
externalHostname: '',
jellyfinForgotPasswordUrl: '',
libraries: [],
serverId: '',
apiKey: '',
},
tautulli: {},
radarr: [],
sonarr: [],
public: {
initialized: false,
},
notifications: {
agents: {
email: {
enabled: false,
options: {
userEmailRequired: false,
emailFrom: '',
smtpHost: '',
smtpPort: 587,
secure: false,
ignoreTls: false,
requireTls: false,
allowSelfSigned: false,
senderName: 'Jellyseerr',
},
},
discord: {
enabled: false,
types: 0,
options: {
webhookUrl: '',
webhookRoleId: '',
enableMentions: true,
},
},
lunasea: {
enabled: false,
types: 0,
options: {
webhookUrl: '',
},
},
slack: {
enabled: false,
types: 0,
options: {
webhookUrl: '',
},
},
telegram: {
enabled: false,
types: 0,
options: {
botAPI: '',
chatId: '',
messageThreadId: '',
sendSilently: false,
},
},
pushbullet: {
enabled: false,
types: 0,
options: {
accessToken: '',
},
},
pushover: {
enabled: false,
types: 0,
options: {
accessToken: '',
userToken: '',
sound: '',
},
},
webhook: {
enabled: false,
types: 0,
options: {
webhookUrl: '',
jsonPayload:
'IntcbiAgXCJub3RpZmljYXRpb25fdHlwZVwiOiBcInt7bm90aWZpY2F0aW9uX3R5cGV9fVwiLFxuICBcImV2ZW50XCI6IFwie3tldmVudH19XCIsXG4gIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gIFwibWVzc2FnZVwiOiBcInt7bWVzc2FnZX19XCIsXG4gIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgXCJ7e21lZGlhfX1cIjoge1xuICAgIFwibWVkaWFfdHlwZVwiOiBcInt7bWVkaWFfdHlwZX19XCIsXG4gICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgXCJzdGF0dXM0a1wiOiBcInt7bWVkaWFfc3RhdHVzNGt9fVwiXG4gIH0sXG4gIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgXCJyZXF1ZXN0ZWRCeV9lbWFpbFwiOiBcInt7cmVxdWVzdGVkQnlfZW1haWx9fVwiLFxuICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVxdWVzdGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tyZXF1ZXN0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2lzc3VlfX1cIjoge1xuICAgIFwiaXNzdWVfaWRcIjogXCJ7e2lzc3VlX2lkfX1cIixcbiAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgIFwiaXNzdWVfc3RhdHVzXCI6IFwie3tpc3N1ZV9zdGF0dXN9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9lbWFpbFwiOiBcInt7cmVwb3J0ZWRCeV9lbWFpbH19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcG9ydGVkQnlfYXZhdGFyXCI6IFwie3tyZXBvcnRlZEJ5X2F2YXRhcn19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc19kaXNjb3JkSWR9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2NvbW1lbnR9fVwiOiB7XG4gICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgXCJjb21tZW50ZWRCeV9lbWFpbFwiOiBcInt7Y29tbWVudGVkQnlfZW1haWx9fVwiLFxuICAgIFwiY29tbWVudGVkQnlfdXNlcm5hbWVcIjogXCJ7e2NvbW1lbnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7Y29tbWVudGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tjb21tZW50ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
},
},
webpush: {
enabled: false,
options: {},
},
gotify: {
enabled: false,
types: 0,
options: {
url: '',
token: '',
priority: 0,
},
},
},
},
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,
trustProxy: false,
proxy: {
enabled: false,
hostname: '',
port: 8080,
useSsl: false,
user: '',
password: '',
bypassFilter: '',
bypassLocalAddresses: true,
},
},
};
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 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,
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,
};
}
get notifications(): NotificationSettings {
return this.data.notifications;
}
set notifications(data: NotificationSettings) {
this.data.notifications = data;
}
get jobs(): Record<JobId, JobSettings> {
return this.data.jobs;
}
set jobs(data: Record<JobId, JobSettings>) {
this.data.jobs = data;
}
get network(): NetworkSettings {
return this.data.network;
}
set network(data: NetworkSettings) {
this.data.network = 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<MainSettings> {
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
* values
*/
public async load(overrideSettings?: AllSettings): Promise<Settings> {
if (overrideSettings) {
this.data = overrideSettings;
return this;
}
let data;
try {
data = await fs.readFile(SETTINGS_PATH, 'utf-8');
} catch {
await this.save();
}
if (data) {
const parsedJson = JSON.parse(data);
const migratedData = await runMigrations(parsedJson, SETTINGS_PATH);
this.data = merge(this.data, migratedData);
}
// 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<void> {
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;