perf(overlays): add TMDB poster caching and fix race conditions (#277)

P0 Critical fixes:
- Add TMDB poster file cache with 7-day TTL (reduces API calls by ~99%)
- Add per-job TMDB URL cache with Promise coalescing for concurrent requests
- Add per-library mutex lock to prevent concurrent overlay processing
- Fix variable shadowing where passed tmdbId was ignored

P1 High priority fixes:
- Fix silent failure path - propagate errors from base poster failures
- Add cache cleanup at job start to prevent stale data

P2 Medium priority fixes:
- Fix neq condition evaluation for undefined/null fields
- Handle rejected promises in URL cache (remove on failure)

---------

Co-authored-by: bitr8 <bitr8@users.noreply.github.com>
Co-authored-by: Tom Wheeler <thomas.wheeler.tcw@gmail.com>
This commit is contained in:
bitr8
2026-01-03 20:14:12 +11:00
committed by GitHub
parent cbe5e627a2
commit 37591405ef
6 changed files with 511 additions and 130 deletions
+97 -21
View File
@@ -39,10 +39,11 @@ class OverlayLibraryService {
private sonarrSeriesCache?: Map<string, SonarrSeries[]>;
private maintainerrCollectionsCache?: MaintainerrCollection[];
// Track running libraries
// Track running libraries with mutex-like behavior
// Prevents concurrent processing of the same library
private runningLibraries = new Map<
string,
{ libraryName: string; startTime: number }
{ libraryName: string; startTime: number; promise: Promise<void> }
>();
/**
@@ -57,6 +58,7 @@ class OverlayLibraryService {
running: true,
libraryName: status.libraryName,
startTime: status.startTime,
runningFor: Math.round((Date.now() - status.startTime) / 1000),
};
}
@@ -69,6 +71,7 @@ class OverlayLibraryService {
libraryId,
libraryName: status.libraryName,
startTime: status.startTime,
runningFor: Math.round((Date.now() - status.startTime) / 1000),
})
);
}
@@ -77,34 +80,106 @@ class OverlayLibraryService {
* Clear library caches (call at start of overlay job)
*/
private clearLibraryCaches() {
this.radarrMoviesCache = new Map();
this.sonarrSeriesCache = new Map();
this.radarrMoviesCache = new Map<string, RadarrMovie[]>();
this.sonarrSeriesCache = new Map<string, SonarrSeries[]>();
this.maintainerrCollectionsCache = undefined;
}
/**
* Apply overlays to all items in a library
* Uses mutex to prevent concurrent processing of the same library
*/
async applyOverlaysToLibrary(
libraryId: string,
checkCancelled?: () => boolean
): Promise<void> {
// Get library configuration first to get name
const configRepository = getRepository(OverlayLibraryConfig);
const config = await configRepository.findOne({
where: { libraryId },
// Check if library is already being processed (mutex check)
// Reject duplicate requests to prevent corruption and match API layer behavior
const existing = this.runningLibraries.get(libraryId);
if (existing) {
const runningFor = Math.round((Date.now() - existing.startTime) / 1000);
logger.warn(
'Library already being processed, rejecting duplicate request',
{
label: 'OverlayLibrary',
libraryId,
libraryName: existing.libraryName,
startedAt: new Date(existing.startTime).toISOString(),
runningFor: `${runningFor}s`,
}
);
throw new Error(
`Library "${existing.libraryName}" is already being processed (running for ${runningFor}s)`
);
}
// Create a deferred promise to set in the map immediately
// This prevents race conditions where two calls pass the check before either awaits
let resolveDeferred: (() => void) | undefined;
let rejectDeferred: ((error: Error) => void) | undefined;
const deferredPromise = new Promise<void>((resolve, reject) => {
resolveDeferred = resolve;
rejectDeferred = reject;
});
// Mark as running
// Verify promise initialization succeeded
if (!resolveDeferred || !rejectDeferred) {
throw new Error('Failed to initialize deferred promise');
}
// Mark as running BEFORE any await (to prevent race condition)
this.runningLibraries.set(libraryId, {
libraryName: config?.libraryName || libraryId,
libraryName: libraryId, // Will update after config fetch
startTime: Date.now(),
promise: deferredPromise,
});
try {
// Get library configuration
const configRepository = getRepository(OverlayLibraryConfig);
const config = await configRepository.findOne({
where: { libraryId },
});
// Update libraryName now that we have config
const runningEntry = this.runningLibraries.get(libraryId);
if (runningEntry) {
runningEntry.libraryName = config?.libraryName || libraryId;
}
// Process the library
await this.processLibraryOverlays(libraryId, config, checkCancelled);
resolveDeferred();
} catch (error) {
rejectDeferred(error instanceof Error ? error : new Error(String(error)));
throw error;
} finally {
// Clean up
this.runningLibraries.delete(libraryId);
}
}
/**
* Internal method to process library overlays
*/
private async processLibraryOverlays(
libraryId: string,
config: OverlayLibraryConfig | null,
checkCancelled?: () => boolean
): Promise<void> {
try {
// Clear library caches at start of job
this.clearLibraryCaches();
// Clear TMDB URL cache to avoid stale data from previous runs
const { plexBasePosterManager } = await import(
'@server/lib/overlays/PlexBasePosterManager'
);
plexBasePosterManager.clearTmdbUrlCache();
// Also clean up expired TMDB poster files
await plexBasePosterManager.cleanTmdbCache();
logger.info('Starting overlay application for library', {
label: 'OverlayLibrary',
libraryId,
@@ -283,10 +358,8 @@ class OverlayLibraryService {
error: error instanceof Error ? error.message : String(error),
});
throw error;
} finally {
// Remove from running libraries
this.runningLibraries.delete(libraryId);
}
// Note: runningLibraries cleanup is handled by the caller (applyOverlaysToLibrary)
}
/**
@@ -529,7 +602,10 @@ class OverlayLibraryService {
plexMetadata,
plexApi['plexClient'] as {
query: (path: string) => Promise<{
MediaContainer?: { Directory?: unknown[]; Metadata?: unknown[] };
MediaContainer?: {
Directory?: unknown[];
Metadata?: unknown[];
};
}>;
}
);
@@ -809,13 +885,13 @@ class OverlayLibraryService {
tmdbId
);
} catch (error) {
logger.error('Failed to get base poster, skipping overlay', {
label: 'OverlayLibrary',
itemTitle: item.title,
ratingKey: item.ratingKey,
error: error instanceof Error ? error.message : String(error),
});
return;
// Re-throw to let caller track this as a failure
// Previously this was silently returning, causing failed items to be counted as success
throw new Error(
`Failed to get base poster for "${item.title}": ${
error instanceof Error ? error.message : String(error)
}`
);
}
const posterBuffer = basePosterResult.posterBuffer;
+12 -2
View File
@@ -176,10 +176,20 @@ function evaluateRule(
context: OverlayRenderContext
): boolean {
const value = context[rule.field];
if (value === undefined || value === null) return false;
const conditionValue = rule.value;
// Handle undefined/null values specially based on operator
if (value === undefined || value === null) {
// For 'neq' (not equal), missing/null IS different from any defined value
// e.g., "downloaded != true" should match when downloaded is undefined
if (rule.operator === 'neq') {
return conditionValue !== undefined && conditionValue !== null;
}
// For all other operators (eq, gt, gte, lt, lte, contains, in, etc.)
// undefined/null means the condition can't be evaluated, so false
return false;
}
switch (rule.operator) {
case 'eq':
// For array fields (like radarrTags/sonarrTags), check if array contains the value
+362 -102
View File
@@ -12,20 +12,170 @@ const BASE_POSTERS_DIR = path.join(
'plex-base-posters'
);
const TMDB_POSTER_CACHE_DIR = path.join(
process.cwd(),
'config',
'tmdb-poster-cache'
);
// TMDB poster cache TTL: 7 days
const TMDB_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
/**
* Simple file storage manager for base posters used in overlay application
* All tracking is done via MediaItemMetadata database - NO JSON registry
*/
class PlexBasePosterManager {
// Per-job cache for TMDB poster URLs (avoids repeated API calls within a single overlay run)
// Key format: `${tmdbId}-${mediaType}-${language}`
// Stores Promises to handle concurrent requests (request coalescing)
// Uses null to indicate "no poster available" (negative caching)
private tmdbUrlCache: Map<string, Promise<string | null>> = new Map();
/**
* Clear the per-job TMDB URL cache
* Call this at the start of each overlay job
*/
clearTmdbUrlCache(): void {
const size = this.tmdbUrlCache.size;
this.tmdbUrlCache.clear();
if (size > 0) {
logger.debug('Cleared TMDB URL cache', {
label: 'PlexBasePosterManager',
previousSize: size,
});
}
}
/**
* Get TMDB poster URL with per-job caching
* Avoids repeated API calls for the same item within a single overlay run
* Uses Promise caching to handle concurrent requests (request coalescing)
* Caches null for items without posters (negative caching)
*/
private async getTmdbPosterUrl(
tmdbId: number,
mediaType: 'movie' | 'show',
language: string
): Promise<string | undefined> {
const cacheKey = `${tmdbId}-${mediaType}-${language}`;
// Check cache first - returns Promise to handle concurrent requests
const cachedPromise = this.tmdbUrlCache.get(cacheKey);
if (cachedPromise) {
logger.debug('TMDB URL cache hit', {
label: 'PlexBasePosterManager',
tmdbId,
mediaType,
language,
});
const result = await cachedPromise;
return result ?? undefined; // Convert null back to undefined
}
// Cache miss - create Promise for TMDB API call
// Store Promise immediately to coalesce concurrent requests
// Wrap with error handling to remove failed entries from cache
const fetchPromise = this.fetchTmdbPosterUrl(
tmdbId,
mediaType,
language
).catch((error: unknown) => {
// Remove failed entry so future calls can retry
this.tmdbUrlCache.delete(cacheKey);
throw error;
});
this.tmdbUrlCache.set(cacheKey, fetchPromise);
logger.debug('TMDB URL cache miss - fetching', {
label: 'PlexBasePosterManager',
tmdbId,
mediaType,
language,
cacheSize: this.tmdbUrlCache.size,
});
const result = await fetchPromise;
return result ?? undefined; // Convert null back to undefined
}
/**
* Fetch TMDB poster URL from API (internal helper)
* Returns null if no poster available (for negative caching)
*/
private async fetchTmdbPosterUrl(
tmdbId: number,
mediaType: 'movie' | 'show',
language: string
): Promise<string | null> {
const TheMovieDb = (await import('@server/api/themoviedb')).default;
const tmdbClient = new TheMovieDb();
let posterUrl: string | null = null;
try {
if (mediaType === 'movie') {
const images = await tmdbClient.getMovieImages({
movieId: tmdbId,
language,
});
const poster = images.posters.find((p) => p.iso_639_1 === language);
if (poster) {
posterUrl = `https://image.tmdb.org/t/p/original${poster.file_path}`;
} else {
// Fallback to main poster from movie details
const movie = await tmdbClient.getMovie({ movieId: tmdbId });
posterUrl = movie.poster_path
? `https://image.tmdb.org/t/p/original${movie.poster_path}`
: null;
}
} else {
const images = await tmdbClient.getTvShowImages({
tvId: tmdbId,
language,
});
const poster = images.posters.find((p) => p.iso_639_1 === language);
if (poster) {
posterUrl = `https://image.tmdb.org/t/p/original${poster.file_path}`;
} else {
// Fallback to main poster from TV show details
const tvShow = await tmdbClient.getTvShow({ tvId: tmdbId });
posterUrl = tvShow.poster_path
? `https://image.tmdb.org/t/p/original${tvShow.poster_path}`
: null;
}
}
} catch (error) {
logger.warn('Failed to fetch TMDB poster URL', {
label: 'PlexBasePosterManager',
tmdbId,
mediaType,
language,
error: error instanceof Error ? error.message : String(error),
});
// Return null to cache the failure (negative caching)
return null;
}
return posterUrl;
}
/**
* Initialize base poster storage directory
*/
async initialize(): Promise<void> {
try {
await fs.mkdir(BASE_POSTERS_DIR, { recursive: true });
await fs.mkdir(TMDB_POSTER_CACHE_DIR, { recursive: true });
logger.info('Initialized base poster storage', {
label: 'PlexBasePosterManager',
directory: BASE_POSTERS_DIR,
tmdbCacheDirectory: TMDB_POSTER_CACHE_DIR,
});
} catch (error) {
logger.error('Failed to initialize base poster storage', {
@@ -36,6 +186,149 @@ class PlexBasePosterManager {
}
}
/**
* Extract cache filename from TMDB poster URL
* TMDB URLs have clean filenames that are filesystem-safe (e.g., /abc123.jpg)
*/
private getTmdbCacheFilename(posterUrl: string): string {
// Extract the file path from URL (e.g., /abc123.jpg from https://image.tmdb.org/t/p/original/abc123.jpg)
const urlPath = new URL(posterUrl).pathname;
const filename = path.basename(urlPath);
return filename;
}
/**
* Get cached TMDB poster if valid (exists and not expired)
*/
private async getTmdbCachedPoster(posterUrl: string): Promise<Buffer | null> {
const filename = this.getTmdbCacheFilename(posterUrl);
const cachePath = path.join(TMDB_POSTER_CACHE_DIR, filename);
try {
const stats = await fs.stat(cachePath);
const age = Date.now() - stats.mtimeMs;
if (age > TMDB_CACHE_TTL_MS) {
logger.debug('TMDB poster cache expired', {
label: 'PlexBasePosterManager',
filename,
ageHours: Math.round(age / (60 * 60 * 1000)),
});
// Delete expired file to free disk space
try {
await fs.unlink(cachePath);
} catch {
// Ignore deletion errors
}
return null;
}
const buffer = await fs.readFile(cachePath);
logger.debug('TMDB poster cache hit', {
label: 'PlexBasePosterManager',
filename,
ageHours: Math.round(age / (60 * 60 * 1000)),
});
return buffer;
} catch (error) {
// File doesn't exist is expected - only log unexpected errors
if (error instanceof Error && !error.message.includes('ENOENT')) {
logger.debug('TMDB poster cache read error', {
label: 'PlexBasePosterManager',
filename,
error: error.message,
});
}
return null;
}
}
/**
* Clean up TMDB cache files
* - If caching is enabled: Deletes expired files (7-day TTL)
* - If caching is disabled: Deletes ALL cached files to free disk space
* Call this periodically or at job start
*/
async cleanTmdbCache(): Promise<{ deleted: number; errors: number }> {
const settings = getSettings();
const cacheEnabled = settings.main.enableTmdbPosterCache ?? true;
let deleted = 0;
let errors = 0;
try {
const files = await fs.readdir(TMDB_POSTER_CACHE_DIR);
const now = Date.now();
for (const file of files) {
const filePath = path.join(TMDB_POSTER_CACHE_DIR, file);
try {
if (cacheEnabled) {
// Cache enabled - only delete expired files
const stats = await fs.stat(filePath);
if (now - stats.mtimeMs > TMDB_CACHE_TTL_MS) {
await fs.unlink(filePath);
deleted++;
}
} else {
// Cache disabled - delete ALL files to free disk space
await fs.unlink(filePath);
deleted++;
}
} catch {
errors++;
}
}
if (deleted > 0) {
logger.info(
cacheEnabled
? 'Cleaned expired TMDB poster cache files'
: 'Cleared all TMDB poster cache files (caching disabled)',
{
label: 'PlexBasePosterManager',
deleted,
errors,
cacheEnabled,
}
);
}
} catch (error) {
logger.warn('Failed to clean TMDB cache', {
label: 'PlexBasePosterManager',
error: error instanceof Error ? error.message : String(error),
});
}
return { deleted, errors };
}
/**
* Store TMDB poster in cache
*/
private async storeTmdbCachedPoster(
posterUrl: string,
buffer: Buffer
): Promise<void> {
const filename = this.getTmdbCacheFilename(posterUrl);
const cachePath = path.join(TMDB_POSTER_CACHE_DIR, filename);
try {
await fs.writeFile(cachePath, buffer);
logger.debug('Stored TMDB poster in cache', {
label: 'PlexBasePosterManager',
filename,
size: buffer.length,
});
} catch (error) {
logger.warn('Failed to cache TMDB poster', {
label: 'PlexBasePosterManager',
filename,
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Generate base poster filename (single current version per item)
*/
@@ -337,7 +630,6 @@ class PlexBasePosterManager {
return urlChanged;
} else {
// ===== TMDB SOURCE =====
const TheMovieDb = (await import('@server/api/themoviedb')).default;
const { getTmdbLanguage } = await import('@server/lib/settings');
// Extract TMDB ID
@@ -360,47 +652,13 @@ class PlexBasePosterManager {
const mediaType: 'movie' | 'show' =
item.type === 'movie' ? 'movie' : 'show';
// Get TMDB poster URL (lightweight - no download)
// Get TMDB poster URL using cached lookup
const language = await getTmdbLanguage(libraryId);
const tmdbClient = new TheMovieDb();
let posterUrl: string | undefined;
if (mediaType === 'movie') {
const images = await tmdbClient.getMovieImages({
movieId: tmdbId,
language,
});
const poster = images.posters.find((p) => p.iso_639_1 === language);
if (poster) {
posterUrl = `https://image.tmdb.org/t/p/original${poster.file_path}`;
} else {
// Fallback to main poster from movie details
const movie = await tmdbClient.getMovie({ movieId: tmdbId });
posterUrl = movie.poster_path
? `https://image.tmdb.org/t/p/original${movie.poster_path}`
: undefined;
}
} else {
const images = await tmdbClient.getTvShowImages({
tvId: tmdbId,
language,
});
const poster = images.posters.find((p) => p.iso_639_1 === language);
if (poster) {
posterUrl = `https://image.tmdb.org/t/p/original${poster.file_path}`;
} else {
// Fallback to main poster from TV show details
const tvShow = await tmdbClient.getTvShow({ tvId: tmdbId });
posterUrl = tvShow.poster_path
? `https://image.tmdb.org/t/p/original${tvShow.poster_path}`
: undefined;
}
}
const posterUrl = await this.getTmdbPosterUrl(
tmdbId,
mediaType,
language
);
if (!posterUrl) {
throw new Error('No TMDB poster available');
@@ -638,102 +896,104 @@ class PlexBasePosterManager {
};
} else {
// ===== TMDB SOURCE =====
const TheMovieDb = (await import('@server/api/themoviedb')).default;
const { getTmdbLanguage } = await import('@server/lib/settings');
// Extract TMDB ID
let tmdbId: number | undefined;
if (item.Guid) {
// Use passed tmdbId if available, otherwise extract from item
let resolvedTmdbId = tmdbId;
if (!resolvedTmdbId && item.Guid) {
const tmdbGuid = item.Guid.find((g) => g.id?.includes('tmdb://'));
if (tmdbGuid) {
const match = tmdbGuid.id.match(/tmdb:\/\/(\d+)/);
if (match) {
tmdbId = parseInt(match[1]);
resolvedTmdbId = parseInt(match[1]);
}
}
}
if (!tmdbId) {
if (!resolvedTmdbId) {
throw new Error('No TMDB ID found for item');
}
// Log TMDB fetch details for debugging wrong poster issues
logger.info('Fetching TMDB poster', {
logger.debug('Fetching TMDB poster', {
label: 'PlexBasePosterManager',
itemTitle: item.title,
ratingKey: item.ratingKey,
itemType: item.type,
tmdbId,
tmdbId: resolvedTmdbId,
mediaType,
endpoint: mediaType === 'movie' ? `/movie/${tmdbId}` : `/tv/${tmdbId}`,
endpoint:
mediaType === 'movie'
? `/movie/${resolvedTmdbId}`
: `/tv/${resolvedTmdbId}`,
});
// Get TMDB poster URL (lightweight - no download yet)
// Get TMDB poster URL using cached lookup
const language = await getTmdbLanguage(libraryId);
const tmdbClient = new TheMovieDb();
let posterUrl: string | undefined;
if (mediaType === 'movie') {
const images = await tmdbClient.getMovieImages({
movieId: tmdbId,
language,
});
const poster = images.posters.find((p) => p.iso_639_1 === language);
if (poster) {
posterUrl = `https://image.tmdb.org/t/p/original${poster.file_path}`;
} else {
// Fallback to main poster from movie details
const movie = await tmdbClient.getMovie({ movieId: tmdbId });
posterUrl = movie.poster_path
? `https://image.tmdb.org/t/p/original${movie.poster_path}`
: undefined;
}
} else {
const images = await tmdbClient.getTvShowImages({
tvId: tmdbId,
language,
});
const poster = images.posters.find((p) => p.iso_639_1 === language);
if (poster) {
posterUrl = `https://image.tmdb.org/t/p/original${poster.file_path}`;
} else {
// Fallback to main poster from TV show details
const tvShow = await tmdbClient.getTvShow({ tvId: tmdbId });
posterUrl = tvShow.poster_path
? `https://image.tmdb.org/t/p/original${tvShow.poster_path}`
: undefined;
}
}
const posterUrl = await this.getTmdbPosterUrl(
resolvedTmdbId,
mediaType,
language
);
if (!posterUrl) {
throw new Error('No TMDB poster available');
}
// Check if TMDB URL changed (for deduplication)
// We don't cache TMDB posters - always download fresh
const tmdbUrlChanged = metadata.originalPlexPosterUrl !== posterUrl;
// ALWAYS download fresh from TMDB (no caching)
logger.info('Downloading TMDB poster', {
label: 'PlexBasePosterManager',
libraryId,
ratingKey: item.ratingKey,
tmdbUrl: posterUrl,
urlChanged: tmdbUrlChanged,
});
// Check if file caching is enabled (defaults to true)
const settings = getSettings();
const cacheEnabled = settings.main.enableTmdbPosterCache ?? true;
const posterBuffer = await this.downloadFromTMDB(posterUrl);
let posterBuffer: Buffer;
if (cacheEnabled) {
// Try to get from cache first (7-day TTL)
const cachedPoster = await this.getTmdbCachedPoster(posterUrl);
if (cachedPoster) {
logger.debug('Using cached TMDB poster', {
label: 'PlexBasePosterManager',
libraryId,
ratingKey: item.ratingKey,
tmdbUrl: posterUrl,
urlChanged: tmdbUrlChanged,
});
posterBuffer = cachedPoster;
} else {
// Cache miss - download from TMDB
logger.info('Downloading TMDB poster (cache miss)', {
label: 'PlexBasePosterManager',
libraryId,
ratingKey: item.ratingKey,
tmdbUrl: posterUrl,
urlChanged: tmdbUrlChanged,
});
posterBuffer = await this.downloadFromTMDB(posterUrl);
// Store in cache for future use
await this.storeTmdbCachedPoster(posterUrl, posterBuffer);
}
} else {
// Cache disabled - always download fresh from TMDB
logger.debug('Downloading TMDB poster (cache disabled)', {
label: 'PlexBasePosterManager',
libraryId,
ratingKey: item.ratingKey,
tmdbUrl: posterUrl,
});
posterBuffer = await this.downloadFromTMDB(posterUrl);
}
return {
posterBuffer,
basePosterChanged: tmdbUrlChanged, // Only changed if URL is different
sourceUrl: posterUrl,
filename: '', // NO LOCAL CACHE for TMDB
filename: this.getTmdbCacheFilename(posterUrl), // Now we cache TMDB posters
fileModTime: undefined,
};
}
@@ -835,7 +1095,7 @@ class PlexBasePosterManager {
await fs.mkdir(orphanedDir, { recursive: true });
// Get all current rating keys from Plex for configured libraries
const currentRatingKeys = new Set<string>();
const currentRatingKeys = new Set();
for (const libraryId of libraryIds) {
let offset = 0;
+2
View File
@@ -539,6 +539,7 @@ export interface MainSettings {
trustProxy: boolean;
locale: string;
tmdbLanguage?: string; // Language for TMDB API calls (poster metadata, etc.) - defaults to 'en'
enableTmdbPosterCache?: boolean; // Enable 7-day file cache for TMDB posters to reduce API calls - defaults to true
nextConfigId?: number; // Next sequential ID for collection configs (starts at 10000)
// Global sync status tracking
lastGlobalSyncAt?: string; // ISO string timestamp of last full collections sync
@@ -641,6 +642,7 @@ class Settings {
trustProxy: false,
locale: 'en',
tmdbLanguage: 'en',
enableTmdbPosterCache: true,
},
plex: {
name: '',
+7 -5
View File
@@ -253,12 +253,14 @@ router.post('/:libraryId/apply', async (req, res, next) => {
});
// Start async overlay application
overlayLibraryService.applyOverlaysToLibrary(libraryId).catch((error) => {
logger.error('Overlay application failed', {
libraryId,
error: error instanceof Error ? error.message : String(error),
overlayLibraryService
.applyOverlaysToLibrary(libraryId)
.catch((error: unknown) => {
logger.error('Overlay application failed', {
libraryId,
error: error instanceof Error ? error.message : String(error),
});
});
});
// Return immediately - overlay application runs in background
return res.status(202).json({
@@ -51,6 +51,9 @@ const messages = defineMessages({
locale: 'Display Language',
tmdbLanguage: 'TMDB Language',
tmdbLanguageTip: 'Language for TMDB posters',
enableTmdbPosterCache: 'Enable TMDB Poster Cache',
enableTmdbPosterCacheTip:
'Cache TMDB posters for 7 days to reduce API calls and improve performance (recommended)',
resetAgregarr: 'Reset',
resetAgregarrDescription:
'Remove all Agregarr collections from Plex and clear all user labels.',
@@ -155,6 +158,7 @@ const SettingsMain = () => {
csrfProtection: data?.csrfProtection,
locale: data?.locale ?? 'en',
tmdbLanguage: data?.tmdbLanguage ?? 'en',
enableTmdbPosterCache: data?.enableTmdbPosterCache ?? true,
trustProxy: data?.trustProxy,
}}
enableReinitialize
@@ -167,6 +171,7 @@ const SettingsMain = () => {
csrfProtection: values.csrfProtection,
locale: values.locale,
tmdbLanguage: values.tmdbLanguage,
enableTmdbPosterCache: values.enableTmdbPosterCache,
trustProxy: values.trustProxy,
});
mutate('/api/v1/settings/public');
@@ -317,6 +322,32 @@ const SettingsMain = () => {
</div>
</div>
</div>
<div className="form-row">
<label
htmlFor="enableTmdbPosterCache"
className="checkbox-label"
>
<span className="mr-2">
{intl.formatMessage(messages.enableTmdbPosterCache)}
</span>
<span className="label-tip">
{intl.formatMessage(messages.enableTmdbPosterCacheTip)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="enableTmdbPosterCache"
name="enableTmdbPosterCache"
onChange={() => {
setFieldValue(
'enableTmdbPosterCache',
!values.enableTmdbPosterCache
);
}}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="trustProxy" className="checkbox-label">
<span className="mr-2">