mirror of
https://github.com/agregarr/agregarr.git
synced 2026-05-12 21:59:11 -05:00
95f77d64a4
Longstanding Plex bug not respecting label restrictions for Collections on Home/Recommended has been fixed in PMS Beta 1.43.1.10540, confirmed working with Agregarr. Also removes previous easter egg which enabled the option (intended for use when the project was going to be a PR for Overseerr, allowing use of the option without waiting for an update) fix #112
2376 lines
84 KiB
TypeScript
2376 lines
84 KiB
TypeScript
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
|
|
|
|
/**
|
|
* Sonarr monitor types - determines which episodes are monitored when adding a series
|
|
*/
|
|
export type SonarrMonitorType =
|
|
| 'all' // Monitor all episodes except specials
|
|
| 'future' // Monitor episodes that have not aired yet
|
|
| 'missing' // Monitor episodes that do not have files or have not aired yet
|
|
| 'existing' // Monitor episodes that have files or have not aired yet
|
|
| 'recent' // Monitor episodes aired within the last 90 days and future episodes
|
|
| 'pilot' // Only monitor the first episode of the first season
|
|
| 'firstSeason' // Monitor all episodes of the first season
|
|
| 'lastSeason' // Monitor all episodes of the last season
|
|
| 'none'; // No episodes will be monitored
|
|
|
|
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', 'most_watched_plays', 'most_watched_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;
|
|
// TMDB streaming service fields
|
|
readonly watchProviderId?: number; // TMDB watch provider ID (e.g., 337 for Disney+)
|
|
readonly region?: string; // Country region for streaming services (default: 'US')
|
|
// TMDB discover sorting (for TMDB advanced_custom_tmdb advanced discover)
|
|
readonly tmdbMovieSortBy?: string; // TMDB /discover/movie sort_by
|
|
readonly tmdbTvSortBy?: string; // TMDB /discover/tv sort_by
|
|
// TMDB advanced discover filters
|
|
readonly tmdbAdvancedFilters?: {
|
|
readonly filterGroups?: readonly {
|
|
readonly id: string;
|
|
readonly operator: 'and' | 'or'; // How this group combines with previous groups
|
|
readonly filters: readonly {
|
|
readonly id: string;
|
|
readonly field: string; // e.g., 'with_genres', 'vote_average.gte'
|
|
readonly operator: 'and' | 'or'; // For multi-value fields (comma vs pipe)
|
|
readonly value: string | number | boolean;
|
|
}[];
|
|
}[];
|
|
};
|
|
// 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
|
|
};
|
|
readonly keywords?: {
|
|
readonly mode: 'exclude' | 'include'; // Default: 'exclude'
|
|
readonly values: number[]; // TMDB keyword IDs
|
|
};
|
|
};
|
|
|
|
// 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 (deprecated, use monitorType)
|
|
readonly directDownloadSonarrMonitorType?: SonarrMonitorType; // Override Sonarr monitor type 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<string, string>; // 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, supported for Coming Soon and TMDB auto_franchise collections)
|
|
// Wallpaper, summary, and theme settings
|
|
readonly customWallpaper?: string | Record<string, string>; // 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<string, string>; // 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)
|
|
readonly includeAllReleasedItems?: boolean; // If true, include all released items regardless of release date (default: true for new configs)
|
|
// Placeholder filter settings (independent of auto-request filters)
|
|
readonly placeholderMinimumYear?: number;
|
|
readonly placeholderMinimumImdbRating?: number;
|
|
readonly placeholderMinimumRottenTomatoesRating?: number;
|
|
readonly placeholderMinimumRottenTomatoesAudienceRating?: number;
|
|
readonly placeholderFilterSettings?: {
|
|
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 keywords?: {
|
|
readonly mode: 'exclude' | 'include';
|
|
readonly values: number[];
|
|
};
|
|
};
|
|
// Legacy Coming Soon fields (for backward compatibility during migration)
|
|
readonly comingSoonReleasedDays?: number; // @deprecated Use placeholderReleasedDays
|
|
readonly comingSoonDays?: number; // @deprecated Use placeholderDaysAhead
|
|
// Coming Soon "Monitored" server/tag filtering
|
|
readonly comingSoonRadarrServerId?: number; // Selected Radarr server for coming soon monitored
|
|
readonly comingSoonSonarrServerId?: number; // Selected Sonarr server for coming soon monitored
|
|
readonly comingSoonFilterByTags?: boolean; // Enable tag filtering for coming soon monitored
|
|
readonly comingSoonTagMode?: 'include' | 'exclude'; // Tag filter mode
|
|
readonly comingSoonRadarrTagIds?: number[]; // Radarr tag IDs to filter by
|
|
readonly comingSoonSonarrTagIds?: number[]; // Sonarr tag IDs to filter by
|
|
// 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<string, string>; // 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<string, string>; // Path to custom wallpaper (art) image file, or per-library wallpaper mapping
|
|
customSummary?: string; // Custom summary/description text for the collection
|
|
customTheme?: string | Record<string, string>; // 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
|
|
applyOverlaysDuringSync?: boolean; // Apply item overlays during sync
|
|
}
|
|
|
|
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
|
|
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;
|
|
tagExistingItems?: boolean; // Apply collection tags to items that already exist in Radarr/Sonarr
|
|
}
|
|
|
|
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;
|
|
monitorType?: SonarrMonitorType; // Which episodes to monitor when adding series (defaults to 'all')
|
|
}
|
|
|
|
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
|
|
tagWithUsername?: boolean; // Tag media with the user's Plex username
|
|
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
|
|
tagWithUsername?: boolean; // Tag media with the user's Plex username
|
|
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<string, string>; // libraryKey -> movie placeholder path mapping
|
|
placeholderTVRootFolders?: Record<string, string>; // libraryKey -> TV placeholder path mapping
|
|
// YouTube trailer download settings
|
|
skipYoutubeTrailerDownloads?: boolean; // If true, skip YouTube trailer downloads and use hardcoded placeholder video only (speeds up sync)
|
|
}
|
|
|
|
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<JobId, JobSettings>;
|
|
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: [],
|
|
},
|
|
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<JobId, JobSettings> {
|
|
return this.data.jobs;
|
|
}
|
|
|
|
set jobs(data: Record<JobId, JobSettings>) {
|
|
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<string, string>;
|
|
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
|
|
readonly includeAllReleasedItems?: boolean; // If true, include all released items regardless of release date
|
|
// Placeholder filter settings (independent of auto-request filters)
|
|
readonly placeholderMinimumYear?: number;
|
|
readonly placeholderMinimumImdbRating?: number;
|
|
readonly placeholderMinimumRottenTomatoesRating?: number;
|
|
readonly placeholderMinimumRottenTomatoesAudienceRating?: number;
|
|
readonly placeholderFilterSettings?: {
|
|
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 keywords?: {
|
|
readonly mode: 'exclude' | 'include';
|
|
readonly values: number[];
|
|
};
|
|
};
|
|
// 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 seasonGrabOrder?: SeasonGrabOrder;
|
|
readonly maxPositionToProcess?: number;
|
|
readonly minimumYear?: number;
|
|
readonly minimumImdbRating?: number;
|
|
readonly minimumRottenTomatoesRating?: number;
|
|
readonly minimumRottenTomatoesAudienceRating?: 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 keywords?: {
|
|
readonly mode: 'exclude' | 'include';
|
|
readonly values: number[];
|
|
};
|
|
};
|
|
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 directDownloadSonarrMonitorType?: SonarrMonitorType;
|
|
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<string, string>; // 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<string, string>; // 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<string> => {
|
|
// 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;
|