import { getRepository } from '@server/datasource'; import { OverlayLibraryConfig } from '@server/entity/OverlayLibraryConfig'; import { defaultHubConfigService } from '@server/lib/collections/services/DefaultHubConfigService'; import { preExistingCollectionConfigService } from '@server/lib/collections/services/PreExistingCollectionConfigService'; import logger from '@server/logger'; import { randomUUID } from 'crypto'; import fs from 'fs'; import { merge } from 'lodash'; import path from 'path'; export enum CollectionType { DEFAULT_PLEX_HUB = 'default_plex_hub', // Built-in Plex algorithmic hubs AGREGARR_CREATED = 'agregarr_created', // Agregarr-managed collections PRE_EXISTING = 'pre_existing', // Pre-existing Plex collections } /** * Season grab order modes for TV shows */ export type SeasonGrabOrder = 'first' | 'latest' | 'airing'; /** * Sort order options for collection items */ export type CollectionSortOrder = | 'default' // As provided by source | 'reverse' // Reverse source order | 'random' // Fisher-Yates shuffle | 'imdb_rating_desc' // Highest to lowest IMDb rating | 'imdb_rating_asc' // Lowest to highest IMDb rating | 'release_date_desc' // Newest to oldest release date | 'release_date_asc' // Oldest to newest release date | 'date_added_desc' // Most recently added to Plex | 'date_added_asc' // Least recently added to Plex | 'alphabetical_asc' // A-Z alphabetical order | 'alphabetical_desc'; // Z-A alphabetical order export interface Library { readonly key: string; readonly name: string; readonly type: 'show' | 'movie'; readonly lastScan?: number; } /** * Smart Collection Sort Options */ export interface SmartCollectionSortOption { readonly value: string; // The sort parameter value (e.g., 'year:desc', 'titleSort', 'rating:desc') readonly label: string; // Human-readable label for the dropdown } export interface CollectionConfig { readonly id: string; readonly name: string; readonly type: | 'overseerr' | 'tautulli' | 'trakt' | 'tmdb' | 'imdb' | 'letterboxd' | 'mdblist' | 'networks' | 'originals' | 'myanimelist' | 'anilist' | 'plex' | 'multi-source' | 'radarrtag' | 'sonarrtag' | 'comingsoon' | 'filtered_hub'; readonly subtype?: string; // Specific option like 'users', 'most_popular_plays', 'most_popular_duration', etc. Optional for types like recently_added readonly template: string; // Collection template readonly customMovieTemplate?: string; // Custom template for movie collections when mediaType is 'both' readonly customTVTemplate?: string; // Custom template for TV collections when mediaType is 'both' readonly visibilityConfig: { usersHome: boolean; serverOwnerHome: boolean; libraryRecommended: boolean; }; readonly isActive: boolean; // Whether collection is currently active (time restrictions met) readonly missing?: boolean; // True if collection no longer exists in Plex // Sync status tracking fields readonly lastSyncedAt?: string; // ISO string timestamp of last successful sync to Plex readonly lastModifiedAt?: string; // ISO string timestamp when config was last modified readonly needsSync?: boolean; // true if modified since last sync readonly lastSyncError?: string; // Error message from last failed sync (cleared on success) readonly lastSyncErrorAt?: string; // ISO string timestamp of when the sync error occurred readonly maxItems: number; readonly customDays?: number; // Number of days for Tautulli collections (required for Tautulli type) readonly minimumPlays?: number; // Minimum play count for Tautulli collections (defaults to 3 if not set, 1-100) readonly libraryId: string; // Library ID this collection belongs to readonly libraryName: string; // Library name for display readonly sortOrderHome?: number; // Order for Plex home screen (1+ for positioned items, 0 for void/unpositioned) readonly sortOrderLibrary?: number; // Order for Plex library tab (0 for A-Z section, 1+ for promoted section) readonly isLibraryPromoted?: boolean; // true = promoted section (uses exclamation marks), false = A-Z section (defaults to true for Agregarr collections) readonly randomizeHomeOrder?: boolean; // If true, randomize position amongst other randomized items on home screen readonly isLinked?: boolean; // True if collection is actively linked to other collections readonly linkId?: number; // Group ID for linked collections (preserved even when isLinked=false) readonly isUnlinked?: boolean; // True if this collection was deliberately unlinked and should not be grouped with siblings everLibraryPromoted?: boolean; // True if this collection has ever been promoted to the promoted section (once true, stays true until sortTitle reset) readonly isPromotedToHub?: boolean; // True if collection exists as a promotable hub in Plex (appears in hub management list) readonly collectionRatingKey?: string; // Plex collection rating key (when created) readonly showUnwatchedOnly?: boolean; // If true, create a smart collection that filters to unwatched items only readonly smartCollectionRatingKey?: string; // LEGACY: Old dual-collection system smart collection rating key (for migration only) readonly smartCollectionSort?: SmartCollectionSortOption; // Sort option for smart collections // Custom URL fields for external collections readonly tmdbCustomCollectionUrl?: string; // Trakt-specific fields readonly timePeriod?: string; readonly traktStatType?: 'trending' | 'popular' | 'watched'; readonly tautulliStatType?: 'plays' | 'duration'; // Tautulli stat type: plays or duration // Download mode - either Overseerr requests OR direct *arr download (not both) readonly downloadMode?: 'overseerr' | 'direct'; // Download mode: 'overseerr' = create requests (default), 'direct' = download directly to *arr // Common auto-download settings (apply to both modes) readonly searchMissingMovies?: boolean; // Auto-handle missing movies readonly searchMissingTV?: boolean; // Auto-handle missing TV shows readonly autoApproveMovies?: boolean; // Auto-approve/download movies readonly autoApproveTV?: boolean; // Auto-approve/download TV shows readonly maxSeasonsToRequest?: number; // Max seasons for auto-approval/download (TV shows with more seasons require manual approval or are skipped) readonly seasonsPerShowLimit?: number; // Limit each TV show to only the first X seasons (0 = all seasons) readonly seasonGrabOrder?: SeasonGrabOrder; // Order to grab seasons: first, latest, or airing (default: 'first') readonly maxPositionToProcess?: number; // Only process items in positions 1-X of the list (0 = no limit) readonly minimumYear?: number; // Only process movies/TV shows released on or after this year (0 = no limit) readonly minimumImdbRating?: number; // Only process movies/TV shows with IMDb rating >= this value (0 = no limit) readonly minimumRottenTomatoesRating?: number; // Only process movies/TV shows with Rotten Tomatoes critics score >= this value (0 = no limit) readonly minimumRottenTomatoesAudienceRating?: number; // Only process movies/TV shows with Rotten Tomatoes audience score >= this value (0 = no limit) readonly excludedGenres?: number[]; // @deprecated Use filterSettings.genres - Exclude items with these TMDB genre IDs from missing items search readonly excludedCountries?: string[]; // @deprecated Use filterSettings.countries - Exclude items with these ISO 3166-1 country codes from missing items search readonly excludedLanguages?: string[]; // @deprecated Use filterSettings.languages - Exclude items with these ISO 639-1 language codes from missing items search // New unified filter settings with include/exclude modes readonly filterSettings?: { readonly genres?: { readonly mode: 'exclude' | 'include'; // Default: 'exclude' readonly values: number[]; // TMDB genre IDs }; readonly countries?: { readonly mode: 'exclude' | 'include'; // Default: 'exclude' readonly values: string[]; // ISO 3166-1 country codes }; readonly languages?: { readonly mode: 'exclude' | 'include'; // Default: 'exclude' readonly values: string[]; // ISO 639-1 language codes }; }; // Direct download server selection (for downloadMode: 'direct') readonly directDownloadRadarrServerId?: number; // Selected Radarr server ID for movies readonly directDownloadRadarrProfileId?: number; // Selected Radarr profile ID for movies readonly directDownloadRadarrRootFolder?: string; // Selected Radarr root folder path for movies readonly directDownloadRadarrTags?: number[]; // Selected Radarr tags for movies readonly directDownloadRadarrMonitor?: boolean; // Override Radarr monitor setting for movies readonly directDownloadRadarrSearchOnAdd?: boolean; // Override Radarr search on add setting for movies readonly directDownloadSonarrServerId?: number; // Selected Sonarr server ID for TV shows readonly directDownloadSonarrProfileId?: number; // Selected Sonarr profile ID for TV shows readonly directDownloadSonarrRootFolder?: string; // Selected Sonarr root folder path for TV shows readonly directDownloadSonarrTags?: number[]; // Selected Sonarr tags for TV shows readonly directDownloadSonarrMonitor?: boolean; // Override Sonarr monitor setting for TV shows readonly directDownloadSonarrSearchOnAdd?: boolean; // Override Sonarr search on add setting for TV shows // Overseerr request configuration (for downloadMode: 'overseerr') readonly overseerrRadarrServerId?: number; // Override Radarr server ID for Overseerr movie requests readonly overseerrRadarrProfileId?: number; // Override Radarr profile ID for Overseerr movie requests readonly overseerrRadarrRootFolder?: string; // Override Radarr root folder path for Overseerr movie requests readonly overseerrRadarrTags?: number[]; // Override Radarr tags for Overseerr movie requests readonly overseerrSonarrServerId?: number; // Override Sonarr server ID for Overseerr TV requests readonly overseerrSonarrProfileId?: number; // Override Sonarr profile ID for Overseerr TV requests readonly overseerrSonarrRootFolder?: string; // Override Sonarr root folder path for Overseerr TV requests readonly overseerrSonarrTags?: number[]; // Override Sonarr tags for Overseerr TV requests // Trakt custom list fields readonly traktCustomListUrl?: string; // Custom Trakt list URL (e.g., https://trakt.tv/users/username/lists/list-name or https://trakt.tv/lists/official/collection-name) // IMDb custom list fields readonly imdbCustomListUrl?: string; // Custom IMDb list URL (e.g., https://www.imdb.com/list/ls123456789/) // Letterboxd custom list fields readonly letterboxdCustomListUrl?: string; // Custom Letterboxd list URL (e.g., https://letterboxd.com/username/list/list-name/) // MDBList custom list fields readonly mdblistCustomListUrl?: string; // Custom MDBList list URL (e.g., https://mdblist.com/lists/123456 or https://mdblist.com/lists/username/list-name) // Networks (FlixPatrol) fields readonly networksCountry?: string; // Country/region for Networks collections (e.g., 'world', 'us', 'uk') // AniList custom list fields readonly anilistCustomListUrl?: string; // Custom AniList list URL // Radarr/Sonarr tag fields readonly radarrTagId?: number; // Selected Radarr tag ID for tag-based collections readonly radarrInstanceId?: number; // Selected Radarr instance ID for tag-based collections readonly sonarrTagId?: number; // Selected Sonarr tag ID for tag-based collections readonly sonarrInstanceId?: number; // Selected Sonarr instance ID for tag-based collections // Generic ordering options (applicable to all collection types) readonly sortOrder?: CollectionSortOrder; // Sort order for collection items (default: 'default') // Unified person minimum items (applies to both actors and directors) readonly personMinimumItems?: number; // Plex Library separator settings for auto person collections readonly useSeparator?: boolean; // Create a separator collection for actors/directors multi-collections readonly separatorTitle?: string; // Custom title for the separator collection // Collection exclusion settings readonly excludeFromCollections?: string[]; // Array of collection IDs to exclude items from (mutual exclusion) // Poster settings readonly customPoster?: string | Record; // Path to custom poster image file, or per-library poster mapping readonly autoPoster?: boolean; // Auto-generate poster during sync (only available for Overseerr user collections) readonly autoPosterTemplate?: number | null; // Template ID for auto-generated posters (null for default template) readonly useTmdbFranchisePoster?: boolean; // Use TMDB franchise poster instead of auto-generated poster (only for TMDB auto_franchise collections) readonly hideIndividualItems?: boolean; // Hide individual items, show collection (collectionMode = 1, only for TMDB auto_franchise collections) // Wallpaper, summary, and theme settings readonly customWallpaper?: string | Record; // Path to custom wallpaper (art) image file, or per-library wallpaper mapping readonly customSummary?: string; // Custom summary/description text for the collection readonly customTheme?: string | Record; // Path to custom theme music file, or per-library theme mapping readonly enableCustomWallpaper?: boolean; // Enable custom wallpaper sync to Plex readonly enableCustomSummary?: boolean; // Enable custom summary sync to Plex readonly enableCustomTheme?: boolean; // Enable custom theme sync to Plex // Placeholder settings (for createPlaceholdersForMissing feature) readonly createPlaceholdersForMissing?: boolean; // If true, create placeholder files in Plex for missing items instead of auto-requesting readonly placeholderReleasedDays?: number; // Days to keep released items with overlay (default: 7). After this window, original posters are restored. readonly placeholderDaysAhead?: number; // Number of days to look ahead for release dates (default: 360) // Legacy Coming Soon fields (for backward compatibility during migration) readonly comingSoonReleasedDays?: number; // @deprecated Use placeholderReleasedDays readonly comingSoonDays?: number; // @deprecated Use placeholderDaysAhead // Overlay sync option readonly applyOverlaysDuringSync?: boolean; // If true, apply overlays to collection items immediately after sync (default: true for Coming Soon, false for others) // Time restriction settings readonly timeRestriction?: { readonly alwaysActive: boolean; // If true, collection is always active (default) readonly removeFromPlexWhenInactive?: boolean; // If true, completely remove from Plex when inactive (old behavior) readonly inactiveVisibilityConfig?: { usersHome: boolean; serverOwnerHome: boolean; libraryRecommended: boolean; }; // Visibility settings to use when collection is inactive (only used if removeFromPlexWhenInactive is false) readonly dateRanges?: readonly { readonly startDate: string; // DD-MM format (e.g., "05-12" for 5th December) readonly endDate: string; // DD-MM format (e.g., "26-12" for 26th December) }[]; readonly weeklySchedule?: { readonly monday: boolean; readonly tuesday: boolean; readonly wednesday: boolean; readonly thursday: boolean; readonly friday: boolean; readonly saturday: boolean; readonly sunday: boolean; }; }; // Multi-source specific properties (only present when type === 'multi-source') readonly isMultiSource?: boolean; // Enable multi-source mode readonly sources?: readonly { readonly id: string; readonly type: string; readonly subtype?: string; readonly customUrl?: string; readonly timePeriod?: 'daily' | 'weekly' | 'monthly' | 'all'; readonly priority: number; readonly isExpanded?: boolean; // UI state for expandable sections readonly customDays?: number; readonly minimumPlays?: number; readonly networksCountry?: string; // Selected country for Networks collections readonly radarrTagServerId?: number; // Radarr instance ID for radarrtag sources readonly radarrTagId?: number; // Radarr tag ID for radarrtag sources readonly radarrTagLabel?: string; // Radarr tag label for display readonly sonarrTagServerId?: number; // Sonarr instance ID for sonarrtag sources readonly sonarrTagId?: number; // Sonarr tag ID for sonarrtag sources readonly sonarrTagLabel?: string; // Sonarr tag label for display }[]; readonly combineMode?: | 'interleaved' | 'list_order' | 'randomised' | 'cycle_lists'; // Individual sync scheduling readonly customSyncSchedule?: CustomSyncSchedule; } /** * Configuration for Plex built-in hubs (Recently Added, Continue Watching, etc.) */ export interface PlexHubConfig { id: string; // Generated unique identifier hubIdentifier: string; // Plex hub identifier (e.g., "movie.recentlyadded") name: string; // Display name (e.g., "Recently Added Movies") libraryId: string; // Library ID this hub belongs to libraryName: string; // Library display name mediaType: 'movie' | 'tv'; // Media type (hubs are always single type) sortOrderHome: number; // Position on Plex home screen (1+ for positioned items, 0 for void) sortOrderLibrary: number; // Position in library (0 for A-Z section, 1+ for promoted section) isLibraryPromoted: boolean; // true = promoted section (uses exclamation marks), false = A-Z section randomizeHomeOrder?: boolean; // If true, randomize position amongst other randomized items on home screen visibilityConfig: { usersHome: boolean; serverOwnerHome: boolean; libraryRecommended: boolean; }; isActive: boolean; // Whether hub is currently active (computed from time restrictions) missing?: boolean; // True if hub no longer exists in Plex // Sync status tracking fields lastSyncedAt?: string; // ISO string timestamp of last successful sync to Plex lastModifiedAt?: string; // ISO string timestamp when config was last modified needsSync?: boolean; // true if modified since last sync // Simplified categorization system collectionType: CollectionType; isLinked?: boolean; // True if hub is actively linked to other hubs (set by backend linking logic) linkId?: number; // Group ID for linked hubs (set by backend linking logic) isUnlinked?: boolean; // True if this hub was deliberately unlinked and should not be grouped with siblings everLibraryPromoted?: boolean; // True if this hub has ever been promoted to the promoted section (once true, stays true until sortTitle reset) isPromotedToHub?: boolean; // True if hub exists as a promotable item in Plex (appears in hub management list) // Time restriction settings - all hub types can have time restrictions timeRestriction?: { readonly alwaysActive: boolean; // If true, hub is always active (default) readonly removeFromPlexWhenInactive?: boolean; // If true, completely remove from Plex when inactive (not available for default Plex hubs) readonly inactiveVisibilityConfig?: { usersHome: boolean; serverOwnerHome: boolean; libraryRecommended: boolean; }; // Visibility settings to use when hub is inactive (only used if removeFromPlexWhenInactive is false) readonly dateRanges?: readonly { readonly startDate: string; // DD-MM format (e.g., "05-12" for 5th December) readonly endDate: string; // DD-MM format (e.g., "26-12" for 26th December) }[]; readonly weeklySchedule?: { readonly monday: boolean; readonly tuesday: boolean; readonly wednesday: boolean; readonly thursday: boolean; readonly friday: boolean; readonly saturday: boolean; readonly sunday: boolean; }; }; } /** * Configuration for pre-existing Plex collections (not created by Agregarr) */ export interface PreExistingCollectionConfig { id: string; // Generated unique identifier collectionRatingKey: string; // Plex collection rating key (e.g., "35954") name: string; // Display name from Plex libraryId: string; // Library ID this collection belongs to libraryName: string; // Library display name mediaType: 'movie' | 'tv'; // Media type based on library type titleSort?: string; // Plex sortTitle field for alphabetical ordering sortOrderHome: number; // Position on Plex home screen (1+ for positioned items, 0 for void) sortOrderLibrary: number; // Position in library (0 for A-Z section, 1+ for promoted section) isLibraryPromoted: boolean; // true = promoted section (uses exclamation marks), false = A-Z section randomizeHomeOrder?: boolean; // If true, randomize position amongst other randomized items on home screen visibilityConfig: { usersHome: boolean; serverOwnerHome: boolean; libraryRecommended: boolean; }; isActive: boolean; // Whether collection is currently active (computed from time restrictions) missing?: boolean; // True if collection no longer exists in Plex // Sync status tracking fields lastSyncedAt?: string; // ISO string timestamp of last successful sync to Plex lastModifiedAt?: string; // ISO string timestamp when config was last modified needsSync?: boolean; // true if modified since last sync // Simplified categorization system (consistent with PlexHubConfig) collectionType: CollectionType; isLinked?: boolean; // True if collection is actively linked to other collections (set by backend linking logic) linkId?: number; // Group ID for linked collections (set by backend linking logic) isUnlinked?: boolean; // True if this collection was deliberately unlinked and should not be grouped with siblings everLibraryPromoted?: boolean; // True if this collection has ever been promoted to the promoted section (once true, stays true until sortTitle reset) isPromotedToHub?: boolean; // True if collection exists as a promotable hub in Plex (appears in hub management list) // Time restriction settings readonly timeRestriction?: { readonly alwaysActive: boolean; // If true, collection is always active (default) readonly removeFromPlexWhenInactive?: boolean; // If true, completely remove from Plex when inactive readonly inactiveVisibilityConfig?: { usersHome: boolean; serverOwnerHome: boolean; libraryRecommended: boolean; }; // Visibility settings to use when collection is inactive readonly dateRanges?: readonly { readonly startDate: string; // DD-MM format (e.g., "05-12" for 5th December) readonly endDate: string; // DD-MM format (e.g., "26-12" for 26th December) }[]; readonly weeklySchedule?: { readonly monday: boolean; readonly tuesday: boolean; readonly wednesday: boolean; readonly thursday: boolean; readonly friday: boolean; readonly saturday: boolean; readonly sunday: boolean; }; }; // Custom poster support customPoster?: string | Record; // Path to custom poster image file, or per-library poster mapping autoPoster?: boolean; // Auto-generate poster during sync (same as CollectionConfig) autoPosterTemplate?: number | null; // Template ID for auto-generated posters (null for default template) // Wallpaper, summary, and theme support customWallpaper?: string | Record; // Path to custom wallpaper (art) image file, or per-library wallpaper mapping customSummary?: string; // Custom summary/description text for the collection customTheme?: string | Record; // Path to custom theme music file, or per-library theme mapping enableCustomWallpaper?: boolean; // Enable custom wallpaper sync to Plex enableCustomSummary?: boolean; // Enable custom summary sync to Plex enableCustomTheme?: boolean; // Enable custom theme sync to Plex } export interface PlexSettings { name: string; machineId?: string; ip: string; port: number; useSsl?: boolean; libraries: Library[]; webAppUrl?: string; collectionConfigs?: CollectionConfig[]; // Agregarr-created collections hubConfigs?: PlexHubConfig[]; // Plex built-in hub configurations preExistingCollectionConfigs?: PreExistingCollectionConfig[]; // Pre-existing Plex collections discovered by hub discovery usersHomeUnlocked?: boolean; // Secret unlock for Users Home collections autoEmptyTrash?: boolean; // Auto-empty Plex trash after placeholder cleanup (default: true) } export interface TraktSettings { // Legacy API key support (Client ID was previously stored here) apiKey?: string; clientId?: string; clientSecret?: string; accessToken?: string; refreshToken?: string; tokenExpiresAt?: number; } export interface MDBListSettings { apiKey?: string; } export interface MyAnimeListSettings { apiKey?: string; } export interface TautulliSettings { hostname?: string; port?: number; useSsl?: boolean; urlBase?: string; apiKey?: string; externalUrl?: string; } export interface MaintainerrSettings { hostname?: string; port?: number; useSsl?: boolean; urlBase?: string; apiKey?: string; externalUrl?: string; } export interface OverseerrSettings { hostname?: string; port?: number; useSsl?: boolean; urlBase?: string; apiKey?: string; externalUrl?: string; // Movie defaults (Radarr) radarrServerId?: number; radarrProfileId?: number; radarrRootFolder?: string; radarrTags?: number[]; // TV defaults (Sonarr) sonarrServerId?: number; sonarrProfileId?: number; sonarrRootFolder?: string; sonarrTags?: number[]; } export interface ServiceUserSettings { userCreationMode: 'single' | 'per-service' | 'granular'; // How to create service users } export type TagRequestsMode = 'off' | 'single' | 'per-service' | 'granular'; 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; monitorByDefault?: boolean; // Whether to monitor items when added (defaults to true) searchOnAdd?: boolean; // Whether to immediately search for items when added (defaults to true) tagRequests?: boolean; tagRequestsMode?: TagRequestsMode; } 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; } export interface WatchlistSyncSettings { enableOwner: boolean; // Enable sync for admin/owner enableUsers: boolean; // Enable sync for all Plex users radarr?: { enabled: boolean; // Enable movie sync serverId?: number; // Selected Radarr server ID profileId?: number; // Quality profile override rootFolder?: string; // Root folder override tags?: number[]; // Tags override monitor?: boolean; // Monitor by default override searchOnAdd?: boolean; // Search on add override }; sonarr?: { enabled: boolean; // Enable TV show sync serverId?: number; // Selected Sonarr server ID profileId?: number; // Quality profile override rootFolder?: string; // Root folder override tags?: number[]; // Tags override monitor?: boolean; // Monitor by default override searchOnAdd?: boolean; // Search on add override seasonFolder?: boolean; // Season folder override }; lastSyncAt?: Date; // Last successful sync timestamp lastSyncError?: string; // Last sync error message } // Quota interface removed - request system not needed in Agregarr export interface MainSettings { apiKey: string; applicationTitle: string; applicationUrl: string; csrfProtection: boolean; localLogin: boolean; newPlexLogin: boolean; trustProxy: boolean; locale: string; tmdbLanguage?: string; // Language for TMDB API calls (poster metadata, etc.) - defaults to 'en' enableTmdbPosterCache?: boolean; // Enable 7-day file cache for TMDB posters to reduce API calls - defaults to true nextConfigId?: number; // Next sequential ID for collection configs (starts at 10000) // Global sync status tracking lastGlobalSyncAt?: string; // ISO string timestamp of last full collections sync globalSyncError?: string; // Last sync error message if any (master error) syncCounter?: number; // Counter for alternating Plex hub ordering methods (prevents precision convergence) // Quick Sync timestamps lastCollectionsQuickSyncAt?: string; // ISO string timestamp of last collections quick sync lastOverlaysQuickSyncAt?: string; // ISO string timestamp of last overlays quick sync // External service data for template variables adminUsername?: string; // Admin's Plex username adminNickname?: string; // Admin's Plex title/display name externalApplicationUrl?: string; // External Overseerr URL externalApplicationTitle?: string; // External Overseerr title // Overseerr user label state tracking overseerrLabelsApplied?: boolean; // True if Overseerr user filter labels are currently applied to Plex users // Placeholder root folders (per-library) placeholderMovieRootFolders?: Record; // libraryKey -> movie placeholder path mapping placeholderTVRootFolders?: Record; // libraryKey -> TV placeholder path mapping } interface PublicSettings { initialized: boolean; } interface FullPublicSettings extends PublicSettings { applicationTitle: string; applicationUrl: string; localLogin: boolean; movie4kEnabled: boolean; series4kEnabled: boolean; locale: string; newPlexLogin: boolean; } // Notification system removed - not needed in Agregarr collections management // Notification agents and settings removed - not needed in Agregarr interface JobSettings { schedule: string; } export interface OverlaySettings { defaultPosterSource: 'tmdb' | 'plex' | 'local'; initialSetupComplete: boolean; } export type JobId = | 'plex-refresh-token' | 'plex-collections-sync' | 'plex-collections-quick-sync' | 'plex-randomize-home-order' | 'overlay-application' | 'overlay-quick-sync' | 'watchlist-sync'; export interface GlobalExclusions { movies: number[]; // TMDB IDs for excluded movies shows: { id: number; type: 'tmdb' | 'tvdb' }[]; // TMDB or TVDB IDs for excluded TV shows } interface AllSettings { clientId: string; main: MainSettings; plex: PlexSettings; tautulli: TautulliSettings; maintainerr: MaintainerrSettings; overseerr: OverseerrSettings; myanimelist: MyAnimeListSettings; serviceUser: ServiceUserSettings; trakt: TraktSettings; mdblist: MDBListSettings; radarr: RadarrSettings[]; sonarr: SonarrSettings[]; public: PublicSettings; jobs: Record; watchlistSync: WatchlistSyncSettings; globalExclusions?: GlobalExclusions; // Global item exclusions for collections completedMigrations?: string[]; // Track completed migrations overlays?: OverlaySettings; // Overlay system settings } 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(), main: { apiKey: '', applicationTitle: 'Agregarr', applicationUrl: '', csrfProtection: false, localLogin: false, newPlexLogin: true, trustProxy: false, locale: 'en', tmdbLanguage: 'en', enableTmdbPosterCache: true, }, plex: { name: '', ip: '', port: 32400, useSsl: false, libraries: [], collectionConfigs: [], hubConfigs: [], preExistingCollectionConfigs: [], usersHomeUnlocked: false, }, tautulli: {}, maintainerr: {}, overseerr: {}, myanimelist: {}, serviceUser: { userCreationMode: 'per-service', // Default to per-service users }, trakt: {}, mdblist: {}, radarr: [], sonarr: [], public: { initialized: false, }, jobs: { 'plex-refresh-token': { schedule: '0 0 5 * * *', }, 'plex-collections-sync': { schedule: '0 0 */12 * * *', }, 'plex-collections-quick-sync': { schedule: '0 */30 * * * *', // Every 30 minutes (user customizable) }, 'plex-randomize-home-order': { schedule: '0 0 6 * * *', }, 'overlay-application': { schedule: '0 0 3 * * *', // Every 24 hours at 3am }, 'overlay-quick-sync': { schedule: '0 */30 * * * *', // Every 30 minutes (user customizable) }, 'watchlist-sync': { schedule: '0 0 */6 * * *', // Every 6 hours }, }, watchlistSync: { enableOwner: false, enableUsers: false, radarr: { enabled: false, }, sonarr: { enabled: false, }, }, globalExclusions: { movies: [], shows: [], }, }; if (initialSettings) { this.data = merge(this.data, initialSettings); } this.normalizeTagSettings(); } private normalizeTagSettings(): void { let modified = false; this.data.radarr = this.data.radarr.map((radarrInstance) => { const currentMode = radarrInstance.tagRequestsMode ?? (radarrInstance.tagRequests ? 'per-service' : 'off'); const normalizedMode: TagRequestsMode = ( ['off', 'single', 'per-service', 'granular'] as TagRequestsMode[] ).includes(currentMode as TagRequestsMode) ? (currentMode as TagRequestsMode) : 'off'; if ( radarrInstance.tagRequestsMode !== normalizedMode || (radarrInstance.tagRequests ?? false) !== (normalizedMode !== 'off') ) { modified = true; } return { ...radarrInstance, tagRequestsMode: normalizedMode, tagRequests: normalizedMode !== 'off', }; }); this.data.sonarr = this.data.sonarr.map((sonarrInstance) => { const currentMode = sonarrInstance.tagRequestsMode ?? (sonarrInstance.tagRequests ? 'per-service' : 'off'); const normalizedMode: TagRequestsMode = ( ['off', 'single', 'per-service', 'granular'] as TagRequestsMode[] ).includes(currentMode as TagRequestsMode) ? (currentMode as TagRequestsMode) : 'off'; if ( sonarrInstance.tagRequestsMode !== normalizedMode || (sonarrInstance.tagRequests ?? false) !== (normalizedMode !== 'off') ) { modified = true; } return { ...sonarrInstance, tagRequestsMode: normalizedMode, tagRequests: normalizedMode !== 'off', }; }); if (modified) { this.save(); } } /** * Migrate legacy reverseOrder/randomizeOrder boolean flags to sortOrder enum * This is a one-time migration for users upgrading from older versions */ public migrateSortOrderToEnum(): void { const migrationId = 'sort-order-to-enum'; // Initialize completedMigrations if it doesn't exist if (!this.data.completedMigrations) { this.data.completedMigrations = []; } // Skip if already completed if (this.data.completedMigrations.includes(migrationId)) { return; } if (!this.data.plex.collectionConfigs) { this.data.completedMigrations.push(migrationId); this.save(); return; } let migratedCount = 0; this.data.plex.collectionConfigs = this.data.plex.collectionConfigs.map( (config) => { // Skip if already using new format if (config.sortOrder) { return config; } // Check if config has legacy fields (using type assertion for detection) const legacyConfig = config as unknown as { reverseOrder?: boolean; randomizeOrder?: boolean; }; const hasLegacy = legacyConfig.reverseOrder !== undefined || legacyConfig.randomizeOrder !== undefined; if (!hasLegacy) { return config; // No legacy fields to migrate } // Determine new sortOrder value from legacy fields let sortOrder: CollectionSortOrder = 'default'; if (legacyConfig.randomizeOrder === true) { sortOrder = 'random'; } else if (legacyConfig.reverseOrder === true) { sortOrder = 'reverse'; } migratedCount++; logger.info(`Migrating collection "${config.name}" to sortOrder enum`, { label: 'Settings Migration', configId: config.id, }); // Return collection with new format, removing old fields return { ...config, sortOrder, reverseOrder: undefined, randomizeOrder: undefined, }; } ); if (migratedCount > 0) { logger.info( `Migrated ${migratedCount} collection(s) to sortOrder enum format`, { label: 'Settings Migration', } ); } this.data.completedMigrations.push(migrationId); this.save(); } get main(): MainSettings { if (!this.data.main.apiKey) { this.data.main.apiKey = this.generateApiKey(); this.save(); } 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 tautulli(): TautulliSettings { return this.data.tautulli; } set tautulli(data: TautulliSettings) { this.data.tautulli = data; } get maintainerr(): MaintainerrSettings { return this.data.maintainerr; } set maintainerr(data: MaintainerrSettings) { this.data.maintainerr = data; } get trakt(): TraktSettings { return this.data.trakt; } set trakt(data: TraktSettings) { this.data.trakt = data; } get mdblist(): MDBListSettings { return this.data.mdblist; } set mdblist(data: MDBListSettings) { this.data.mdblist = data; } get overseerr(): OverseerrSettings { return this.data.overseerr; } set overseerr(data: OverseerrSettings) { this.data.overseerr = data; } get myanimelist(): MyAnimeListSettings { return this.data.myanimelist; } set myanimelist(data: MyAnimeListSettings) { this.data.myanimelist = data; } get serviceUser(): ServiceUserSettings { return this.data.serviceUser; } set serviceUser(data: ServiceUserSettings) { this.data.serviceUser = 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 watchlistSync(): WatchlistSyncSettings { return this.data.watchlistSync; } set watchlistSync(data: WatchlistSyncSettings) { this.data.watchlistSync = 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, localLogin: this.data.main.localLogin, movie4kEnabled: this.data.radarr.some( (radarr) => radarr.is4k && radarr.isDefault ), series4kEnabled: this.data.sonarr.some( (sonarr) => sonarr.is4k && sonarr.isDefault ), locale: this.data.main.locale, newPlexLogin: this.data.main.newPlexLogin, }; } // Notification methods removed - not needed in Agregarr get jobs(): Record { return this.data.jobs; } set jobs(data: Record) { this.data.jobs = data; } get globalExclusions(): GlobalExclusions { if (!this.data.globalExclusions) { this.data.globalExclusions = { movies: [], shows: [], }; } return this.data.globalExclusions; } set globalExclusions(data: GlobalExclusions) { this.data.globalExclusions = data; } get clientId(): string { if (!this.data.clientId) { this.data.clientId = randomUUID(); this.save(); } return this.data.clientId; } get overlays(): OverlaySettings | undefined { return this.data.overlays; } set overlays(data: OverlaySettings | undefined) { this.data.overlays = data; } // VAPID keys methods removed - push notifications not needed in Agregarr public regenerateApiKey(): MainSettings { this.main.apiKey = this.generateApiKey(); this.save(); return this.main; } private generateApiKey(): string { return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64'); } // generateVapidKeys method removed - push notifications not needed in Agregarr /** * 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 load(overrideSettings?: AllSettings): Settings { if (overrideSettings) { this.data = overrideSettings; return this; } if (!fs.existsSync(SETTINGS_PATH)) { this.save(); } const data = fs.readFileSync(SETTINGS_PATH, 'utf-8'); if (data) { this.data = merge(this.data, JSON.parse(data)); this.save(); } return this; } public save(): void { fs.writeFileSync(SETTINGS_PATH, JSON.stringify(this.data, undefined, ' ')); } /** * Update admin Plex user information for template variables */ public updateAdminPlexInfo(username?: string, nickname?: string): void { if (username) { this.data.main.adminUsername = username; } if (nickname) { this.data.main.adminNickname = nickname; } this.save(); } /** * Update external Overseerr information for template variables */ public updateExternalOverseerrInfo( applicationUrl?: string, applicationTitle?: string ): void { if (applicationUrl) { this.data.main.externalApplicationUrl = applicationUrl; } if (applicationTitle) { this.data.main.externalApplicationTitle = applicationTitle; } this.save(); } /** * Set Overseerr user filter label state * Used to track whether labels are currently applied to Plex users */ public setOverseerrLabelsApplied(applied: boolean): void { this.data.main.overseerrLabelsApplied = applied; this.save(); } /** * Collection Sync Status Tracking Methods */ /** * Mark a collection as modified (needs sync) */ public markCollectionModified( collectionId: string, collectionType: 'collection' | 'hub' | 'preExisting' ): void { const now = new Date().toISOString(); // Find and update the appropriate collection switch (collectionType) { case 'collection': if (this.data.plex.collectionConfigs) { const config = this.data.plex.collectionConfigs.find( (c) => c.id === collectionId ); if (config) { Object.assign(config, { needsSync: true, lastModifiedAt: now }); } } break; case 'hub': if (this.data.plex.hubConfigs) { const config = this.data.plex.hubConfigs.find( (c) => c.id === collectionId ); if (config) { config.needsSync = true; config.lastModifiedAt = now; } } break; case 'preExisting': if (this.data.plex.preExistingCollectionConfigs) { const config = this.data.plex.preExistingCollectionConfigs.find( (c) => c.id === collectionId ); if (config) { config.needsSync = true; config.lastModifiedAt = now; } } break; } this.save(); } /** * Mark a collection as successfully synced (clears any previous error) */ public markCollectionSynced( collectionId: string, collectionType: 'collection' | 'hub' | 'preExisting' ): void { const now = new Date().toISOString(); // Find and update the appropriate collection switch (collectionType) { case 'collection': if (this.data.plex.collectionConfigs) { const config = this.data.plex.collectionConfigs.find( (c) => c.id === collectionId ); if (config) { Object.assign(config, { needsSync: false, lastSyncedAt: now, lastSyncError: undefined, lastSyncErrorAt: undefined, }); } } break; case 'hub': if (this.data.plex.hubConfigs) { const config = this.data.plex.hubConfigs.find( (c) => c.id === collectionId ); if (config) { config.needsSync = false; config.lastSyncedAt = now; } } break; case 'preExisting': if (this.data.plex.preExistingCollectionConfigs) { const config = this.data.plex.preExistingCollectionConfigs.find( (c) => c.id === collectionId ); if (config) { config.needsSync = false; config.lastSyncedAt = now; } } break; } this.save(); } /** * Set a sync error for a specific collection */ public setCollectionSyncError(collectionId: string, error: string): void { const now = new Date().toISOString(); if (this.data.plex.collectionConfigs) { const config = this.data.plex.collectionConfigs.find( (c) => c.id === collectionId ); if (config) { Object.assign(config, { lastSyncError: error, lastSyncErrorAt: now, needsSync: true, // Keep marked as needing sync since it failed }); this.save(); } } } /** * Set global sync error message */ public setGlobalSyncError(error: string): void { this.data.main.globalSyncError = error; this.save(); } /** * Mark global sync as completed successfully */ public setGlobalSyncComplete(): void { this.data.main.lastGlobalSyncAt = new Date().toISOString(); this.data.main.globalSyncError = undefined; // Clear any previous errors this.save(); } /** * Get global sync status for UI display */ public getGlobalSyncStatus(): { lastGlobalSyncAt?: string; globalSyncError?: string; collectionsNeedingSync: number; } { let collectionsNeedingSync = 0; // Count collections that need sync if (this.data.plex.collectionConfigs) { collectionsNeedingSync += this.data.plex.collectionConfigs.filter( (c) => 'needsSync' in c && (c as { needsSync?: boolean }).needsSync ).length; } if (this.data.plex.hubConfigs) { collectionsNeedingSync += this.data.plex.hubConfigs.filter( (c) => c.needsSync ).length; } if (this.data.plex.preExistingCollectionConfigs) { collectionsNeedingSync += this.data.plex.preExistingCollectionConfigs.filter( (c) => c.needsSync ).length; } return { lastGlobalSyncAt: this.data.main.lastGlobalSyncAt, globalSyncError: this.data.main.globalSyncError, collectionsNeedingSync, }; } /** * Initialize sync status for existing collections (migration helper) */ public initializeSyncStatusForExistingCollections(): void { const now = new Date().toISOString(); // Initialize sync status for existing collections if (this.data.plex.collectionConfigs) { this.data.plex.collectionConfigs.forEach((config) => { if (!('needsSync' in config)) { Object.assign(config, { needsSync: true, lastModifiedAt: now }); } }); } if (this.data.plex.hubConfigs) { this.data.plex.hubConfigs.forEach((config) => { if (config.needsSync === undefined) { config.needsSync = true; config.lastModifiedAt = now; } }); } if (this.data.plex.preExistingCollectionConfigs) { this.data.plex.preExistingCollectionConfigs.forEach((config) => { if (config.needsSync === undefined) { config.needsSync = true; config.lastModifiedAt = now; } }); } this.save(); } /** * Complete collection data normalization migration for v1.1.0 * Replaces 4 incomplete migrations with comprehensive field normalization across all config types */ public migrateCollectionDataNormalizationV110(): void { const migrationId = 'collection-data-normalization-v1.1.0'; // Initialize completedMigrations if it doesn't exist if (!this.data.completedMigrations) { this.data.completedMigrations = []; } // Check if migration already completed if (this.data.completedMigrations.includes(migrationId)) { return; } const stats = { hubs: 0, collections: 0, preExisting: 0, duplicatesFixed: 0, }; // Step 1: Normalize all hub configs stats.hubs = this.normalizeHubConfigs(); // Step 2: Normalize all collection configs stats.collections = this.normalizeCollectionConfigs(); // Step 3: Normalize all pre-existing configs stats.preExisting = this.normalizePreExistingConfigs(); // Step 4: Fix duplicates per library for (const library of this.data.plex.libraries) { stats.duplicatesFixed += this.fixDuplicateSortOrdersForLibrary( library.key ); } // Step 5: Save and log this.data.completedMigrations.push(migrationId); this.save(); logger.info( `v1.1.0 Migration: Normalized ${ stats.hubs + stats.collections + stats.preExisting } configs, fixed ${stats.duplicatesFixed} duplicates`, { label: 'Settings Migration', stats, } ); } /** * Normalize hub configs with hub-specific business rules */ private normalizeHubConfigs(): number { let fixedCount = 0; if (!this.data.plex.hubConfigs) { return fixedCount; } this.data.plex.hubConfigs = this.data.plex.hubConfigs.map((config) => { const isVisibleOnHome = config.visibilityConfig?.usersHome || config.visibilityConfig?.serverOwnerHome || config.visibilityConfig?.libraryRecommended; // Check if normalization is needed const needsNormalization = config.sortOrderLibrary !== 0 || config.isLibraryPromoted !== false || config.everLibraryPromoted !== false || (!isVisibleOnHome && config.sortOrderHome > 0) || (config.collectionType === CollectionType.DEFAULT_PLEX_HUB && config.isPromotedToHub !== true) || config.isPromotedToHub === undefined; if (needsNormalization) { fixedCount++; return { ...config, // Business rule: Hubs CANNOT appear in library tabs sortOrderLibrary: 0, isLibraryPromoted: false, everLibraryPromoted: false, // Visibility rule: Only visible hubs get home positioning sortOrderHome: isVisibleOnHome ? config.sortOrderHome : 0, // Discovery rule: All default hubs are promotable isPromotedToHub: config.collectionType === CollectionType.DEFAULT_PLEX_HUB ? true : config.isPromotedToHub ?? true, }; } return config; }); return fixedCount; } /** * Normalize collection configs with collection business rules */ private normalizeCollectionConfigs(): number { let fixedCount = 0; if (!this.data.plex.collectionConfigs) { return fixedCount; } this.data.plex.collectionConfigs = this.data.plex.collectionConfigs.map( (config) => { const isVisibleOnHome = config.visibilityConfig?.usersHome || config.visibilityConfig?.serverOwnerHome || config.visibilityConfig?.libraryRecommended; // Check if normalization is needed const needsNormalization = (!isVisibleOnHome && config.sortOrderHome && config.sortOrderHome > 0) || (config.isLibraryPromoted === true && (!config.sortOrderLibrary || config.sortOrderLibrary === 0)) || (config.isLibraryPromoted === false && config.sortOrderLibrary && config.sortOrderLibrary > 0) || config.everLibraryPromoted === undefined; if (needsNormalization) { fixedCount++; return { ...config, // Visibility rule: Only visible collections get positioning sortOrderHome: isVisibleOnHome ? config.sortOrderHome : 0, // Consistency rule: Library positioning matches promotion status sortOrderLibrary: config.isLibraryPromoted ? config.sortOrderLibrary : 0, // Historical rule: Track promotion history everLibraryPromoted: config.isLibraryPromoted || (config.everLibraryPromoted ?? false), // No isPromotedToHub changes (calculated dynamically) }; } return config; } ); return fixedCount; } /** * Migrate comingsoon/recently_added configs to standalone recently_added type * This is a one-time migration for users upgrading from older versions */ public migrateComingSoonRecentlyAddedToStandalone(): void { const migrationId = 'comingsoon-recently-added-to-standalone'; // Initialize completedMigrations if it doesn't exist if (!this.data.completedMigrations) { this.data.completedMigrations = []; } // Skip if already completed if (this.data.completedMigrations.includes(migrationId)) { return; } if (!this.data.plex.collectionConfigs) { this.data.completedMigrations.push(migrationId); this.save(); return; } let migratedCount = 0; this.data.plex.collectionConfigs = this.data.plex.collectionConfigs.map( (config) => { // Check if this is a comingsoon/recently_added config that needs migration if ( config.type === 'comingsoon' && config.subtype === 'recently_added' ) { migratedCount++; logger.info( `Migrating comingsoon/recently_added config "${config.name}" to filtered_hub type with subtype recently_added`, { label: 'Settings Migration', configId: config.id, } ); return { ...config, type: 'filtered_hub' as const, subtype: 'recently_added', // filtered_hub requires a subtype }; } return config; } ); if (migratedCount > 0) { logger.info( `Migrated ${migratedCount} comingsoon/recently_added config(s) to filtered_hub type`, { label: 'Settings Migration', } ); } this.data.completedMigrations.push(migrationId); this.save(); } /** * Migrate recently_added type to filtered_hub with subtype recently_added * This is a one-time migration for the filtered hub refactoring */ public migrateRecentlyAddedToFilteredHub(): void { const migrationId = 'recently-added-to-filtered-hub'; // Initialize completedMigrations if it doesn't exist if (!this.data.completedMigrations) { this.data.completedMigrations = []; } // Skip if already completed if (this.data.completedMigrations.includes(migrationId)) { return; } if (!this.data.plex.collectionConfigs) { this.data.completedMigrations.push(migrationId); this.save(); return; } let migratedCount = 0; this.data.plex.collectionConfigs = this.data.plex.collectionConfigs.map( (config) => { // Check if this is a recently_added config that needs migration // Type assertion needed because 'recently_added' is a legacy type if ((config.type as string) === 'recently_added') { migratedCount++; logger.info( `Migrating recently_added config "${config.name}" to filtered_hub type with subtype recently_added`, { label: 'Settings Migration', configId: config.id, } ); return { ...config, type: 'filtered_hub' as const, subtype: 'recently_added', // Set subtype to recently_added }; } return config; } ); if (migratedCount > 0) { logger.info( `Migrated ${migratedCount} recently_added config(s) to filtered_hub type`, { label: 'Settings Migration', } ); } this.data.completedMigrations.push(migrationId); this.save(); } /** * Migrate old filter format (excludedGenres, excludedCountries, excludedLanguages) * to new unified filterSettings format with include/exclude modes * This is a one-time migration for users upgrading from older versions */ public migrateToUnifiedFilterSettings(): void { const migrationId = 'unified-filter-settings-v2'; // Initialize completedMigrations if it doesn't exist if (!this.data.completedMigrations) { this.data.completedMigrations = []; } // Skip if already completed if (this.data.completedMigrations.includes(migrationId)) { return; } if (!this.data.plex.collectionConfigs) { this.data.completedMigrations.push(migrationId); this.save(); return; } let migratedCount = 0; this.data.plex.collectionConfigs = this.data.plex.collectionConfigs.map( (config) => { // Check if we need to migrate old-format filters to new format const hasOldFilters = (config.excludedGenres && config.excludedGenres.length > 0) || (config.excludedCountries && config.excludedCountries.length > 0) || (config.excludedLanguages && config.excludedLanguages.length > 0); // Build new filterSettings if migrating from old format let filterSettings = config.filterSettings; if (hasOldFilters && !config.filterSettings) { // Build new filterSettings object from old format const newFilterSettings: { genres?: { mode: 'exclude' | 'include'; values: number[] }; countries?: { mode: 'exclude' | 'include'; values: string[] }; languages?: { mode: 'exclude' | 'include'; values: string[] }; } = {}; if (config.excludedGenres && config.excludedGenres.length > 0) { newFilterSettings.genres = { mode: 'exclude', values: config.excludedGenres, }; } if (config.excludedCountries && config.excludedCountries.length > 0) { newFilterSettings.countries = { mode: 'exclude', values: config.excludedCountries, }; } if (config.excludedLanguages && config.excludedLanguages.length > 0) { newFilterSettings.languages = { mode: 'exclude', values: config.excludedLanguages, }; } filterSettings = newFilterSettings; migratedCount++; logger.info( `Migrating collection "${config.name}" from old filter format to unified filterSettings`, { label: 'Settings Migration', configId: config.id, } ); } // Check if we need to clean up deprecated fields const hasDeprecatedFields = config.excludedGenres !== undefined || config.excludedCountries !== undefined || config.excludedLanguages !== undefined; // Always remove deprecated fields (whether they had values or not) if (hasDeprecatedFields) { return { ...config, filterSettings: filterSettings && Object.keys(filterSettings).length > 0 ? filterSettings : undefined, excludedGenres: undefined, excludedCountries: undefined, excludedLanguages: undefined, }; } return config; } ); if (migratedCount > 0) { logger.info( `Migrated ${migratedCount} collection(s) to unified filter settings format`, { label: 'Settings Migration', } ); } this.data.completedMigrations.push(migrationId); this.save(); } /** * Migrate overlay-application job schedule from midnight to 3am * Prevents conflict with plex-collections-sync which runs at midnight * This is a one-time migration for users upgrading from older versions */ public migrateOverlayJobSchedule(): void { const migrationId = 'overlay-job-schedule-fix'; // Initialize completedMigrations if it doesn't exist if (!this.data.completedMigrations) { this.data.completedMigrations = []; } // Skip if already completed if (this.data.completedMigrations.includes(migrationId)) { return; } // If overlay-application job is still at old default (midnight), update to 3am const currentSchedule = this.data.jobs['overlay-application'].schedule; if (currentSchedule === '0 0 0 * * *') { // Old midnight default this.data.jobs['overlay-application'].schedule = '0 0 3 * * *'; // New 3am default logger.info( 'Migrated overlay-application schedule from midnight to 3am to avoid conflict with collections sync', { label: 'Settings Migration', } ); } this.data.completedMigrations.push(migrationId); this.save(); } /** * Migrate global placeholder settings to per-library format * One-time migration: copies global settings to each library, then removes global settings */ public migratePlaceholderSettingsToPerLibrary(): void { const migrationId = 'placeholder-settings-per-library-v1'; // Initialize completedMigrations if it doesn't exist if (!this.data.completedMigrations) { this.data.completedMigrations = []; } // Skip if already completed if (this.data.completedMigrations.includes(migrationId)) { return; } let migratedCount = 0; // Initialize per-library maps if they don't exist if (!this.data.main.placeholderMovieRootFolders) { this.data.main.placeholderMovieRootFolders = {}; } if (!this.data.main.placeholderTVRootFolders) { this.data.main.placeholderTVRootFolders = {}; } // Get legacy global settings (access via index signature for backwards compatibility) type LegacyMainSettings = MainSettings & { placeholderMovieRootFolder?: string; placeholderTVRootFolder?: string; }; const mainSettings = this.data.main as LegacyMainSettings; const globalMovieFolder = mainSettings.placeholderMovieRootFolder; const globalTVFolder = mainSettings.placeholderTVRootFolder; // Copy global settings to all libraries if (globalMovieFolder || globalTVFolder) { for (const library of this.data.plex.libraries) { if (library.type === 'movie' && globalMovieFolder) { this.data.main.placeholderMovieRootFolders[library.key] = globalMovieFolder; migratedCount++; } else if (library.type === 'show' && globalTVFolder) { this.data.main.placeholderTVRootFolders[library.key] = globalTVFolder; migratedCount++; } } // Delete global settings after migration delete mainSettings.placeholderMovieRootFolder; delete mainSettings.placeholderTVRootFolder; logger.info( `Migrated ${migratedCount} library placeholder folder setting(s) from global to per-library format`, { label: 'Settings Migration' } ); } this.data.completedMigrations.push(migrationId); this.save(); } /** * Normalize pre-existing configs with pre-existing collection business rules */ private normalizePreExistingConfigs(): number { let fixedCount = 0; const preExistingConfigs = preExistingCollectionConfigService.getConfigs(); const updatedConfigs: PreExistingCollectionConfig[] = []; for (const config of preExistingConfigs) { const isVisibleOnHome = config.visibilityConfig?.usersHome || config.visibilityConfig?.serverOwnerHome || config.visibilityConfig?.libraryRecommended; // Check if normalization is needed const needsNormalization = (!isVisibleOnHome && config.sortOrderHome > 0) || (config.isLibraryPromoted === true && config.sortOrderLibrary === 0) || (config.isLibraryPromoted === false && config.sortOrderLibrary > 0) || config.everLibraryPromoted === undefined || config.isPromotedToHub === undefined; if (needsNormalization) { fixedCount++; updatedConfigs.push({ ...config, // Same as Collections (identical business rules) sortOrderHome: isVisibleOnHome ? config.sortOrderHome : 0, sortOrderLibrary: config.isLibraryPromoted ? config.sortOrderLibrary : 0, everLibraryPromoted: config.isLibraryPromoted || (config.everLibraryPromoted ?? false), // Discovery rule: Default to collections API only isPromotedToHub: config.isPromotedToHub ?? false, }); } else { updatedConfigs.push(config); } } // Save updated configs if any changes were made if (fixedCount > 0) { preExistingCollectionConfigService.saveConfigs(updatedConfigs); } return fixedCount; } /** * Fix duplicate sort orders for a specific library */ private fixDuplicateSortOrdersForLibrary(libraryKey: string): number { // Get all configs for this library const libraryCollections = (this.data.plex.collectionConfigs || []).filter( (config) => { const belongsToLibrary = Array.isArray(config.libraryId) ? config.libraryId.includes(libraryKey) : config.libraryId === libraryKey; return belongsToLibrary; } ); const libraryHubs = defaultHubConfigService .getConfigs() .filter((config: PlexHubConfig) => { return config.libraryId === libraryKey; }); const libraryPreExisting = preExistingCollectionConfigService .getConfigs() .filter((config: PreExistingCollectionConfig) => { return config.libraryId === libraryKey; }); let totalFixed = 0; // Fix home screen duplicates totalFixed += this.fixDuplicateSortOrdersInContext( libraryCollections, libraryHubs, libraryPreExisting, 'sortOrderHome' ); // Fix library tab duplicates totalFixed += this.fixDuplicateSortOrdersInContext( libraryCollections, libraryHubs, libraryPreExisting, 'sortOrderLibrary' ); return totalFixed; } /** * Fix duplicate sort orders in a specific context (home or library) */ private fixDuplicateSortOrdersInContext( collections: CollectionConfig[], hubs: PlexHubConfig[], preExisting: PreExistingCollectionConfig[], sortOrderField: 'sortOrderHome' | 'sortOrderLibrary' ): number { // Combine all items that should be positioned (including promoted items with 0 values) const allItems = [ ...collections .filter( (c) => (c[sortOrderField] || 0) > 0 || (sortOrderField === 'sortOrderLibrary' && c.isLibraryPromoted) || (sortOrderField === 'sortOrderHome' && (c.visibilityConfig?.usersHome || c.visibilityConfig?.serverOwnerHome || c.visibilityConfig?.libraryRecommended)) ) .map((c) => ({ ...c, configType: 'collection' as const })), ...hubs .filter( (h) => (h[sortOrderField] || 0) > 0 || (sortOrderField === 'sortOrderHome' && (h.visibilityConfig?.usersHome || h.visibilityConfig?.serverOwnerHome || h.visibilityConfig?.libraryRecommended)) ) .map((h) => ({ ...h, configType: 'hub' as const })), ...preExisting .filter( (p) => (p[sortOrderField] || 0) > 0 || (sortOrderField === 'sortOrderLibrary' && p.isLibraryPromoted) || (sortOrderField === 'sortOrderHome' && (p.visibilityConfig?.usersHome || p.visibilityConfig?.serverOwnerHome || p.visibilityConfig?.libraryRecommended)) ) .map((p) => ({ ...p, configType: 'preExisting' as const })), ]; if (allItems.length === 0) { return 0; } // Sort by current position to preserve relative ordering allItems.sort( (a, b) => (a[sortOrderField] || 0) - (b[sortOrderField] || 0) ); // Assign sequential positions and track changes let fixedCount = 0; const updatedCollections: CollectionConfig[] = []; const updatedHubs: PlexHubConfig[] = []; const updatedPreExisting: PreExistingCollectionConfig[] = []; allItems.forEach((item, index) => { const newPosition = index + 1; const currentPosition = item[sortOrderField] || 0; if (currentPosition !== newPosition) { fixedCount++; const updatedItem = { ...item, [sortOrderField]: newPosition }; if (item.configType === 'collection') { updatedCollections.push(updatedItem as CollectionConfig); } else if (item.configType === 'hub') { updatedHubs.push(updatedItem as PlexHubConfig); } else { updatedPreExisting.push(updatedItem as PreExistingCollectionConfig); } } }); // Apply updates if (updatedCollections.length > 0) { updatedCollections.forEach((updatedConfig) => { const index = (this.data.plex.collectionConfigs || []).findIndex( (c) => c.id === updatedConfig.id ); if (index >= 0 && this.data.plex.collectionConfigs) { this.data.plex.collectionConfigs[index] = updatedConfig; } }); } if (updatedHubs.length > 0) { const allHubConfigs = defaultHubConfigService .getConfigs() .map((config) => { const updated = updatedHubs.find((u) => u.id === config.id); return updated || config; }); defaultHubConfigService.saveExistingConfigs(allHubConfigs); } if (updatedPreExisting.length > 0) { const allPreExistingConfigs = preExistingCollectionConfigService .getConfigs() .map((config) => { const updated = updatedPreExisting.find((u) => u.id === config.id); return updated || config; }); preExistingCollectionConfigService.saveConfigs(allPreExistingConfigs); } return fixedCount; } } let settings: Settings | undefined; // Multi-source collection types export type MultiSourceCombineMode = | 'interleaved' | 'list_order' | 'randomised' | 'cycle_lists'; /** * Sync schedule preset options */ export const SYNC_SCHEDULE_PRESETS = [ { key: '10m', label: 'Every 10 minutes', intervalHours: 1 / 6 }, { key: '15m', label: 'Every 15 minutes', intervalHours: 1 / 4 }, { key: '30m', label: 'Every 30 minutes', intervalHours: 0.5 }, { key: '1h', label: 'Every hour', intervalHours: 1 }, { key: '2h', label: 'Every 2 hours', intervalHours: 2 }, { key: '3h', label: 'Every 3 hours', intervalHours: 3 }, { key: '6h', label: 'Every 6 hours', intervalHours: 6 }, { key: '12h', label: 'Every 12 hours', intervalHours: 12 }, { key: '1d', label: 'Once daily', intervalHours: 24 }, { key: '2d', label: 'Every 2 days', intervalHours: 48 }, { key: '3d', label: 'Every 3 days', intervalHours: 72 }, { key: '1w', label: 'Once weekly', intervalHours: 168 }, { key: '2w', label: 'Every 2 weeks', intervalHours: 336 }, { key: '1m', label: 'Once monthly', intervalHours: 720 }, // ~30 days { key: '3m', label: 'Every 3 months', intervalHours: 2160 }, // ~90 days { key: '6m', label: 'Every 6 months', intervalHours: 4320 }, // ~180 days { key: '1y', label: 'Once yearly', intervalHours: 8760 }, // ~365 days ] as const; export interface CustomSyncSchedule { readonly enabled: boolean; readonly scheduleType: 'preset' | 'custom'; // Type of schedule: preset dropdown or custom cron readonly intervalHours?: number; // Legacy field for backward compatibility (when scheduleType === 'preset') readonly preset?: string; // Preset option key (e.g., '10m', '30m', '1h', '6h', '1d', '1w') readonly customCron?: string; // Custom cron expression (when scheduleType === 'custom') readonly startNow: boolean; // If true, start immediately; if false, use startDate readonly startDate?: string; // Start date in DD-MM format (e.g., "01-01" for January 1st) readonly startTime?: string; // Start time in HH:MM format (e.g., "09:00") firstSyncAt?: string; // ISO timestamp of when this schedule was first created (for persistence across restarts) - mutable for system updates } export type MultiSourceType = | 'trakt' | 'tmdb' | 'imdb' | 'letterboxd' | 'mdblist' | 'tautulli' | 'overseerr' | 'networks' | 'originals' | 'anilist' | 'myanimelist' | 'radarrtag' | 'sonarrtag' | 'comingsoon'; export interface SourceDefinition { readonly id: string; readonly type: MultiSourceType; readonly subtype: string; readonly customUrl?: string; readonly timePeriod?: 'daily' | 'weekly' | 'monthly' | 'all'; readonly customDays?: number; readonly minimumPlays?: number; readonly priority: number; readonly networksCountry?: string; readonly radarrTagServerId?: number; readonly radarrTagId?: number; readonly radarrTagLabel?: string; readonly sonarrTagServerId?: number; readonly sonarrTagId?: number; readonly sonarrTagLabel?: string; } export interface MultiSourceCollectionConfig { readonly id: string; readonly name: string; readonly type: 'multi-source'; readonly visibilityConfig: { usersHome: boolean; serverOwnerHome: boolean; libraryRecommended: boolean; }; readonly mediaType?: 'movie' | 'tv'; readonly libraryId: string; readonly libraryName: string; readonly maxItems?: number; readonly template?: string; readonly sources: readonly SourceDefinition[]; readonly combineMode: MultiSourceCombineMode; readonly customSyncSchedule?: CustomSyncSchedule; readonly isActive?: boolean; readonly sortOrderHome?: number; readonly sortOrderLibrary?: number; readonly isLibraryPromoted?: boolean; readonly timeRestriction?: { readonly alwaysActive: boolean; readonly removeFromPlexWhenInactive?: boolean; readonly inactiveVisibilityConfig?: { usersHome: boolean; serverOwnerHome: boolean; libraryRecommended: boolean; }; }; readonly customPoster?: string | Record; readonly autoPoster?: boolean; readonly autoPosterTemplate?: number | null; // Template ID for auto-generated posters (null for default template) readonly applyOverlaysDuringSync?: boolean; // Apply item overlays during sync (for Coming Soon collections) // Placeholder creation settings (shared with CollectionConfig) readonly createPlaceholdersForMissing?: boolean; // Enable placeholder creation for missing items readonly placeholderDaysAhead?: number; // How many days ahead to create placeholders readonly placeholderReleasedDays?: number; // How many days after release to keep placeholders // Missing items / auto-download settings (same as CollectionConfig) readonly downloadMode?: 'overseerr' | 'direct'; readonly searchMissingMovies?: boolean; readonly searchMissingTV?: boolean; readonly autoApproveMovies?: boolean; readonly autoApproveTV?: boolean; readonly maxSeasonsToRequest?: number; readonly seasonsPerShowLimit?: number; readonly maxPositionToProcess?: number; readonly minimumYear?: number; readonly minimumImdbRating?: number; readonly minimumRottenTomatoesRating?: number; readonly excludedGenres?: number[]; readonly excludedCountries?: string[]; readonly excludedLanguages?: string[]; readonly filterSettings?: { readonly genres?: { readonly mode: 'exclude' | 'include'; readonly values: number[]; }; readonly countries?: { readonly mode: 'exclude' | 'include'; readonly values: string[]; }; readonly languages?: { readonly mode: 'exclude' | 'include'; readonly values: string[]; }; }; readonly directDownloadRadarrServerId?: number; readonly directDownloadRadarrProfileId?: number; readonly directDownloadRadarrRootFolder?: string; readonly directDownloadRadarrTags?: number[]; readonly directDownloadRadarrMonitor?: boolean; readonly directDownloadRadarrSearchOnAdd?: boolean; readonly directDownloadSonarrServerId?: number; readonly directDownloadSonarrProfileId?: number; readonly directDownloadSonarrRootFolder?: string; readonly directDownloadSonarrTags?: number[]; readonly directDownloadSonarrMonitor?: boolean; readonly directDownloadSonarrSearchOnAdd?: boolean; readonly overseerrRadarrServerId?: number; readonly overseerrRadarrProfileId?: number; readonly overseerrRadarrRootFolder?: string; readonly overseerrRadarrTags?: number[]; readonly overseerrSonarrServerId?: number; readonly overseerrSonarrProfileId?: number; readonly overseerrSonarrRootFolder?: string; readonly overseerrSonarrTags?: number[]; readonly collectionRatingKey?: string; // Plex collection rating key (regular or smart collection) readonly smartCollectionRatingKey?: string; // LEGACY: Old dual-collection system smart collection rating key (for migration only) // Smart collection settings (unwatched filter feature) readonly showUnwatchedOnly?: boolean; // If true, create a smart collection that filters to unwatched items only readonly smartCollectionSort?: SmartCollectionSortOption; // Sort option for smart collections // Wallpaper, summary, and theme settings readonly customWallpaper?: string | Record; // Path to custom wallpaper (art) image file, or per-library wallpaper mapping readonly customSummary?: string; // Custom summary/description text for the collection readonly customTheme?: string | Record; // Path to custom theme music file, or per-library theme mapping readonly enableCustomWallpaper?: boolean; // Enable custom wallpaper sync to Plex readonly enableCustomSummary?: boolean; // Enable custom summary sync to Plex readonly enableCustomTheme?: boolean; // Enable custom theme sync to Plex } export const getSettings = (initialSettings?: AllSettings): Settings => { if (!settings) { settings = new Settings(initialSettings); } return settings; }; /** * Get the configured TMDB language for API calls * * Fallback chain: * 1. Library-specific override (if libraryId provided) * 2. Global setting (settings.main.tmdbLanguage) * 3. Default 'en' * * @param libraryId - Optional Plex library ID for per-library override * @returns ISO language code (e.g., 'en', 'fr', 'pt-BR') */ export const getTmdbLanguage = async (libraryId?: string): Promise => { // Step 1: Check for library-specific override if (libraryId) { try { const overlayConfigRepo = getRepository(OverlayLibraryConfig); const libraryConfig = await overlayConfigRepo.findOne({ where: { libraryId }, }); if (libraryConfig?.tmdbLanguage) { return libraryConfig.tmdbLanguage; } } catch (error) { logger.debug( 'Failed to fetch library TMDB language config, using global fallback', { label: 'Settings', libraryId, error: error instanceof Error ? error.message : String(error), } ); } } // Step 2: Fall back to global setting const settings = getSettings(); return settings.main.tmdbLanguage || 'en'; }; export default Settings;