mirror of
https://github.com/agregarr/agregarr.git
synced 2026-01-20 20:53:32 +08:00
add "Include all released items" toggle, fix orphaned cleanup to use fixed 7-day grace period, fix placeholder quick sync deleting tv items, other minor changes re #336, re #268, re #253, re #307
2275 lines
80 KiB
TypeScript
2275 lines
80 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
|
|
|
|
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<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, only for 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)
|
|
// 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<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
|
|
}
|
|
|
|
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<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: [],
|
|
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<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
|
|
// 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<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;
|