agregarr_agregarr/server/lib/collections/core/BaseCollectionSync.ts
bitr8 63b8851a1a
fix(Placeholders): Separate placeholder filters independent of auto-request filters (#456)
* feat: separate placeholder filters independent of auto-request filters

Add placeholderMinimumYear, placeholderMinimumImdbRating,
placeholderMinimumRottenTomatoesRating,
placeholderMinimumRottenTomatoesAudienceRating, and
placeholderFilterSettings to CollectionConfig and
MultiSourceCollectionConfig.

buildPlaceholderFilterConfig() helper swaps placeholder values into
standard filter fields so MissingItemFilterService works unchanged.
Updated BaseCollectionSync and MultiSourceOrchestrator call sites.

Collapsible "Placeholder Filters" section in collection edit form
reuses FilterWithMode/KeywordFilterWithMode. Auto-expands when
editing configs with existing values.

Fixed pre-existing gap: keywords missing from
MultiSourceCollectionConfig.filterSettings type.

---------

Co-authored-by: bitr8 <bitr8@users.noreply.github.com>
Co-authored-by: Tom Wheeler <thomas.wheeler.tcw@gmail.com>
2026-02-20 13:22:44 +13:00

3703 lines
120 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import ImdbRatingsAPI from '@server/api/imdbRatings';
import type PlexAPI from '@server/api/plexapi';
import { getRepository } from '@server/datasource';
import { CollectionMetadata } from '@server/entity/CollectionMetadata';
import { PosterTemplate } from '@server/entity/PosterTemplate';
import cacheManager from '@server/lib/cache';
import type { ServiceUserManager } from '@server/lib/collections/services/ServiceUserManager';
import { serviceUserManager } from '@server/lib/collections/services/ServiceUserManager';
import type { TemplateEngine } from '@server/lib/collections/utils/TemplateEngine';
import { templateEngine } from '@server/lib/collections/utils/TemplateEngine';
import { TimeRestrictionUtils } from '@server/lib/collections/utils/TimeRestrictionUtils';
import type { CollectionItemWithPoster } from '@server/lib/posterGeneration';
import { generatePoster } from '@server/lib/posterStorage';
import type { CollectionConfig } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import path from 'path';
import {
applyCollectionExclusions,
createCollectionLabel,
createSyncError,
getCollectionMediaType,
handleRateLimit,
logCollectionProcessingResults,
sanitizeCollectionName,
updateConfigWithRatingKey,
validateAndSanitizeItems,
validateCollectionItems,
validateRequiredFields,
type LibraryItemsCache,
} from './CollectionUtilities';
import type {
AutoRequestConfig,
AutoRequestResult,
CollectionItem,
CollectionOperationResult,
CollectionSource,
CollectionSourceData,
CollectionSyncError,
CollectionSyncInterface,
CollectionSyncOptions,
CollectionVisibilityConfig,
FilteringStats,
MissingItem,
PlexCollection,
PlexLabel,
SourceTemplateContext,
SyncResult,
TimeRestrictionResult,
} from './types';
import { CollectionSyncErrorType } from './types';
// Extended PlexCollection interface that includes smart collection properties
interface PlexCollectionWithSmart extends PlexCollection {
smart?: string; // Plex returns string "1" for smart collections
}
// Simple result type - replaces over-engineered MediaTypeStrategies
interface MediaProcessingResult {
created: number;
updated: number;
itemCount: number;
collectionKeys: string[];
error?: string;
}
// CollectionUpdateStrategy removed - logic moved inline below
import type { PlexCollectionItem } from '@server/api/plexapi';
// Types moved from CollectionUpdateStrategy.ts
interface CollectionUpdateOptions {
collectionName: string;
mediaType: 'movie' | 'tv';
visibilityConfig: CollectionVisibilityConfig;
customLabel: string;
sortOrderLibrary?: number;
isLibraryPromoted?: boolean;
totalCollectionsInLibrary?: number;
customPoster?: string | Record<string, string>;
processedCollectionKeys?: Set<string>;
libraryKey: string;
config?: CollectionConfig;
}
interface CollectionUpdateResult {
created: number;
updated: number;
collectionRatingKey?: string;
itemCount: number;
updateStats?: {
added: number;
removed: number;
reordered: boolean;
};
}
/**
* Abstract base class for all collection sync implementations
*
* Provides common functionality and enforces a consistent pipeline across
* all collection sync sources (Overseerr, Tautulli, Trakt).
*
* @template TSource - The specific collection source type (e.g., 'trakt', 'imdb')
*/
export abstract class BaseCollectionSync<TSource extends CollectionSource>
implements CollectionSyncInterface
{
protected templateEngine: TemplateEngine;
protected serviceUserManager: ServiceUserManager;
protected source: TSource;
constructor(source: TSource) {
this.templateEngine = templateEngine;
this.serviceUserManager = serviceUserManager;
this.source = source;
}
/**
* Extract items from a collection configuration without creating/updating collections
* Used by multi-source orchestrator to get items for combining
*/
public async extractItemsForMultiSource(
config: CollectionConfig,
plexClient: PlexAPI,
libraryCache?: LibraryItemsCache,
options?: CollectionSyncOptions
): Promise<CollectionItem[]> {
try {
// Validate source is properly configured
await this.validateConfiguration();
// Validate this config is for our source
const sourceConfigs = this.filterConfigsForSource([config]);
if (sourceConfigs.length === 0) {
logger.debug(`Config ${config.name} is not for source ${this.source}`, {
label: `${this.source} Multi-Source Extractor`,
configName: config.name,
});
return [];
}
const validatedConfig = sourceConfigs[0];
// Fetch data from the external source
const sourceData = await this.fetchSourceData(
validatedConfig,
options,
libraryCache
);
if (!sourceData || sourceData.length === 0) {
logger.debug(`No source data returned for ${validatedConfig.name}`, {
label: `${this.source} Multi-Source Extractor`,
configName: validatedConfig.name,
});
return [];
}
// Map to standardized CollectionItem format
const mappedResult = await this.mapSourceDataToItems(
sourceData,
validatedConfig,
plexClient,
libraryCache
);
logger.debug(
`Extracted ${mappedResult.items.length} items from ${this.source} for multi-source collection`,
{
label: `${this.source} Multi-Source Extractor`,
configName: validatedConfig.name,
itemCount: mappedResult.items.length,
}
);
return mappedResult.items;
} catch (error) {
logger.error(
`Failed to extract items from ${this.source} for multi-source collection: ${error}`,
{
label: `${this.source} Multi-Source Extractor`,
configName: config.name,
error: error instanceof Error ? error.message : String(error),
}
);
return [];
}
}
/**
* Main entry point for processing collections
* Implements the common pipeline that all sources follow
*/
public async processCollections(
collectionConfigs: CollectionConfig[],
plexClient: PlexAPI,
allCollections: PlexCollection[],
processedCollectionKeys?: Set<string>,
libraryCache?: LibraryItemsCache,
options?: CollectionSyncOptions
): Promise<SyncResult> {
const startTime = Date.now();
let created = 0;
let updated = 0;
const errors: CollectionSyncError[] = [];
// Filter configs for this source
const sourceConfigs = this.filterConfigsForSource(collectionConfigs);
if (sourceConfigs.length === 0) {
return { created: 0, updated: 0 };
}
try {
// Validate source is properly configured
await this.validateConfiguration();
// Process each configuration
for (let i = 0; i < sourceConfigs.length; i++) {
const config = sourceConfigs[i];
try {
// Check time restrictions and determine effective visibility
const timeRestrictionResult = this.evaluateTimeRestriction(config);
const removeFromPlexWhenInactive =
config.timeRestriction?.removeFromPlexWhenInactive ?? false;
// Update isActive status in the config if it has changed
if (config.isActive !== timeRestrictionResult.isActive) {
this.updateConfigActiveStatus(
config.id,
timeRestrictionResult.isActive
);
}
// Determine the effective configuration to use
let effectiveConfig = config;
if (!timeRestrictionResult.isActive && !removeFromPlexWhenInactive) {
// Collection is inactive but should use inactive visibility settings
const inactiveVisibilityConfig = config.timeRestriction
?.inactiveVisibilityConfig ?? {
usersHome: false,
serverOwnerHome: false,
libraryRecommended: true,
};
logger.debug(
`Processing collection ${config.name} with inactive visibility settings - time restriction not met (${timeRestrictionResult.reason})`,
{
label: `${this.source} Collections`,
configId: config.id,
reason: timeRestrictionResult.reason,
nextActivation: timeRestrictionResult.nextActivation,
inactiveVisibility: inactiveVisibilityConfig,
}
);
// Override visibility config for inactive collections
effectiveConfig = {
...config,
visibilityConfig: inactiveVisibilityConfig,
};
} else if (
!timeRestrictionResult.isActive &&
removeFromPlexWhenInactive
) {
// Collection is inactive and should be removed completely - skip processing
logger.debug(
`Skipping collection ${config.name} - time restriction not met and set to remove from Plex (${timeRestrictionResult.reason})`,
{
label: `${this.source} Collections`,
configId: config.id,
reason: timeRestrictionResult.reason,
nextActivation: timeRestrictionResult.nextActivation,
}
);
// If collection is time-restricted and inactive, try to remove it from Plex
await this.handleInactiveCollection(
config,
plexClient,
allCollections,
processedCollectionKeys
);
continue;
}
// Process individual configuration (using effective config with potentially overridden visibility)
const result = await this.processConfiguration(
effectiveConfig,
plexClient,
allCollections,
processedCollectionKeys,
libraryCache, // OPTIMIZATION: Pass library cache to eliminate repeated API calls
options
);
created += result.created;
updated += result.updated;
// Apply overlays if enabled for this collection
// Extract rating key from result (handles both MediaProcessingResult and custom result types)
const customResultRatingKey = (result as CollectionUpdateResult)
.collectionRatingKey;
const mediaResultRatingKey = (result as MediaProcessingResult)
.collectionKeys?.[0];
const configRatingKey = effectiveConfig.collectionRatingKey;
const collectionRatingKey =
customResultRatingKey || mediaResultRatingKey || configRatingKey;
logger.info('Checking overlay application for collection', {
label: `${this.source} Collections`,
collectionName: effectiveConfig.name,
configId: effectiveConfig.id,
applyOverlaysDuringSync: effectiveConfig.applyOverlaysDuringSync,
customResultRatingKey,
mediaResultRatingKey,
configRatingKey,
collectionRatingKey,
willApplyOverlays: !!(
effectiveConfig.applyOverlaysDuringSync && collectionRatingKey
),
});
if (effectiveConfig.applyOverlaysDuringSync && collectionRatingKey) {
try {
const { overlayLibraryService } = await import(
'@server/lib/overlays/OverlayLibraryService'
);
// Get item rating keys from this specific collection
const itemRatingKeys = await plexClient.getCollectionItems(
collectionRatingKey
);
if (itemRatingKeys && itemRatingKeys.length > 0) {
logger.info(
'Applying overlays to collection items after sync',
{
label: `${this.source} Collections`,
collectionName: effectiveConfig.name,
itemCount: itemRatingKeys.length,
}
);
// Apply overlays only to collection items
await overlayLibraryService.applyOverlaysToCollectionItems(
itemRatingKeys,
effectiveConfig.libraryId
);
}
} catch (overlayError) {
logger.error('Failed to apply overlays after sync', {
label: `${this.source} Collections`,
collectionName: effectiveConfig.name,
error:
overlayError instanceof Error
? overlayError.message
: String(overlayError),
});
// Don't fail the sync if overlay application fails
}
}
} catch (error) {
const syncError = this.createSyncError(
CollectionSyncErrorType.COLLECTION_ERROR,
`Failed to process configuration ${config.name}`,
{ configId: config.id, configName: config.name },
error instanceof Error ? error : new Error(String(error))
);
errors.push(syncError);
if (options?.onError) {
options.onError(syncError);
}
logger.error(syncError.message, {
label: `${this.source} Collections`,
...syncError.details,
});
}
}
// Collection processing completed silently
if (created > 0 || updated > 0) {
// Processing completed with changes
}
return {
created,
updated,
details: {
processingTime: Date.now() - startTime,
errors: errors.length,
},
};
} catch (error) {
const syncError = this.createSyncError(
CollectionSyncErrorType.CONFIGURATION_ERROR,
`Failed to process ${this.source} collections`,
{},
error instanceof Error ? error : new Error(String(error))
);
logger.error(syncError.message, {
label: `${this.source} Collections`,
error: syncError.details,
});
return { created: 0, updated: 0, error: syncError.message };
}
}
/**
* Filter collection configurations for this specific source
*/
protected filterConfigsForSource(
configs: CollectionConfig[]
): CollectionConfig[] {
return configs.filter((config) => config.type === this.source);
}
/**
* Create a standardized sync error
*/
protected createSyncError(
type: CollectionSyncErrorType,
message: string,
context: Record<string, unknown> = {},
originalError?: Error
): CollectionSyncError {
return createSyncError(type, message, context, originalError, this.source);
}
/**
* Generate collection name using template engine
*/
protected async generateCollectionName(
config: CollectionConfig,
mediaType: 'movie' | 'tv',
customTemplate?: string
): Promise<string> {
let templateToUse = customTemplate || config.template || config.name;
// Handle custom template selection - use the config template or name
if (templateToUse === 'custom') {
templateToUse = config.template || config.name;
}
const context = await this.createTemplateContext(config, mediaType);
return this.templateEngine.processTemplate(templateToUse, context);
}
/**
* Generate collection names with custom templates for movies/TV
*/
public async generateCollectionNameWithCustom(
config: CollectionConfig,
mediaType: 'movie' | 'tv',
// eslint-disable-next-line @typescript-eslint/no-unused-vars
libraryCache?: LibraryItemsCache
): Promise<string> {
const context = await this.createTemplateContext(config, mediaType);
// Handle special dynamic random title template
if (config.template === 'DYNAMIC_RANDOM_TITLE') {
// DYNAMIC_RANDOM_TITLE should be handled by each subclass in fetchSourceData
// Fall back to config.name if not handled
return config.name;
}
// Use custom templates only if template is set to 'custom', otherwise use main template
const template = (() => {
if (config.template === 'custom') {
return mediaType === 'movie'
? config.customMovieTemplate || config.name
: config.customTVTemplate || config.name;
}
return config.template || config.name;
})();
return this.templateEngine.processTemplate(template, context);
}
/**
* Process missing items with auto-request functionality
*/
protected async processAutoRequests(
missingItems: MissingItem[],
config: AutoRequestConfig & { id: number; name: string }
): Promise<AutoRequestResult> {
if (!config.searchMissingMovies && !config.searchMissingTV) {
return {
autoApproved: 0,
manualApproval: 0,
alreadyRequested: 0,
skipped: 0,
total: 0,
};
}
try {
// This would be implemented by subclasses that support auto-requests
// For now, return empty result
return {
autoApproved: 0,
manualApproval: 0,
alreadyRequested: 0,
skipped: 0,
total: missingItems.length,
};
} catch (error) {
logger.error(
`Failed to process auto-requests for ${config.name}: ${error}`,
{
label: `${this.source} Collections`,
}
);
return {
autoApproved: 0,
manualApproval: 0,
alreadyRequested: 0,
skipped: 0,
total: 0,
};
}
}
/**
* Store missing items in database for Quick Sync feature
* Replaces any existing missing items for this collection
*
* @param missingItems - Items not found in Plex during sync
* @param collectionId - Collection configuration ID
* @param libraryId - Plex library key (can be array for multi-library configs)
*/
protected async storeMissingItems(
missingItems: MissingItem[],
collectionRatingKey: string,
libraryId: string | string[],
configId: string
): Promise<void> {
try {
const { getRepository } = await import('@server/datasource');
const { CollectionMissingItems } = await import(
'@server/entity/CollectionMissingItems'
);
const repository = getRepository(CollectionMissingItems);
const timestamp = new Date();
// Handle both single and multi-library configs (use first library for storage)
const targetLibraryId = Array.isArray(libraryId)
? libraryId[0]
: libraryId;
// Delete existing entries for this collection (replace strategy)
await repository.delete({ collectionRatingKey });
// Insert new missing items
const entities = missingItems.map((item) => ({
collectionRatingKey,
configId,
libraryId: targetLibraryId,
tmdbId: item.tmdbId,
tvdbId: item.tvdbId,
mediaType: item.mediaType,
title: item.title,
year: item.year,
originalPosition: item.originalPosition,
source: item.source,
fullSyncTimestamp: timestamp,
}));
if (entities.length > 0) {
await repository.insert(entities);
logger.debug(`Stored ${entities.length} missing items for Quick Sync`, {
label: `${this.source} Collections`,
collectionRatingKey,
configId,
missingItemCount: entities.length,
});
}
} catch (error) {
// Don't fail the sync if storage fails - just log the error
logger.warn('Failed to store missing items for Quick Sync', {
label: `${this.source} Collections`,
collectionRatingKey,
configId,
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Helper to store missing items after collection creation (when rating key is available)
* Call this after creating/updating a collection to enable Quick Sync
*/
protected async storeCollectionMissingItems(
missingItems: MissingItem[] | undefined,
collectionRatingKey: string,
libraryId: string | string[],
configId: string
): Promise<void> {
if (!missingItems || missingItems.length === 0 || !collectionRatingKey) {
return;
}
await this.storeMissingItems(
missingItems,
collectionRatingKey,
libraryId,
configId
);
}
/**
* Tag existing items in Radarr/Sonarr with the collection's configured tags
* Only runs if tagExistingItems is enabled on the target Radarr/Sonarr server
*
* @param items - Collection items that exist in Plex
* @param config - Collection configuration
*/
protected async tagExistingItemsInArr(
items: CollectionItem[],
config: CollectionConfig
): Promise<void> {
// Only proceed if collection has tags configured
const downloadMode = config.downloadMode || 'overseerr';
const hasRadarrTags =
downloadMode === 'direct'
? (config.directDownloadRadarrTags?.length ?? 0) > 0
: (config.overseerrRadarrTags?.length ?? 0) > 0;
const hasSonarrTags =
downloadMode === 'direct'
? (config.directDownloadSonarrTags?.length ?? 0) > 0
: (config.overseerrSonarrTags?.length ?? 0) > 0;
if (!hasRadarrTags && !hasSonarrTags) {
return;
}
try {
const { existingItemTagService } = await import(
'../services/ExistingItemTagService'
);
await existingItemTagService.tagExistingItems(items, config, this.source);
} catch (error) {
// Log but don't fail the sync if tagging fails
logger.warn(
`Failed to tag existing items in Radarr/Sonarr for ${config.name}`,
{
label: `${this.source} Collections`,
error: error instanceof Error ? error.message : String(error),
}
);
}
}
/**
* Handle placeholder cleanup and process missing items in one step
* This combines the cleanup phase (remove old placeholders) with creation phase (add new ones)
*
* @param items - Items that exist in Plex
* @param missingItems - Items that don't exist in Plex
* @param config - Collection configuration
* @param plexClient - Plex API client
* @param libraryCache - Optional library cache for optimization
* @param autoRequestHandler - Optional function to call for auto-requests
* @returns Collection items created from placeholders
*/
protected async handlePlaceholdersAndMissingItems(
items: CollectionItem[],
missingItems: MissingItem[] | undefined,
config: CollectionConfig,
plexClient: PlexAPI,
libraryCache?: LibraryItemsCache,
autoRequestHandler?: () => Promise<void>
): Promise<CollectionItem[]> {
// Phase 1: Cleanup old placeholders (or delete all if setting disabled)
const sourceTmdbIds = new Set([
...items
.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number'),
...(missingItems
?.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number') || []),
]);
const { handlePlaceholderCleanup } = await import(
'@server/lib/placeholders/services/PlaceholderCleanup'
);
await handlePlaceholderCleanup(
config,
plexClient,
libraryCache,
sourceTmdbIds
);
// Phase 2: Create new placeholders for missing items
if (!missingItems || missingItems.length === 0) {
return [];
}
return this.processMissingItems(
missingItems,
config,
plexClient,
autoRequestHandler
);
}
/**
* Process missing items - create placeholders AND/OR send to auto-requests
* This is the main entry point for handling missing items in any collection type.
*
* Both features can be enabled simultaneously:
* - Placeholders show items in Plex immediately
* - Auto-requests download items when available
* - Cleanup removes placeholders when real files arrive
*
* @param missingItems - Items not found in Plex
* @param config - Collection configuration
* @param plexClient - Plex API client (required for placeholder creation)
* @param autoRequestHandler - Function to call for auto-requests
* @returns Collection items created from placeholders (empty array if not creating placeholders)
*/
protected async processMissingItems(
missingItems: MissingItem[],
config: CollectionConfig,
plexClient: PlexAPI,
autoRequestHandler?: () => Promise<void>
): Promise<CollectionItem[]> {
if (!missingItems || missingItems.length === 0) {
return [];
}
// NOTE: Missing items are now stored AFTER collection creation
// when we have the collectionRatingKey available.
// See storeCollectionMissingItems() calls after createOrUpdateCollection()
let placeholderItems: CollectionItem[] = [];
// Create placeholders if enabled
if (config.createPlaceholdersForMissing) {
// Apply missing item filters (rating, year, genre, etc.) before creating placeholders
const { missingItemFilterService, buildPlaceholderFilterConfig } =
await import('../services/MissingItemFilterService');
const placeholderFilterConfig = buildPlaceholderFilterConfig(config);
const { filteredItems } =
await missingItemFilterService.filterMissingItems(
missingItems,
placeholderFilterConfig,
`${this.source} Placeholder Filter`
);
// Import and use the PlaceholderCreation service
const { processPlaceholdersForMissingItems } = await import(
'@server/lib/placeholders/services/PlaceholderCreation'
);
logger.info('Creating placeholders for missing items', {
label: `${this.source} Collections`,
configName: config.name,
missingCount: filteredItems.length,
...(filteredItems.length !== missingItems.length && {
filteredOut: missingItems.length - filteredItems.length,
}),
});
placeholderItems = await processPlaceholdersForMissingItems(
filteredItems,
config,
plexClient
);
}
// Also send to auto-requests if handler provided (works alongside placeholders)
if (autoRequestHandler) {
await autoRequestHandler();
}
return placeholderItems;
}
/**
* Create filtering statistics
*/
protected createFilteringStats(
original: number,
filtered: number,
removalReasons?: Record<string, number>
): FilteringStats {
return {
original,
filtered,
removed: original - filtered,
removalReasons,
};
}
/**
* Update collection config with Plex rating key after collection operation
*/
protected updateConfigWithRatingKey(
config: CollectionConfig,
collectionRatingKey?: string
): void {
if (collectionRatingKey && config.id) {
// Extract library ID from config for multi-library support
// Handle both single string and array formats
const libraryId = Array.isArray(config.libraryId)
? config.libraryId[0]
: config.libraryId;
updateConfigWithRatingKey(config.id, collectionRatingKey, libraryId);
}
}
/**
* Validate and sanitize collection items before processing
*/
protected validateAndSanitizeItems(items: CollectionItem[]): {
validItems: CollectionItem[];
invalidItems: unknown[];
validationErrors: string[];
} {
return validateAndSanitizeItems(items, this.source);
}
/**
* Apply common filtering safety net (validation, deduplication, maxItems safety check)
*/
protected applyCommonFiltering(
items: CollectionItem[],
config: CollectionConfig
): {
filteredItems: CollectionItem[];
stats: FilteringStats;
} {
const originalCount = items.length;
const removalReasons: Record<string, number> = {};
// Remove invalid items (simple validation)
const validItems = items.filter((item) => {
if (!item?.ratingKey || !item?.title) {
removalReasons.invalid = (removalReasons.invalid || 0) + 1;
return false;
}
return true;
});
// Remove duplicates based on ratingKey
const uniqueItems = validItems.reduce((acc, item) => {
const existing = acc.find(
(existing) => existing.ratingKey === item.ratingKey
);
if (existing) {
removalReasons.duplicates = (removalReasons.duplicates || 0) + 1;
return acc;
}
return [...acc, item];
}, [] as CollectionItem[]);
// Remove globally excluded items
const settings = getSettings();
const exclusions = settings.globalExclusions;
const nonExcludedItems = uniqueItems.filter((item) => {
// Check movies (TMDB only)
if (item.type === 'movie') {
const tmdbId = item.tmdbId || (item.metadata?.tmdbId as number);
if (tmdbId && exclusions.movies.includes(tmdbId)) {
removalReasons.globalExclusion =
(removalReasons.globalExclusion || 0) + 1;
return false;
}
}
// Check TV shows (TMDB or TVDB)
if (item.type === 'tv') {
const tmdbId = item.tmdbId || (item.metadata?.tmdbId as number);
const tvdbId = item.metadata?.tvdbId as number | undefined;
// Check TMDB exclusions
if (
tmdbId &&
exclusions.shows.some(
(excluded) => excluded.type === 'tmdb' && excluded.id === tmdbId
)
) {
removalReasons.globalExclusion =
(removalReasons.globalExclusion || 0) + 1;
return false;
}
// Check TVDB exclusions
if (
tvdbId &&
exclusions.shows.some(
(excluded) => excluded.type === 'tvdb' && excluded.id === tvdbId
)
) {
removalReasons.globalExclusion =
(removalReasons.globalExclusion || 0) + 1;
return false;
}
}
return true;
});
// Apply maxItems safety check (most collection types should already be limited efficiently)
let finalItems = nonExcludedItems;
if (
config.maxItems &&
config.maxItems > 0 &&
nonExcludedItems.length > config.maxItems
) {
finalItems = nonExcludedItems.slice(0, config.maxItems);
removalReasons.safetyMaxItemsLimit =
nonExcludedItems.length - config.maxItems;
}
return {
filteredItems: finalItems,
stats: this.createFilteringStats(
originalCount,
finalItems.length,
removalReasons
),
};
}
/**
* Log collection processing results with standardized format
*/
protected logProcessingResults(
config: CollectionConfig,
result: CollectionOperationResult,
processingTime: number,
additionalContext?: Record<string, unknown>
): void {
logCollectionProcessingResults(
config.name,
result,
processingTime,
this.source,
config.id,
additionalContext
);
}
/**
* Handle rate limiting with exponential backoff
*/
protected async handleRateLimit(
attempt: number,
maxAttempts?: number
): Promise<void> {
return handleRateLimit(attempt, this.source, maxAttempts);
}
/**
* Create collection name with fallbacks and sanitization
*/
protected async createSanitizedCollectionName(
config: CollectionConfig,
mediaType: 'movie' | 'tv',
fallbackName?: string
): Promise<string> {
try {
const rawName = await this.generateCollectionName(config, mediaType);
return sanitizeCollectionName(rawName);
} catch (error) {
logger.warn(
`Failed to generate collection name for ${config.name}, using fallback`,
{
label: `${this.source} Collections`,
configId: config.id,
error: error instanceof Error ? error.message : String(error),
}
);
const fallback =
fallbackName || config.name || `${this.source} Collection`;
return sanitizeCollectionName(fallback);
}
}
/**
* Validate configuration with detailed error reporting
*/
protected validateConfigurationDetailed(
config: CollectionConfig,
requiredFields: string[]
): void {
const missingFields = validateRequiredFields(
config as unknown as Record<string, unknown>,
requiredFields
);
if (missingFields.length > 0) {
throw this.createSyncError(
CollectionSyncErrorType.CONFIGURATION_ERROR,
`Configuration validation failed for ${config.name}`,
{
configId: config.id,
missingFields,
providedFields: Object.keys(config),
}
);
}
}
/**
* Standardized collection creation/update using incremental approach
* This is the ONLY method that should be used for collection updates
*/
public async createOrUpdateCollectionStandardized(
items: CollectionItem[],
collectionName: string,
mediaType: 'movie' | 'tv',
config: CollectionConfig,
plexClient: PlexAPI,
allCollections: PlexCollection[],
processedCollectionKeys?: Set<string>,
userInfo?: { userId?: number | string; customLabel?: string },
missingItems?: MissingItem[]
): Promise<CollectionOperationResult> {
// Support user-specific collections for services like Overseerr
const customLabel =
userInfo?.customLabel ||
createCollectionLabel(
this.source,
config.id,
userInfo?.userId ? Number(userInfo.userId) : undefined
);
// Simplified collection update logic (moved from CollectionUpdateStrategy)
const updateResult = await this.createOrUpdateCollection(
plexClient,
allCollections,
items,
{
collectionName,
mediaType,
visibilityConfig: {
usersHome: config.visibilityConfig?.usersHome ?? false,
serverOwnerHome: config.visibilityConfig?.serverOwnerHome ?? false,
libraryRecommended:
config.visibilityConfig?.libraryRecommended ?? false,
isActive: config.isActive,
},
customLabel,
sortOrderLibrary: config.sortOrderLibrary,
isLibraryPromoted: config.isLibraryPromoted,
totalCollectionsInLibrary: (
config as CollectionConfig & { _totalCollectionsInLibrary?: number }
)._totalCollectionsInLibrary,
customPoster: config.customPoster,
processedCollectionKeys,
libraryKey: config.libraryId,
config,
}
);
// Update config with rating key if collection was created/updated
// Skip for multi-collection patterns (one config generates multiple collections)
const isMultiCollectionPattern =
(config.type === 'overseerr' && config.subtype === 'users') ||
(config.type === 'tmdb' && config.subtype === 'auto_franchise');
if (updateResult.collectionRatingKey && !isMultiCollectionPattern) {
this.updateConfigWithRatingKey(config, updateResult.collectionRatingKey);
}
// Store missing items for Quick Sync (now that we have collectionRatingKey)
if (updateResult.collectionRatingKey && missingItems) {
await this.storeCollectionMissingItems(
missingItems,
updateResult.collectionRatingKey,
config.libraryId,
config.id // Always store parent config ID, even for multi-collection patterns
);
}
// Auto-generate poster if enabled (available for all collection types)
// Default to true for existing collections that don't have this field set
const shouldGeneratePoster = config.autoPoster ?? true;
if (shouldGeneratePoster && updateResult.collectionRatingKey) {
await this.generateAutoPoster(
collectionName,
config,
updateResult.collectionRatingKey,
plexClient,
items,
userInfo
);
}
return {
created: updateResult.created,
updated: updateResult.updated,
collectionRatingKey: updateResult.collectionRatingKey,
itemCount: updateResult.itemCount,
stats: updateResult.updateStats,
};
}
/**
* Unified create or update collection method
* Simple, predictable pipeline for all collection operations
*/
private async createOrUpdateCollection(
plexClient: PlexAPI,
allCollections: PlexCollection[],
items: CollectionItem[],
options: CollectionUpdateOptions
): Promise<CollectionUpdateResult> {
const { collectionName, mediaType, customLabel } = options;
// Validate items first
const validation = validateCollectionItems(items);
if (validation.valid.length === 0) {
logger.warn(`No valid items for collection ${collectionName}`, {
label: 'Collection Update',
totalItems: items.length,
errors: validation.errors.slice(0, 5),
});
return { created: 0, updated: 0, itemCount: 0 };
}
const validItems = validation.valid;
const libraryKey = options.libraryKey;
// Filter items to only include those from the target library
const libraryFilteredItems = this.filterItemsByLibrary(
validItems,
libraryKey
);
if (libraryFilteredItems.length === 0) {
logger.warn(
`No items found in target library ${libraryKey} for collection ${collectionName}`,
{
label: 'Collection Update',
totalItems: validItems.length,
targetLibrary: libraryKey,
mediaType,
}
);
return { created: 0, updated: 0, itemCount: 0 };
}
const plexItems = await this.getValidPlexItems(
plexClient,
libraryFilteredItems
);
if (plexItems.length === 0) {
return { created: 0, updated: 0, itemCount: 0 };
}
// Check if any items are episodes to determine collection type
const containsEpisodes = libraryFilteredItems.some(
(item) => item.episodeInfo
);
// Check for existing collection
let existingCollection = await this.findExistingCollection(
plexClient,
customLabel,
libraryKey,
options.config
);
// BRANCH: Create EITHER smart collection OR regular collection, never both
const isOverseerrUsersCollection =
options.config?.type === 'overseerr' &&
options.config?.subtype === 'users';
const shouldCreateSmartCollection =
options.config?.showUnwatchedOnly && !isOverseerrUsersCollection;
let collectionRatingKey: string | undefined;
let created = 0;
let updated = 0;
if (shouldCreateSmartCollection && options.config) {
// PATH A: Create label-based smart collection (unwatched items only)
const labelName = `agregarr-unwatched-${options.config.id}`;
const itemRatingKeys = plexItems.map((item) => item.ratingKey);
logger.info(
`Creating label-based smart collection with ${itemRatingKeys.length} labeled items`,
{
label: 'Collection Creation',
collectionName,
labelName,
itemCount: itemRatingKeys.length,
}
);
// Label all items (new and existing)
for (const itemKey of itemRatingKeys) {
await plexClient.addLabelToItem(itemKey, labelName);
}
// CLEANUP: Remove labels from items that are no longer in the collection
// This ensures items that fall off the list are properly removed from the smart collection
try {
const currentlyLabeledItems = await plexClient.getItemsWithLabel(
libraryKey,
labelName
);
// Find items that have the label but are no longer in the new item list
const itemsToUnlabel = currentlyLabeledItems.filter(
(labeledItemKey) => !itemRatingKeys.includes(labeledItemKey)
);
if (itemsToUnlabel.length > 0) {
logger.info(
`Removing label from ${itemsToUnlabel.length} items no longer in collection`,
{
label: 'Collection Sync',
collectionName,
labelName,
itemsToUnlabel: itemsToUnlabel.length,
}
);
for (const itemKey of itemsToUnlabel) {
await plexClient.removeLabelFromItem(itemKey, labelName);
}
}
} catch (error) {
logger.warn(
`Failed to cleanup labels from removed items, smart collection may show stale items`,
{
label: 'Collection Sync',
collectionName,
error: error instanceof Error ? error.message : String(error),
}
);
// Don't fail the sync if label cleanup fails
}
// MIGRATION: Check for old dual-collection system (base + smart collection)
if (options.config.smartCollectionRatingKey) {
logger.info(
`Detected old dual-collection system - migrating to label-based system`,
{
label: 'Collection Migration',
collectionName,
oldSmartCollectionRatingKey:
options.config.smartCollectionRatingKey,
oldBaseCollectionRatingKey: options.config.collectionRatingKey,
}
);
// Step 1: Verify the old smart collection still exists
const oldSmartCollection = await plexClient.getCollectionMetadataSafe(
options.config.smartCollectionRatingKey
);
if (oldSmartCollection) {
// Step 2: Find and delete the old dash-prefixed base collection
if (options.config.collectionRatingKey) {
try {
const oldBaseCollection =
await plexClient.getCollectionMetadataSafe(
options.config.collectionRatingKey
);
if (
oldBaseCollection &&
oldBaseCollection.title.startsWith('-')
) {
logger.info(
`Deleting old dash-prefixed base collection: ${oldBaseCollection.title}`,
{
label: 'Collection Migration',
baseCollectionRatingKey: options.config.collectionRatingKey,
}
);
await plexClient.deleteCollection(
options.config.collectionRatingKey
);
// Clear existingCollection reference since we just deleted it
existingCollection = null;
}
} catch (error) {
logger.warn(
`Failed to delete old base collection, continuing migration`,
{
label: 'Collection Migration',
error: error instanceof Error ? error.message : String(error),
}
);
}
}
// Step 3: Update the old smart collection to use new label-based filters
collectionRatingKey = options.config.smartCollectionRatingKey;
logger.info(`Updating old smart collection to label-based filters`, {
label: 'Collection Migration',
smartCollectionRatingKey: collectionRatingKey,
});
await plexClient.updateLabelBasedSmartCollectionUri(
collectionRatingKey,
libraryKey,
labelName,
mediaType,
options.config.smartCollectionSort?.value,
options.config.maxItems
);
// Step 4: Migrate config - move smartCollectionRatingKey to collectionRatingKey
this.updateConfigWithRatingKey(options.config, collectionRatingKey);
this.clearSmartCollectionRatingKey(options.config);
logger.info(
`Successfully migrated dual-collection system to label-based system`,
{
label: 'Collection Migration',
collectionName,
migratedRatingKey: collectionRatingKey,
}
);
updated = 1;
} else {
logger.warn(
`Old smart collection not found, will create new label-based collection`,
{
label: 'Collection Migration',
oldSmartCollectionRatingKey:
options.config.smartCollectionRatingKey,
}
);
// Clear the invalid rating key and proceed to creation
this.clearSmartCollectionRatingKey(options.config);
collectionRatingKey = undefined;
}
}
// Check if smart collection already exists (skip if we just migrated)
if (!collectionRatingKey && existingCollection) {
// Check if it's actually a smart collection (smart=1 attribute)
const collectionMeta = await plexClient.getCollectionMetadata(
existingCollection.ratingKey
);
const isSmart =
collectionMeta &&
(collectionMeta as { smart?: string }).smart === '1';
if (isSmart) {
// Update existing smart collection
collectionRatingKey = existingCollection.ratingKey;
await plexClient.updateLabelBasedSmartCollectionUri(
collectionRatingKey,
libraryKey,
labelName,
mediaType,
options.config.smartCollectionSort?.value,
options.config.maxItems
);
updated = 1;
} else {
// MIGRATION: Old system had a regular collection, delete it and create smart
logger.info(
`Migrating from old system - deleting regular collection, will create smart collection`,
{
label: 'Collection Migration',
collectionName,
oldCollectionRatingKey: existingCollection.ratingKey,
}
);
await plexClient.deleteCollection(existingCollection.ratingKey);
collectionRatingKey = undefined; // Force creation below
}
}
if (!collectionRatingKey) {
// Create new smart collection
const newSmartCollectionRatingKey =
await plexClient.createLabelBasedSmartCollection(
collectionName,
libraryKey,
labelName,
mediaType,
options.config.smartCollectionSort?.value,
customLabel,
options.config.maxItems
);
if (!newSmartCollectionRatingKey) {
throw new Error(
`Failed to create smart collection ${collectionName}`
);
}
collectionRatingKey = newSmartCollectionRatingKey;
created = 1;
}
} else {
// PATH B: Create regular collection (normal flow)
// MIGRATION: If existing collection is a smart collection, delete it
// (user toggled showUnwatchedOnly from true to false)
if (existingCollection) {
const collectionMeta = await plexClient.getCollectionMetadata(
existingCollection.ratingKey
);
const isSmart =
collectionMeta &&
(collectionMeta as { smart?: string }).smart === '1';
if (isSmart) {
logger.info(
`User disabled showUnwatchedOnly - migrating from smart to regular collection`,
{
label: 'Collection Migration',
collectionName,
collectionRatingKey: existingCollection.ratingKey,
}
);
// Clean up: remove labels from items and delete smart collection
if (options.config) {
const labelName = `agregarr-unwatched-${options.config.id}`;
const labeledItems = await plexClient.getItemsWithLabel(
libraryKey,
labelName
);
if (labeledItems.length > 0) {
for (const itemKey of labeledItems) {
await plexClient.removeLabelFromItem(itemKey, labelName);
}
}
}
await plexClient.deleteCollection(existingCollection.ratingKey);
// Force creation of regular collection below
collectionRatingKey = undefined;
} else {
// UPDATE PATH: Collection exists (and is regular collection)
collectionRatingKey = existingCollection.ratingKey;
// Smart update: add new items, remove old ones
const updateResult = await plexClient.updateCollectionContents(
collectionRatingKey,
plexItems
);
// Label items that fell out of the collection as stale
if (updateResult.removedKeys.length > 0) {
for (const removedKey of updateResult.removedKeys) {
try {
await plexClient.addLabelToItem(removedKey, 'agregarr-stale');
} catch (error) {
logger.warn(
`Failed to add agregarr-stale label to item ${removedKey}`,
{
label: 'Collection Update',
error:
error instanceof Error ? error.message : String(error),
}
);
}
}
logger.info(
`Labeled ${updateResult.removedKeys.length} removed items as agregarr-stale in collection ${collectionName}`,
{ label: 'Collection Update' }
);
}
// Clean up stale labels for items still in this collection
const currentPlexKeys = new Set(
plexItems.map((item) => item.ratingKey)
);
const staleItems = await plexClient.getItemsWithLabel(
libraryKey,
'agregarr-stale'
);
for (const staleKey of staleItems) {
if (currentPlexKeys.has(staleKey)) {
try {
await plexClient.removeLabelFromItem(
staleKey,
'agregarr-stale'
);
} catch (error) {
logger.warn(
`Failed to remove agregarr-stale label from item ${staleKey}`,
{
label: 'Collection Update',
error:
error instanceof Error ? error.message : String(error),
}
);
}
}
}
updated = 1;
}
}
if (!collectionRatingKey) {
// CREATE PATH: New collection
const newCollectionRatingKey = await plexClient.createEmptyCollection(
collectionName,
libraryKey,
mediaType,
containsEpisodes
);
if (!newCollectionRatingKey) {
throw new Error(`Failed to create collection ${collectionName}`);
}
collectionRatingKey = newCollectionRatingKey;
// Add all items to the new collection
await plexClient.addItemsToCollection(collectionRatingKey, plexItems);
created = 1;
}
// For regular collections: Set custom sort and arrange items
await plexClient.updateCollectionContentSort(
collectionRatingKey,
'custom'
);
if (plexItems.length > 1) {
try {
await plexClient.arrangeCollectionItemsInOrder(
collectionRatingKey,
plexItems
);
} catch (error) {
logger.warn(
`Failed to arrange items in collection ${collectionName}`,
{
label: 'Collection Update',
error: error instanceof Error ? error.message : String(error),
}
);
}
}
}
// Ensure we have a collection rating key
if (!collectionRatingKey) {
throw new Error(`Failed to create or find collection ${collectionName}`);
}
// Apply metadata to the collection
await this.updateCollectionMetadata(
plexClient,
collectionRatingKey,
options
);
// Track processed collection
if (options.processedCollectionKeys) {
options.processedCollectionKeys.add(collectionRatingKey);
}
return {
created,
updated,
collectionRatingKey,
itemCount: plexItems.length,
};
}
/**
* Filter items to only include those from the specified library
* This prevents cross-library collection issues in Plex
*/
private filterItemsByLibrary(
items: CollectionItem[],
targetLibraryKey: string
): CollectionItem[] {
return items.filter((item) => {
// Check if item has library information
if (
item.metadata &&
typeof item.metadata === 'object' &&
item.metadata.libraryKey
) {
return item.metadata.libraryKey === targetLibraryKey;
}
// For items without library metadata, include them (they'll be filtered out later if invalid)
return true;
});
}
/**
* Helper methods for collection management
*/
private async findTargetLibrary(
plexClient: PlexAPI,
mediaType: 'movie' | 'tv'
): Promise<string> {
const libraries = await plexClient.getLibraries();
// Map mediaType to Plex library type: 'tv' -> 'show', 'movie' -> 'movie'
const plexLibraryType = mediaType === 'tv' ? 'show' : 'movie';
const targetLibrary = libraries.find((lib) => lib.type === plexLibraryType);
if (!targetLibrary) {
throw new Error(`No ${mediaType} library found`);
}
return targetLibrary.key;
}
private async findExistingCollection(
plexClient: PlexAPI,
customLabel: string,
libraryKey: string,
config?: CollectionConfig
): Promise<PlexCollection | null> {
try {
// First, try to find collection by stored ratingKey if available
// This is more reliable than label matching for all single collections
// Skip for multi-collection patterns (one config generates multiple collections)
const isMultiCollectionPattern =
(config?.type === 'overseerr' && config?.subtype === 'users') ||
(config?.type === 'tmdb' && config?.subtype === 'auto_franchise');
if (config?.collectionRatingKey && !isMultiCollectionPattern) {
try {
const existingByRatingKey = await plexClient.getCollectionMetadata(
config.collectionRatingKey
);
if (existingByRatingKey) {
// CRITICAL: Validate that the found collection is in the correct library
// This prevents linked configs from stealing each other's rating keys
const collectionLibraryKey =
existingByRatingKey.librarySectionID ||
existingByRatingKey.libraryKey;
if (String(collectionLibraryKey) !== String(libraryKey)) {
logger.debug(
`Stored ratingKey ${config.collectionRatingKey} found but in wrong library - falling back to label search`,
{
label: 'Base Collection Sync',
storedRatingKey: config.collectionRatingKey,
collectionTitle: existingByRatingKey.title,
collectionLibraryKey,
expectedLibraryKey: libraryKey,
configId: config.id,
configName: config.name,
}
);
} else {
logger.debug(
`Found existing collection by stored ratingKey: ${existingByRatingKey.title}`,
{
label: 'Base Collection Sync',
ratingKey: config.collectionRatingKey,
collectionTitle: existingByRatingKey.title,
collectionType: config.type,
collectionSubtype: config.subtype,
libraryKey: collectionLibraryKey,
}
);
return {
ratingKey: existingByRatingKey.ratingKey,
title: existingByRatingKey.title,
labels: existingByRatingKey.labels || [],
type: existingByRatingKey.type || 'collection',
};
}
}
} catch (error) {
logger.debug(
`Stored ratingKey ${config.collectionRatingKey} not found, falling back to label search`,
{
label: 'Base Collection Sync',
storedRatingKey: config.collectionRatingKey,
error: error instanceof Error ? error.message : String(error),
}
);
}
}
// Get collections only from the specific library where we would create new collections
interface PlexClientWithQuery {
plexClient: {
query<T>(options: {
uri: string;
extraHeaders?: Record<string, string>;
}): Promise<T>;
query<T>(path: string): Promise<T>;
};
}
const clientWithQuery = plexClient as unknown as PlexClientWithQuery;
const response = await clientWithQuery.plexClient.query<{
MediaContainer?: { Metadata?: PlexCollection[] };
}>({
uri: `/library/sections/${libraryKey}/collections`,
extraHeaders: {
'X-Plex-Container-Size': `0`,
},
});
const collections = response.MediaContainer?.Metadata || [];
// OPTIMIZATION: Fetch all collection metadata concurrently instead of sequentially
// This eliminates the N×API-call bottleneck when searching for existing collections
logger.debug(
`Searching for collection with label "${customLabel}" among ${collections.length} collections (concurrent fetch)`,
{
label: 'Base Collection Sync',
customLabel,
collectionsToSearch: collections.length,
libraryKey,
}
);
if (collections.length === 0) {
return null;
}
// Create concurrent metadata fetch promises for all collections
const metadataPromises = collections.map(async (collection) => {
try {
const detailedCollection = await plexClient.getCollectionMetadata(
collection.ratingKey
);
const labels = detailedCollection?.labels || [];
// Check if customLabel exists in labels array
// Labels can be strings or objects with 'tag' property
const found = labels.some((label: string | PlexLabel) => {
const labelText = typeof label === 'string' ? label : label.tag;
return labelText === customLabel;
});
return {
collection,
labels,
found,
error: null,
};
} catch (error) {
logger.debug(
`Failed to get metadata for collection ${collection.ratingKey}`,
{
label: 'Base Collection Sync',
collectionRatingKey: collection.ratingKey,
collectionTitle: collection.title,
error: error instanceof Error ? error.message : String(error),
}
);
return {
collection,
labels: [],
found: false,
error: error instanceof Error ? error.message : String(error),
};
}
});
// Wait for all metadata fetches to complete
const metadataResults = await Promise.allSettled(metadataPromises);
// Find the first collection that matches the label
// CRITICAL: Skip smart collections - we need the base collection for content updates
for (let i = 0; i < metadataResults.length; i++) {
const result = metadataResults[i];
if (result.status === 'fulfilled' && result.value.found) {
const matchedCollection = result.value;
// Check if this is a smart collection
const isSmartCollection =
(matchedCollection.collection as PlexCollectionWithSmart).smart ===
'1';
// Skip smart collections - we need the base collection for updating contents
if (isSmartCollection) {
logger.debug(
`Skipping smart collection with label "${customLabel}": ${matchedCollection.collection.title}`,
{
label: 'Base Collection Sync',
ratingKey: matchedCollection.collection.ratingKey,
reason:
'Smart collections cannot have contents updated, looking for base collection',
}
);
continue;
}
logger.debug(
`Found existing collection with label "${customLabel}": ${matchedCollection.collection.title}`,
{
label: 'Base Collection Sync',
foundCollection: matchedCollection.collection.title,
ratingKey: matchedCollection.collection.ratingKey,
labels: matchedCollection.labels,
}
);
return {
ratingKey: matchedCollection.collection.ratingKey,
title: matchedCollection.collection.title,
labels: matchedCollection.labels,
type: matchedCollection.collection.type || 'collection',
};
}
}
logger.debug(
`No existing collection found with label "${customLabel}" in library ${libraryKey}`,
{
label: 'Base Collection Sync',
customLabel,
libraryKey,
searchedCollections: collections.length,
}
);
// FALLBACK: Check for orphaned agregarr collection with matching title
if (config?.name) {
for (let i = 0; i < metadataResults.length; i++) {
const result = metadataResults[i];
if (result.status === 'fulfilled' && !result.value.found) {
const collection = result.value.collection;
const labels = result.value.labels;
// Check if this is an orphaned agregarr collection with matching title
const hasAgregarrLabel = labels.some((label: string) =>
label.toLowerCase().startsWith('agregarr')
);
if (collection.title === config.name && hasAgregarrLabel) {
// CRITICAL: Check if this is a smart collection vs base collection
const isSmartCollection =
(collection as PlexCollectionWithSmart).smart === '1';
logger.info(
`Found orphaned collection by title: "${collection.title}" - updating label`,
{
label: 'Base Collection Sync',
collectionTitle: collection.title,
ratingKey: collection.ratingKey,
collectionType: isSmartCollection ? 'smart' : 'regular',
oldLabels: labels,
newLabel: customLabel,
}
);
// Update the collection's label to match the new config ID
try {
await plexClient.addLabelToCollection(
collection.ratingKey,
customLabel
);
logger.info(`Updated collection label to match new config ID`);
} catch (labelError) {
logger.warn(`Failed to update collection label, continuing`, {
error:
labelError instanceof Error
? labelError.message
: String(labelError),
});
}
return {
ratingKey: collection.ratingKey,
title: collection.title,
labels: [customLabel],
type: collection.type || 'collection',
};
}
}
}
}
return null;
} catch (error) {
logger.error(
`Error finding existing collection in library ${libraryKey}`,
{
label: 'Collection Search',
libraryKey,
customLabel,
error: error instanceof Error ? error.message : String(error),
}
);
return null;
}
}
private async getValidPlexItems(
plexClient: PlexAPI,
items: CollectionItem[]
): Promise<PlexCollectionItem[]> {
return items.map((item) => ({
ratingKey: item.ratingKey,
title: item.title || 'Unknown Title',
}));
}
protected async updateCollectionMetadata(
plexClient: PlexAPI,
collectionRatingKey: string,
options: CollectionUpdateOptions
): Promise<void> {
const {
customLabel,
visibilityConfig,
sortOrderLibrary,
isLibraryPromoted,
customPoster,
collectionName,
libraryKey,
} = options;
// Add collection label
await plexClient.addLabelToCollection(collectionRatingKey, customLabel);
// Update collection title to reflect any template changes
if (collectionName) {
await plexClient.updateCollectionTitle(
collectionRatingKey,
collectionName,
libraryKey
);
}
// Update sort title if needed - for Agregarr-created collections
// Find the config to check everLibraryPromoted status
const settings = getSettings();
const allConfigs = settings.plex.collectionConfigs || [];
const matchingConfig = allConfigs.find((config) => {
const configLibraryId = Array.isArray(config.libraryId)
? config.libraryId[0]
: config.libraryId;
return (
configLibraryId === options.libraryKey &&
config.collectionRatingKey === collectionRatingKey
);
});
// Only update sortTitle if everLibraryPromoted is not explicitly false
if (
sortOrderLibrary !== undefined &&
matchingConfig?.everLibraryPromoted !== false
) {
let sortTitle: string;
const updateConfig: Partial<CollectionConfig> = {};
if (isLibraryPromoted && sortOrderLibrary > 0) {
// Promoted: Set exclamation marks
const sameLibraryConfigs = allConfigs.filter((config) => {
const configLibraryId = Array.isArray(config.libraryId)
? config.libraryId[0]
: config.libraryId;
return (
configLibraryId === options.libraryKey &&
config.sortOrderLibrary !== undefined &&
config.isLibraryPromoted === true
);
});
if (sameLibraryConfigs.length > 0) {
const sortOrders = sameLibraryConfigs
.map((c) => c.sortOrderLibrary)
.filter((order): order is number => order !== undefined);
const maxSortOrder = Math.max(...sortOrders);
const exclamationCount = maxSortOrder - sortOrderLibrary + 2;
const exclamationPrefix = '!'.repeat(exclamationCount);
sortTitle = `${exclamationPrefix}${collectionName}`;
} else {
sortTitle = `!!${collectionName}`;
}
} else {
// Demoted: Reset to natural title and mark as cleaned
sortTitle = collectionName;
// After reset, set everLibraryPromoted back to false
updateConfig.everLibraryPromoted = false;
}
await plexClient.updateCollectionSortTitle(
collectionRatingKey,
sortTitle
);
// Update config if everLibraryPromoted needs to be reset
if (updateConfig.everLibraryPromoted !== undefined && matchingConfig) {
this.updateCollectionConfigField(matchingConfig.id, updateConfig);
}
}
// Update visibility settings - but only promote to hub if collection has ANY visibility
if (visibilityConfig) {
const hasAnyVisibility =
visibilityConfig.usersHome ||
visibilityConfig.serverOwnerHome ||
visibilityConfig.libraryRecommended;
if (hasAnyVisibility) {
// Only call updateCollectionVisibility (which promotes to hub) if collection should be visible somewhere
await plexClient.updateCollectionVisibility(
collectionRatingKey,
visibilityConfig.libraryRecommended, // recommended (promotedToRecommended)
visibilityConfig.serverOwnerHome, // home (promotedToOwnHome)
visibilityConfig.usersHome // shared (promotedToSharedHome)
);
}
// Collections with false/false/false visibility remain as basic collections (not promoted to hubs)
}
// Update poster if provided
if (customPoster) {
let posterFilename: string | undefined;
if (typeof customPoster === 'string') {
// Legacy single poster
posterFilename = customPoster;
} else {
// Per-library poster mapping - get poster for current library
posterFilename = customPoster[options.libraryKey];
}
if (posterFilename) {
try {
// Get full path to poster file
const { getPosterPath } = await import('@server/lib/posterStorage');
const posterPath = getPosterPath(posterFilename);
// Check if poster needs reapplication using metadata tracking
const metadataService = (
await import('@server/lib/metadata/MetadataTrackingService')
).default;
let shouldUploadPoster = true;
try {
const currentPosterUrl = await plexClient.getCurrentPosterUrl(
collectionRatingKey
);
// Check if same poster file and URL matches (using filename as "input hash" for custom posters)
const repo = getRepository(CollectionMetadata);
const metadata = await repo.findOne({
where: { plexCollectionRatingKey: collectionRatingKey },
});
if (
metadata?.lastPosterInputHash === posterFilename &&
metadata?.lastPosterUploadUrl === currentPosterUrl
) {
logger.debug('Custom poster unchanged, skipping reupload', {
label: `${this.source} Collections`,
collectionName,
posterFilename,
});
shouldUploadPoster = false;
}
} catch (metaError) {
logger.warn('Metadata check failed, proceeding with upload', {
label: 'MetadataTracking',
error:
metaError instanceof Error
? metaError.message
: String(metaError),
});
// Fall through to upload
}
if (shouldUploadPoster) {
await plexClient.updateCollectionPoster(
collectionRatingKey,
posterPath
);
// Get new Plex URL after upload and record metadata
try {
const newPlexPosterUrl = await plexClient.getCurrentPosterUrl(
collectionRatingKey
);
if (newPlexPosterUrl) {
await metadataService.recordPosterApplication(
collectionRatingKey,
posterFilename, // Use filename as "input hash" for custom posters
newPlexPosterUrl,
{
configId: options.config?.id,
libraryKey: options.libraryKey,
posterLocalPath: posterFilename, // Track the local file path
}
);
}
} catch (metaError) {
logger.error(
'Failed to record poster metadata, upload succeeded',
{
label: 'MetadataTracking',
error:
metaError instanceof Error
? metaError.message
: String(metaError),
}
);
}
}
logger.debug(
`Successfully uploaded poster for collection ${collectionName}`,
{
label: `${this.source} Collections`,
collectionRatingKey,
posterFilename,
}
);
} catch (error) {
logger.warn(
`Failed to upload poster for collection ${collectionName}`,
{
label: `${this.source} Collections`,
collectionRatingKey,
posterFilename,
error: error instanceof Error ? error.message : String(error),
}
);
// Don't fail the entire collection sync if poster upload fails
}
}
}
// Update wallpaper/art if enabled and provided
const customWallpaper = options.config?.customWallpaper;
const enableCustomWallpaper =
options.config?.enableCustomWallpaper ?? false;
if (enableCustomWallpaper && customWallpaper) {
let wallpaperFilename: string | undefined;
if (typeof customWallpaper === 'string') {
// Legacy single wallpaper
wallpaperFilename = customWallpaper;
} else {
// Per-library wallpaper mapping - get wallpaper for current library
wallpaperFilename = customWallpaper[options.libraryKey];
}
if (wallpaperFilename) {
try {
// Get full path to wallpaper file
const { getWallpaperPath } = await import(
'@server/lib/wallpaperStorage'
);
const wallpaperPath = getWallpaperPath(wallpaperFilename);
// Check if wallpaper needs reapplication using metadata tracking
const metadataService = (
await import('@server/lib/metadata/MetadataTrackingService')
).default;
let shouldUploadWallpaper = true;
try {
const currentArtUrl = await plexClient.getCurrentArtUrl(
collectionRatingKey
);
const shouldReapply = await metadataService.shouldReapplyWallpaper(
collectionRatingKey,
wallpaperFilename,
currentArtUrl
);
if (!shouldReapply) {
logger.info('Wallpaper unchanged, skipping reupload', {
label: `${this.source} Collections`,
collectionName,
wallpaperFilename,
});
shouldUploadWallpaper = false;
}
} catch (metaError) {
logger.warn('Metadata check failed, proceeding with upload', {
label: 'MetadataTracking',
error:
metaError instanceof Error
? metaError.message
: String(metaError),
});
// Fall through to upload
}
if (shouldUploadWallpaper) {
await plexClient.uploadArtFromFile(
collectionRatingKey,
wallpaperPath
);
await plexClient.lockArt(collectionRatingKey);
// Get new Plex art URL after upload and record metadata
try {
const newArtUrl = await plexClient.getCurrentArtUrl(
collectionRatingKey
);
if (newArtUrl) {
await metadataService.recordWallpaperApplication(
collectionRatingKey,
wallpaperFilename,
newArtUrl,
{
configId: options.config?.id,
libraryKey: options.libraryKey,
}
);
}
} catch (metaError) {
logger.error(
'Failed to record wallpaper metadata, upload succeeded',
{
label: 'MetadataTracking',
error:
metaError instanceof Error
? metaError.message
: String(metaError),
}
);
}
}
logger.debug(
`Successfully uploaded wallpaper for collection ${collectionName}`,
{
label: `${this.source} Collections`,
collectionRatingKey,
wallpaperFilename,
}
);
} catch (error) {
logger.warn(
`Failed to upload wallpaper for collection ${collectionName}`,
{
label: `${this.source} Collections`,
collectionRatingKey,
wallpaperFilename,
error: error instanceof Error ? error.message : String(error),
}
);
// Don't fail the entire collection sync if wallpaper upload fails
}
}
}
// Update summary if enabled and provided
const customSummary = options.config?.customSummary;
const enableCustomSummary = options.config?.enableCustomSummary ?? false;
if (enableCustomSummary && customSummary) {
try {
await plexClient.updateSummary(collectionRatingKey, customSummary);
logger.debug(
`Successfully updated summary for collection ${collectionName}`,
{
label: `${this.source} Collections`,
collectionRatingKey,
}
);
} catch (error) {
logger.warn(
`Failed to update summary for collection ${collectionName}`,
{
label: `${this.source} Collections`,
collectionRatingKey,
error: error instanceof Error ? error.message : String(error),
}
);
// Don't fail the entire collection sync if summary update fails
}
}
// Update theme if enabled and provided
const customTheme = options.config?.customTheme;
const enableCustomTheme = options.config?.enableCustomTheme ?? false;
if (enableCustomTheme && customTheme) {
let themeFilename: string | undefined;
if (typeof customTheme === 'string') {
// Legacy single theme
themeFilename = customTheme;
} else {
// Per-library theme mapping - get theme for current library
themeFilename = customTheme[options.libraryKey];
}
if (themeFilename) {
try {
// Get full path to theme file
const { getThemePath } = await import('@server/lib/themeStorage');
const themePath = getThemePath(themeFilename);
// Check if theme needs reapplication using metadata tracking
const metadataService = (
await import('@server/lib/metadata/MetadataTrackingService')
).default;
let shouldUploadTheme = true;
try {
const currentThemeUrl = await plexClient.getCurrentThemeUrl(
collectionRatingKey
);
const shouldReapply = await metadataService.shouldReapplyTheme(
collectionRatingKey,
themeFilename,
currentThemeUrl
);
if (!shouldReapply) {
logger.info('Theme unchanged, skipping reupload', {
label: `${this.source} Collections`,
collectionName,
themeFilename,
});
shouldUploadTheme = false;
}
} catch (metaError) {
logger.warn('Metadata check failed, proceeding with upload', {
label: 'MetadataTracking',
error:
metaError instanceof Error
? metaError.message
: String(metaError),
});
// Fall through to upload
}
if (shouldUploadTheme) {
await plexClient.uploadThemeFromFile(
collectionRatingKey,
themePath
);
await plexClient.lockTheme(collectionRatingKey);
// Get new Plex theme URL after upload and record metadata
try {
const newThemeUrl = await plexClient.getCurrentThemeUrl(
collectionRatingKey
);
if (newThemeUrl) {
await metadataService.recordThemeApplication(
collectionRatingKey,
themeFilename,
newThemeUrl,
{
configId: options.config?.id,
libraryKey: options.libraryKey,
}
);
}
} catch (metaError) {
logger.error(
'Failed to record theme metadata, upload succeeded',
{
label: 'MetadataTracking',
error:
metaError instanceof Error
? metaError.message
: String(metaError),
}
);
}
}
logger.debug(
`Successfully uploaded theme for collection ${collectionName}`,
{
label: `${this.source} Collections`,
collectionRatingKey,
themeFilename,
}
);
} catch (error) {
logger.warn(
`Failed to upload theme for collection ${collectionName}`,
{
label: `${this.source} Collections`,
collectionRatingKey,
themeFilename,
error: error instanceof Error ? error.message : String(error),
}
);
// Don't fail the entire collection sync if theme upload fails
}
}
}
}
/**
* Update the isActive status for a collection config
*/
private updateConfigActiveStatus(configId: string, isActive: boolean): void {
try {
const settings = getSettings();
const collectionConfigs = settings.plex.collectionConfigs || [];
const configIndex = collectionConfigs.findIndex((c) => c.id === configId);
if (configIndex === -1) {
logger.warn(
`Config ${configId} not found when updating active status`,
{
label: `${this.source} Collections`,
configId,
isActive,
}
);
return;
}
// Update the config
const updatedConfig = { ...collectionConfigs[configIndex], isActive };
collectionConfigs[configIndex] = updatedConfig;
// Save the updated settings
settings.plex.collectionConfigs = collectionConfigs;
settings.save();
logger.debug(
`Updated collection ${configId} active status to ${isActive}`,
{
label: `${this.source} Collections`,
configId,
isActive,
}
);
} catch (error) {
logger.error(`Failed to update active status for config ${configId}`, {
label: `${this.source} Collections`,
configId,
isActive,
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Update specific fields of a collection config
*/
protected updateCollectionConfigField(
configId: string,
updateConfig: Partial<CollectionConfig>
): void {
try {
const settings = getSettings();
const collectionConfigs = settings.plex.collectionConfigs || [];
const configIndex = collectionConfigs.findIndex((c) => c.id === configId);
if (configIndex !== -1) {
collectionConfigs[configIndex] = {
...collectionConfigs[configIndex],
...updateConfig,
};
settings.plex.collectionConfigs = collectionConfigs;
settings.save();
logger.debug(`Updated collection config fields: ${configId}`, {
label: `${this.source} Collections`,
configId,
updatedFields: Object.keys(updateConfig),
});
}
} catch (error) {
logger.error(
`Failed to update collection config fields for ${configId}`,
{
label: `${this.source} Collections`,
configId,
error: error instanceof Error ? error.message : String(error),
}
);
}
}
/**
* Clear the legacy smartCollectionRatingKey field from config (migration cleanup)
*/
protected clearSmartCollectionRatingKey(config: CollectionConfig): void {
try {
const settings = getSettings();
const collectionConfigs = settings.plex.collectionConfigs || [];
const configIndex = collectionConfigs.findIndex(
(c) => c.id === config.id
);
if (configIndex !== -1) {
const updatedConfig = {
...collectionConfigs[configIndex],
};
// Remove the legacy field
delete (updatedConfig as { smartCollectionRatingKey?: string })
.smartCollectionRatingKey;
collectionConfigs[configIndex] = updatedConfig;
settings.plex.collectionConfigs = collectionConfigs;
settings.save();
logger.debug(
`Cleared legacy smartCollectionRatingKey from config ${config.id}`,
{
label: `${this.source} Collections`,
configId: config.id,
}
);
}
} catch (error) {
logger.error(
`Failed to clear smartCollectionRatingKey from config ${config.id}`,
{
label: `${this.source} Collections`,
configId: config.id,
error: error instanceof Error ? error.message : String(error),
}
);
}
}
// Abstract methods that must be implemented by subclasses
/**
* Validate that the source is properly configured
* (e.g., API keys are present, services are reachable)
*/
protected abstract validateConfiguration(): Promise<void>;
/**
* Process a single collection configuration
* @param config - Collection configuration
* @param plexClient - Plex API client
* @param allCollections - All existing Plex collections
* @param processedCollectionKeys - Set to track processed collection keys
* @param libraryCache - Pre-fetched library items cache for optimization
* @param options - Additional sync options
*/
protected abstract processConfiguration(
config: CollectionConfig,
plexClient: PlexAPI,
allCollections: PlexCollection[],
processedCollectionKeys?: Set<string>,
libraryCache?: LibraryItemsCache,
options?: CollectionSyncOptions
): Promise<SyncResult>;
/**
* Create template context specific to this source
*/
protected abstract createTemplateContext(
config: CollectionConfig,
mediaType: 'movie' | 'tv'
): Promise<SourceTemplateContext>;
/**
* Generate a stable cache key for a collection configuration
* Used to cache list data between syncs for fast preview
*/
protected generateCacheKey(config: CollectionConfig): string {
const parts: string[] = [
this.source,
config.type,
config.subtype || '',
config.libraryId || '',
];
// Add type-specific identifiers for unique caching
if (config.traktCustomListUrl) parts.push(config.traktCustomListUrl);
if (config.imdbCustomListUrl) parts.push(config.imdbCustomListUrl);
if (config.letterboxdCustomListUrl)
parts.push(config.letterboxdCustomListUrl);
if (config.tmdbCustomCollectionUrl)
parts.push(config.tmdbCustomCollectionUrl);
if (config.mdblistCustomListUrl) parts.push(config.mdblistCustomListUrl);
if (config.anilistCustomListUrl) parts.push(config.anilistCustomListUrl);
if (config.timePeriod) parts.push(config.timePeriod);
if (config.customDays) parts.push(String(config.customDays));
if (config.minimumPlays) parts.push(String(config.minimumPlays));
if (config.networksCountry) parts.push(config.networksCountry);
return parts.join(':');
}
/**
* Get the appropriate cache ID for this source type
*/
protected getCacheId():
| 'trakt-list'
| 'imdb-list'
| 'letterboxd-list'
| 'tmdb-list'
| 'mdblist-list'
| 'tautulli-list'
| 'overseerr-list'
| 'networks-list'
| 'originals-list'
| 'anilist-list'
| 'myanimelist-list' {
const cacheIdMap: Partial<Record<CollectionSource, string>> = {
trakt: 'trakt-list',
imdb: 'imdb-list',
letterboxd: 'letterboxd-list',
tmdb: 'tmdb-list',
mdblist: 'mdblist-list',
tautulli: 'tautulli-list',
overseerr: 'overseerr-list',
networks: 'networks-list',
originals: 'originals-list',
anilist: 'anilist-list',
myanimelist: 'myanimelist-list',
// Note: multi-source doesn't have its own cache, it uses individual source caches
};
return (cacheIdMap[this.source] || 'trakt-list') as
| 'trakt-list'
| 'imdb-list'
| 'letterboxd-list'
| 'tmdb-list'
| 'mdblist-list'
| 'tautulli-list'
| 'overseerr-list'
| 'networks-list'
| 'originals-list'
| 'anilist-list'
| 'myanimelist-list';
}
/**
* Wrapper around fetchSourceData that handles caching
* - During sync: Always fetches fresh data and caches it
* - During preview with useCache=true: Returns cached data if available
* - During preview refresh: Fetches fresh and updates cache
*/
public async fetchSourceDataWithCache(
config: CollectionConfig,
options?: CollectionSyncOptions & { useCache?: boolean },
libraryCache?: LibraryItemsCache
): Promise<CollectionSourceData[]> {
const cacheKey = this.generateCacheKey(config);
const cache = cacheManager.getCache(this.getCacheId());
const useCache = options?.useCache ?? false;
// Try to use cached data if requested
if (useCache) {
const cachedData = cache.data.get<CollectionSourceData[]>(cacheKey);
if (cachedData) {
logger.debug(
`Using cached list data for ${config.name} (${this.source})`,
{
label: `${this.source} Collections Cache`,
configId: config.id,
configName: config.name,
cacheKey,
}
);
return cachedData;
}
logger.debug(
`No cached data found for ${config.name}, fetching fresh data`,
{
label: `${this.source} Collections Cache`,
configId: config.id,
configName: config.name,
cacheKey,
}
);
}
// Fetch fresh data from external source
const freshData = await this.fetchSourceData(config, options, libraryCache);
// Cache the fresh data for future preview use
if (freshData && freshData.length > 0) {
cache.data.set(cacheKey, freshData);
logger.debug(`Cached list data for ${config.name} (${this.source})`, {
label: `${this.source} Collections Cache`,
configId: config.id,
configName: config.name,
cacheKey,
itemCount: freshData.length,
});
}
return freshData;
}
/**
* Fetch data from the external source (Trakt API, Tautulli API, etc.)
* This method should be implemented by each source to fetch fresh data
*/
public abstract fetchSourceData(
config: CollectionConfig,
options?: CollectionSyncOptions,
libraryCache?: LibraryItemsCache
): Promise<CollectionSourceData[]>;
/**
* Map source data to standardized CollectionItem format
* @param sourceData - Raw data from external source
* @param config - Collection configuration
* @param plexClient - Optional Plex API client for lookups
* @param libraryCache - Optional pre-fetched library items cache for performance optimization
*/
public abstract mapSourceDataToItems(
sourceData: CollectionSourceData[],
config: CollectionConfig,
plexClient?: PlexAPI,
libraryCache?: LibraryItemsCache
): Promise<{
items: CollectionItem[];
missingItems?: MissingItem[];
stats?: FilteringStats;
}>;
/**
* Create collection in Plex using the standardized pipeline
*/
protected abstract createCollection(
items: CollectionItem[],
mediaType: 'movie' | 'tv',
collectionName: string,
plexClient: PlexAPI,
allCollections: PlexCollection[],
config: CollectionConfig,
processedCollectionKeys?: Set<string>
): Promise<CollectionOperationResult>;
/**
* Fetch IMDb ratings for collection items and enrich them
* For items without IMDb IDs, attempts to fetch them from TMDB first
*
* @param items - Collection items to enrich
* @returns Promise resolving to enriched collection items with IMDb ratings
*/
protected async enrichItemsWithImdbRatings(
items: CollectionItem[]
): Promise<CollectionItem[]> {
try {
const { default: TheMovieDb } = await import('@server/api/themoviedb');
const tmdb = new TheMovieDb();
// Step 1: Identify items that need IMDb ID resolution from TMDB
const itemsNeedingImdbIds = items.filter(
(item) => !item.imdbId && item.tmdbId
);
let enrichedItemsWithImdbIds = [...items];
if (itemsNeedingImdbIds.length > 0) {
logger.debug(
`Fetching IMDb IDs from TMDB for ${itemsNeedingImdbIds.length} items`,
{
label: `${this.source} Collections`,
}
);
// Fetch external IDs from TMDB in batches to avoid rate limiting
const batchSize = 10;
const tmdbIdToImdbIdMap = new Map<number, string>();
for (let i = 0; i < itemsNeedingImdbIds.length; i += batchSize) {
const batch = itemsNeedingImdbIds.slice(i, i + batchSize);
await Promise.all(
batch.map(async (item) => {
try {
if (!item.tmdbId) return;
const details =
item.type === 'movie'
? await tmdb.getMovie({ movieId: item.tmdbId })
: await tmdb.getTvShow({ tvId: item.tmdbId });
const imdbId = details.external_ids?.imdb_id;
if (imdbId) {
tmdbIdToImdbIdMap.set(item.tmdbId, imdbId);
}
} catch (err) {
// Silently skip items that fail - don't block the entire operation
logger.debug(
`Failed to fetch TMDB external IDs for ${item.title}`,
{
label: `${this.source} Collections`,
tmdbId: item.tmdbId,
}
);
}
})
);
// Small delay between batches to respect rate limits
if (i + batchSize < itemsNeedingImdbIds.length) {
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
logger.debug(`Resolved ${tmdbIdToImdbIdMap.size} IMDb IDs from TMDB`, {
label: `${this.source} Collections`,
requested: itemsNeedingImdbIds.length,
resolved: tmdbIdToImdbIdMap.size,
});
// Enrich items with the fetched IMDb IDs
enrichedItemsWithImdbIds = items.map((item) => {
if (item.imdbId || !item.tmdbId) return item;
const imdbId = tmdbIdToImdbIdMap.get(item.tmdbId);
if (imdbId) {
return { ...item, imdbId };
}
return item;
});
}
// Step 2: Extract all IMDb IDs (original + newly resolved)
const imdbIds = enrichedItemsWithImdbIds
.map((item) => item.imdbId)
.filter((id): id is string => !!id);
if (imdbIds.length === 0) {
logger.debug(
'No IMDb IDs found after TMDB resolution, skipping rating fetch',
{
label: `${this.source} Collections`,
totalItems: items.length,
}
);
return items;
}
// Step 3: Fetch ratings via IMDb API (handles batching automatically)
const imdbApi = new ImdbRatingsAPI();
const ratings = await imdbApi.getRatings(imdbIds);
// Create lookup map for fast access
const ratingsMap = new Map(
ratings
.filter((r) => r.rating !== null)
.map((r) => [r.imdbId, r.rating as number])
);
// Step 4: Enrich items with ratings
const finalEnrichedItems = enrichedItemsWithImdbIds.map((item) => {
if (!item.imdbId) return item;
const rating = ratingsMap.get(item.imdbId);
return {
...item,
imdbRating: rating,
};
});
const itemsEnriched = finalEnrichedItems.filter(
(i) => i.imdbRating !== undefined
).length;
logger.debug(
`Enriched ${itemsEnriched}/${items.length} items with IMDb ratings`,
{
label: `${this.source} Collections`,
totalItems: items.length,
itemsWithImdbIds: imdbIds.length,
ratingsRetrieved: ratings.filter((r) => r.rating !== null).length,
itemsEnriched,
}
);
return finalEnrichedItems;
} catch (error) {
logger.error('Failed to enrich items with IMDb ratings:', {
label: `${this.source} Collections`,
error: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
});
// Return original items on error - don't fail the entire sync
return items;
}
}
/**
* Apply item ordering options to collection items
* This is applied BEFORE filtering to ensure the desired order is preserved through the pipeline
*
* @param items - Collection items to order
* @param config - Collection configuration with ordering options
* @returns Ordered collection items
*/
private applyItemOrdering(
items: CollectionItem[],
config: CollectionConfig
): CollectionItem[] {
const sortOrder = config.sortOrder ?? 'default';
switch (sortOrder) {
case 'random': {
// Fisher-Yates shuffle algorithm for true randomization
const shuffled = [...items];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
logger.debug(`Applied randomization to ${shuffled.length} items`, {
label: `${this.source} Collections`,
collection: config.name,
});
return shuffled;
}
case 'reverse': {
const reversed = [...items].reverse();
logger.debug(`Applied reverse order to ${reversed.length} items`, {
label: `${this.source} Collections`,
collection: config.name,
});
return reversed;
}
case 'imdb_rating_desc':
case 'imdb_rating_asc': {
// Sort by IMDb rating
const sorted = [...items].sort((a, b) => {
const ratingA = a.imdbRating ?? -1; // Items without rating go to end
const ratingB = b.imdbRating ?? -1;
if (sortOrder === 'imdb_rating_desc') {
return ratingB - ratingA; // Highest to lowest
} else {
return ratingA - ratingB; // Lowest to highest
}
});
const itemsWithRatings = sorted.filter(
(i) => i.imdbRating !== undefined
).length;
logger.debug(
`Applied IMDb rating sort (${sortOrder}) to ${sorted.length} items`,
{
label: `${this.source} Collections`,
collection: config.name,
itemsWithRatings,
itemsWithoutRatings: sorted.length - itemsWithRatings,
}
);
return sorted;
}
case 'release_date_desc':
case 'release_date_asc': {
// Sort by release date
const sorted = [...items].sort((a, b) => {
const dateA = a.releaseDate ?? 0; // Items without date go to end
const dateB = b.releaseDate ?? 0;
if (sortOrder === 'release_date_desc') {
return dateB - dateA; // Newest to oldest
} else {
return dateA - dateB; // Oldest to newest
}
});
const itemsWithDates = sorted.filter(
(i) => i.releaseDate !== undefined
).length;
logger.debug(
`Applied release date sort (${sortOrder}) to ${sorted.length} items`,
{
label: `${this.source} Collections`,
collection: config.name,
itemsWithDates,
itemsWithoutDates: sorted.length - itemsWithDates,
}
);
return sorted;
}
case 'date_added_desc':
case 'date_added_asc': {
// Sort by date added to Plex
const sorted = [...items].sort((a, b) => {
const dateA = a.addedAt ?? 0; // Items without date go to end
const dateB = b.addedAt ?? 0;
if (sortOrder === 'date_added_desc') {
return dateB - dateA; // Most recently added first
} else {
return dateA - dateB; // Least recently added first
}
});
const itemsWithDates = sorted.filter(
(i) => i.addedAt !== undefined
).length;
logger.debug(
`Applied date added sort (${sortOrder}) to ${sorted.length} items`,
{
label: `${this.source} Collections`,
collection: config.name,
itemsWithDates,
itemsWithoutDates: sorted.length - itemsWithDates,
}
);
return sorted;
}
case 'alphabetical_asc':
case 'alphabetical_desc': {
// Sort alphabetically by title
const sorted = [...items].sort((a, b) => {
const titleA = a.title.toLowerCase();
const titleB = b.title.toLowerCase();
if (sortOrder === 'alphabetical_asc') {
return titleA.localeCompare(titleB); // A-Z
} else {
return titleB.localeCompare(titleA); // Z-A
}
});
logger.debug(
`Applied alphabetical sort (${sortOrder}) to ${sorted.length} items`,
{
label: `${this.source} Collections`,
collection: config.name,
}
);
return sorted;
}
case 'default':
default:
// No ordering, return original
return items;
}
}
/**
* Apply filtering safety net to already-mapped items (validation, deduplication, maxItems safety check)
* Use this after calling your specific mapSourceDataToItems implementation.
*/
public async applyFilteringToMappedItems(
mappedResult: {
items: CollectionItem[];
missingItems?: MissingItem[];
stats?: FilteringStats;
},
config: CollectionConfig
): Promise<{
items: CollectionItem[];
missingItems?: MissingItem[];
mappingStats?: FilteringStats;
filteringStats?: FilteringStats;
}> {
let items = mappedResult.items;
// STEP 1: Enrich with IMDb ratings if needed for sorting
if (
config.sortOrder === 'imdb_rating_desc' ||
config.sortOrder === 'imdb_rating_asc'
) {
items = await this.enrichItemsWithImdbRatings(items);
}
// STEP 2: Apply ordering (reverse/randomize/imdb rating) before filtering
// This ensures the desired order affects the full result before maxItems is applied
const orderedItems = this.applyItemOrdering(items, config);
// STEP 3: Apply common filtering (duplicates, maxItems limit, etc.)
const { filteredItems, stats: filteringStats } = this.applyCommonFiltering(
orderedItems,
config
);
// Filter missing items by global exclusions and maxItems limit
let filteredMissingItems = mappedResult.missingItems;
if (filteredMissingItems && filteredMissingItems.length > 0) {
const settings = getSettings();
const exclusions = settings.globalExclusions;
// Remove globally excluded items from missing items
filteredMissingItems = filteredMissingItems.filter((item) => {
// Check movies (TMDB only)
if (item.mediaType === 'movie') {
if (exclusions.movies.includes(item.tmdbId)) {
return false;
}
}
// Check TV shows (TMDB or TVDB)
if (item.mediaType === 'tv') {
// Check TMDB exclusions
if (
item.tmdbId &&
exclusions.shows.some(
(excluded) =>
excluded.type === 'tmdb' && excluded.id === item.tmdbId
)
) {
return false;
}
// Check TVDB exclusions (if item has tvdbId)
const tvdbId = (item as { tvdbId?: number }).tvdbId;
if (
tvdbId &&
exclusions.shows.some(
(excluded) => excluded.type === 'tvdb' && excluded.id === tvdbId
)
) {
return false;
}
}
return true;
});
// Filter by maxItems limit
// This ensures we only create requests for missing items from the first maxItems positions
if (config.maxItems && config.maxItems > 0) {
filteredMissingItems = filteredMissingItems.filter(
(item) => item.originalPosition <= config.maxItems
);
}
}
return {
items: filteredItems,
missingItems: filteredMissingItems,
mappingStats: mappedResult.stats,
filteringStats,
};
}
/**
* Evaluate time restrictions for a collection configuration
*
* @param config - Collection configuration to evaluate
* @returns TimeRestrictionResult indicating if collection should be active
*/
protected evaluateTimeRestriction(
config: CollectionConfig
): TimeRestrictionResult {
return TimeRestrictionUtils.evaluateTimeRestriction(config.timeRestriction);
}
/**
* Handle collections that are currently inactive due to time restrictions
* This removes the collection from Plex if it exists
*
* @param config - Collection configuration
* @param plexClient - Plex API client
* @param allCollections - All collections from Plex
* @param processedCollectionKeys - Set to track processed collection keys
*/
protected async handleInactiveCollection(
config: CollectionConfig,
plexClient: PlexAPI,
allCollections: PlexCollection[],
processedCollectionKeys?: Set<string>
): Promise<void> {
try {
// Find the collection by stored rating key
const existingCollections = config.collectionRatingKey
? allCollections.filter(
(collection) => collection.ratingKey === config.collectionRatingKey
)
: [];
// This method is only called when removeFromPlexWhenInactive is true
// So we only need the original deletion behavior
for (const collection of existingCollections) {
try {
logger.info(
`Removing time-restricted collection: ${collection.title}`,
{
label: `${this.source} Collections`,
configId: config.id,
configName: config.name,
collectionId: collection.ratingKey,
}
);
// Remove the collection from Plex
await plexClient.deleteCollection(collection.ratingKey);
// Also remove from hub management to prevent stale hub entries
if (collection.libraryKey) {
const hubIdentifier = `custom.collection.${collection.libraryKey}.${collection.ratingKey}`;
try {
await plexClient.deleteHubItem(
collection.libraryKey,
hubIdentifier
);
logger.debug(
`Removed collection from hub management: ${collection.title}`,
{
label: `${this.source} Collections`,
configId: config.id,
hubIdentifier,
}
);
} catch (error) {
// Log as warning - hub item may already be deleted or never existed
logger.warn(
`Could not remove from hub management (may already be deleted): ${collection.title}`,
{
label: `${this.source} Collections`,
configId: config.id,
hubIdentifier,
error:
error instanceof Error ? error.message : 'Unknown error',
}
);
}
} else {
logger.warn(
`Cannot remove from hub management - collection has no libraryKey: ${collection.title}`,
{
label: `${this.source} Collections`,
configId: config.id,
collectionRatingKey: collection.ratingKey,
}
);
}
// Note: We no longer clear the rating key here because the smart missing item detection
// in DiscoveryService now properly handles inactive collections with removeFromPlexWhenInactive
// Mark as processed to avoid conflicts
if (processedCollectionKeys) {
processedCollectionKeys.add(collection.ratingKey);
}
} catch (error) {
logger.warn(
`Failed to remove time-restricted collection ${collection.title}: ${error}`,
{
label: `${this.source} Collections`,
configId: config.id,
error: error instanceof Error ? error.message : 'Unknown error',
}
);
}
}
} catch (error) {
logger.warn(
`Failed to handle inactive collection for ${config.name}: ${error}`,
{
label: `${this.source} Collections`,
configId: config.id,
error: error instanceof Error ? error.message : 'Unknown error',
}
);
}
}
/**
* Process collections with simple media type handling
* Replaces over-engineered strategy pattern with straightforward if/else logic
*/
protected async processWithMediaTypeStrategy(
items: CollectionItem[],
config: CollectionConfig,
plexClient: PlexAPI,
allCollections: PlexCollection[],
processedCollectionKeys?: Set<string>,
userInfo?: { userId?: number | string; customLabel?: string },
libraryCache?: LibraryItemsCache,
missingItems?: MissingItem[]
): Promise<MediaProcessingResult> {
const mediaType = getCollectionMediaType(config);
try {
// Simple single media type processing - 'both' was over-engineered
// Each collection is tied to a specific library (movie OR tv), not both
return await this.processSingleMediaType(
items,
config,
mediaType,
plexClient,
allCollections,
processedCollectionKeys,
userInfo,
libraryCache,
missingItems
);
} catch (error) {
logger.error(`Media type processing failed`, {
label: `${this.source} Collections`,
configName: config.name,
mediaType,
error: error instanceof Error ? error.message : String(error),
});
return {
created: 0,
updated: 0,
itemCount: 0,
collectionKeys: [],
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Process single media type collections (movie OR tv)
*/
private async processSingleMediaType(
items: CollectionItem[],
config: CollectionConfig,
mediaType: 'movie' | 'tv',
plexClient: PlexAPI,
allCollections: PlexCollection[],
processedCollectionKeys?: Set<string>,
userInfo?: { userId?: number | string; customLabel?: string },
libraryCache?: LibraryItemsCache,
missingItems?: MissingItem[]
): Promise<MediaProcessingResult> {
// Filter items by the specified media type
let filteredItems = items.filter((item) => item.type === mediaType);
if (filteredItems.length === 0) {
logger.debug(`No ${mediaType} items found for collection`, {
label: `${this.source} Collections`,
configName: config.name,
mediaType,
totalItems: items.length,
});
return {
created: 0,
updated: 0,
itemCount: 0,
collectionKeys: [],
};
}
// Apply collection mutual exclusion if configured
filteredItems = await applyCollectionExclusions(
filteredItems,
config,
plexClient,
this.source
);
// Check again if we have items after exclusions
if (filteredItems.length === 0) {
logger.info(
`All ${mediaType} items were excluded from collection "${config.name}" based on mutual exclusion rules`,
{
label: `${this.source} Collections`,
configName: config.name,
mediaType,
}
);
return {
created: 0,
updated: 0,
itemCount: 0,
collectionKeys: [],
};
}
const collectionName =
(await this.generateCollectionNameWithCustom?.(
config,
mediaType,
libraryCache
)) ||
config.template ||
config.name;
const result = await this.createOrUpdateCollectionStandardized(
filteredItems,
collectionName,
mediaType,
config,
plexClient,
allCollections,
processedCollectionKeys,
userInfo,
missingItems
);
return {
created: result.created || 0,
updated: result.updated || 0,
itemCount: result.itemCount || 0,
collectionKeys: result.collectionRatingKey
? [result.collectionRatingKey]
: [],
error: result.error,
};
}
/**
* Generate poster automatically for collections during sync
* Called for any collection type with autoPoster enabled
*/
protected async generateAutoPoster(
collectionName: string,
config: CollectionConfig,
collectionRatingKey: string,
plexClient: PlexAPI,
items?: CollectionItem[],
userInfo?: { userId?: number | string; customLabel?: string },
options?: {
collectionTypeOverride?: string; // For networks/originals to pass platform name
dynamicLogo?: string; // For networks to pass extracted sprite logo
personImageUrl?: string; // For person collections to pass TMDB profile image
}
): Promise<void> {
try {
const mediaType = getCollectionMediaType(config);
logger.info(`Auto-generating poster for collection: ${collectionName}`, {
label: `${this.source} Collections`,
configId: config.id,
mediaType,
});
// Convert items to poster format if available
let posterItems: CollectionItemWithPoster[] | undefined;
if (items && items.length > 0) {
// Determine how many items we need based on the template's content grid
let maxItems = 4; // Default fallback for old templates
if (config.autoPosterTemplate) {
try {
const templateRepository = getRepository(PosterTemplate);
const template = await templateRepository.findOne({
where: { id: config.autoPosterTemplate, isActive: true },
});
if (template) {
const templateData = template.getTemplateData();
// Calculate grid size for unified system
if (templateData.elements) {
// Unified system - find content-grid elements and calculate total size
const contentGridElements = templateData.elements.filter(
(el) => el.type === 'content-grid'
);
if (contentGridElements.length > 0) {
// Sum up all content grid sizes (in case there are multiple grids)
maxItems = contentGridElements.reduce((total, element) => {
const props = element.properties as {
columns?: number;
rows?: number;
};
return total + (props.columns || 2) * (props.rows || 2);
}, 0);
}
}
logger.debug(
`Template grid size calculated for collection poster: ${maxItems} items needed`,
{
templateId: config.autoPosterTemplate,
hasElements: !!templateData.elements,
}
);
}
} catch (error) {
logger.warn(
'Failed to get template grid size, using default limit',
{
templateId: config.autoPosterTemplate,
error: error instanceof Error ? error.message : String(error),
}
);
}
}
posterItems = items.slice(0, maxItems).map((item) => ({
title: item.title,
type: item.type,
tmdbId: item.tmdbId,
posterUrl: item.posterUrl,
year: item.year,
episodeInfo: item.episodeInfo,
metadata: item.metadata,
}));
}
// Create collection identifier for poster tracking
// For Overseerr users collections: config.id-userId-mediaType
// For all other collections: config.id
const collectionIdentifier =
config.type === 'overseerr' &&
config.subtype === 'users' &&
userInfo?.userId
? `${config.id}-${userInfo.userId}-${mediaType}`
: config.id;
// Fetch template data for accurate change detection
let templateData: unknown = null;
if (config.autoPosterTemplate) {
const templateRepo = getRepository(PosterTemplate);
const template = await templateRepo.findOne({
where: { id: config.autoPosterTemplate },
});
templateData = template?.templateData;
}
// Calculate input hash for poster to check if regeneration needed
const { calculatePosterInputHash } = await import(
'@server/utils/metadataHashing'
);
const posterInputHash = calculatePosterInputHash({
templateId: config.autoPosterTemplate || null,
templateData, // Include template content for change detection
itemIds: (posterItems || [])
.map((item) => item.tmdbId?.toString() || item.title)
.sort(),
collectionName,
mediaType,
collectionType: options?.collectionTypeOverride || config.type,
collectionSubtype: config.subtype,
});
// Check if regeneration needed using metadata tracking
const metadataService = (
await import('@server/lib/metadata/MetadataTrackingService')
).default;
try {
const shouldRegenerate = await metadataService.shouldRegeneratePoster(
collectionRatingKey,
posterInputHash
);
if (!shouldRegenerate) {
// Check if Plex still has correct poster
const currentPosterUrl = await plexClient.getCurrentPosterUrl(
collectionRatingKey
);
const shouldReapply = await metadataService.shouldReapplyPoster(
collectionRatingKey,
currentPosterUrl
);
if (!shouldReapply) {
logger.info(
'Poster unchanged, skipping regeneration and reapplication',
{
label: `${this.source} Collections`,
collectionName,
collectionRatingKey,
}
);
return; // Skip entire poster workflow
}
logger.info(
'Poster inputs unchanged but Plex URL differs, reapplying',
{
label: `${this.source} Collections`,
collectionName,
}
);
}
} catch (error) {
logger.warn(
'Metadata check failed, proceeding with poster generation',
{
label: 'MetadataTracking',
error: error instanceof Error ? error.message : String(error),
}
);
// Fall through to generate poster
}
// Generate the poster using the processed collection name
// Returns full path to temp file in system temp directory
const posterTempPath = await generatePoster(
{
collectionName,
collectionType: options?.collectionTypeOverride || config.type,
collectionSubtype: config.subtype,
mediaType,
items: posterItems,
autoPosterTemplate: config.autoPosterTemplate,
libraryId: config.libraryId,
...(options?.dynamicLogo && { dynamicLogo: options.dynamicLogo }),
...(options?.personImageUrl && {
personImageUrl: options.personImageUrl,
}),
},
`Auto-generated: ${collectionName}`,
collectionIdentifier
);
// Apply poster to collection (smart or regular)
await plexClient.updateCollectionPoster(
collectionRatingKey,
posterTempPath
);
// Get the full Plex poster URL from the collection to complete the workflow
const plexPosterUrl = await plexClient.getCurrentPosterUrl(
collectionRatingKey
);
if (plexPosterUrl) {
// Record metadata tracking for this poster
try {
await metadataService.recordPosterApplication(
collectionRatingKey,
posterInputHash,
plexPosterUrl,
{
configId: config.id,
libraryKey: undefined, // Library key not available in this context
}
);
} catch (error) {
logger.error('Failed to record poster metadata, upload succeeded', {
label: 'MetadataTracking',
error: error instanceof Error ? error.message : String(error),
});
}
}
// Clean up temp poster file after successful upload
try {
const fs = await import('fs');
if (fs.existsSync(posterTempPath)) {
await fs.promises.unlink(posterTempPath);
logger.debug(
`Deleted temp poster file after upload: ${path.basename(
posterTempPath
)}`,
{
label: `${this.source} Collections`,
collectionName,
}
);
}
} catch (cleanupError) {
logger.warn(
`Failed to delete temp poster file: ${path.basename(posterTempPath)}`,
{
label: `${this.source} Collections`,
error:
cleanupError instanceof Error
? cleanupError.message
: String(cleanupError),
}
);
}
logger.info(
`Successfully generated and applied poster for collection: ${collectionName}`,
{
label: `${this.source} Collections`,
configId: config.id,
collectionRatingKey,
plexPosterUrl,
}
);
} catch (error) {
// Log error but don't fail the sync - poster generation is optional
logger.warn(
`Failed to auto-generate poster for collection: ${collectionName}`,
{
label: `${this.source} Collections`,
configId: config.id,
error: error instanceof Error ? error.message : String(error),
}
);
}
}
}
export default BaseCollectionSync;