chore(preview collections): adds caching for preview collections optimisation

This commit is contained in:
Tom Wheeler
2025-10-09 14:34:01 +13:00
parent cb9774f5de
commit fdfa221470
9 changed files with 540 additions and 174 deletions
+82
View File
@@ -3891,6 +3891,88 @@ paths:
name:
type: string
example: Japan
/movie/{id}:
get:
summary: Get movie details
description: Returns detailed information about a movie from TMDB
tags:
- movies
parameters:
- in: path
name: id
required: true
schema:
type: integer
description: TMDB movie ID
responses:
'200':
description: Movie details
content:
application/json:
schema:
type: object
properties:
id:
type: integer
title:
type: string
backdrop_path:
type: string
nullable: true
poster_path:
type: string
nullable: true
'500':
description: Unable to retrieve movie
/tv/{id}:
get:
summary: Get TV show details
description: Returns detailed information about a TV show from TMDB including seasons
tags:
- tv
parameters:
- in: path
name: id
required: true
schema:
type: integer
description: TMDB TV show ID
responses:
'200':
description: TV show details
content:
application/json:
schema:
type: object
properties:
id:
type: integer
name:
type: string
backdrop_path:
type: string
nullable: true
poster_path:
type: string
nullable: true
seasons:
type: array
items:
type: object
properties:
id:
type: integer
season_number:
type: integer
name:
type: string
episode_count:
type: integer
air_date:
type: string
nullable: true
'500':
description: Unable to retrieve TV show
/status:
get:
summary: Get Agregarr status
+58 -1
View File
@@ -10,7 +10,18 @@ export type AvailableCacheIds =
| 'github'
| 'plexguid'
| 'plextv'
| 'plexwatchlist';
| 'plexwatchlist'
| 'trakt-list'
| 'imdb-list'
| 'letterboxd-list'
| 'tmdb-list'
| 'mdblist-list'
| 'tautulli-list'
| 'overseerr-list'
| 'networks-list'
| 'originals-list'
| 'anilist-list'
| 'myanimelist-list';
const DEFAULT_TTL = 300;
const DEFAULT_CHECK_PERIOD = 120;
@@ -75,6 +86,52 @@ class CacheManager {
checkPeriod: 60,
}),
plexwatchlist: new Cache('plexwatchlist', 'Plex Watchlist'),
// List caches - cache external list data between syncs for faster preview
// 7-day TTL as safety net (syncs normally refresh cache long before expiration)
'trakt-list': new Cache('trakt-list', 'Trakt Lists', {
stdTtl: 86400 * 7, // 7 day cache - safety net if syncs stop
checkPeriod: 60 * 60,
}),
'imdb-list': new Cache('imdb-list', 'IMDb Lists', {
stdTtl: 86400 * 7, // 7 day cache
checkPeriod: 60 * 60,
}),
'letterboxd-list': new Cache('letterboxd-list', 'Letterboxd Lists', {
stdTtl: 86400 * 7, // 7 day cache
checkPeriod: 60 * 60,
}),
'tmdb-list': new Cache('tmdb-list', 'TMDb Lists', {
stdTtl: 86400 * 7, // 7 day cache
checkPeriod: 60 * 60,
}),
'mdblist-list': new Cache('mdblist-list', 'MDBList Lists', {
stdTtl: 86400 * 7, // 7 day cache
checkPeriod: 60 * 60,
}),
'tautulli-list': new Cache('tautulli-list', 'Tautulli Stats', {
stdTtl: 86400 * 7, // 7 day cache
checkPeriod: 60 * 60,
}),
'overseerr-list': new Cache('overseerr-list', 'Overseerr Requests', {
stdTtl: 86400 * 7, // 7 day cache
checkPeriod: 60 * 60,
}),
'networks-list': new Cache('networks-list', 'Network Top 10', {
stdTtl: 86400 * 7, // 7 day cache
checkPeriod: 60 * 60,
}),
'originals-list': new Cache('originals-list', 'Provider Originals', {
stdTtl: 86400 * 7, // 7 day cache
checkPeriod: 60 * 60,
}),
'anilist-list': new Cache('anilist-list', 'AniList Lists', {
stdTtl: 86400 * 7, // 7 day cache
checkPeriod: 60 * 60,
}),
'myanimelist-list': new Cache('myanimelist-list', 'MyAnimeList Lists', {
stdTtl: 86400 * 7, // 7 day cache
checkPeriod: 60 * 60,
}),
};
public getCache(id: AvailableCacheIds): Cache {
@@ -1,4 +1,5 @@
import type PlexAPI from '@server/api/plexapi';
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';
@@ -1921,8 +1922,141 @@ export abstract class BaseCollectionSync implements CollectionSyncInterface {
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.tmdbCustomListUrl) parts.push(config.tmdbCustomListUrl);
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,
+112 -118
View File
@@ -104,7 +104,7 @@ function updatePreviewStatus(
collectionsPreviewRoutes.post('/', isAuthenticated(), async (req, res) => {
try {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { type, libraryId, ...rest } = req.body;
const { type, libraryId, forceRefresh, ...rest } = req.body;
// Validate required fields
if (!type || !libraryId) {
@@ -327,7 +327,8 @@ async function processMultiSourcePreview(
mediaType: 'movie' | 'tv',
plexClient: PlexAPI,
libraryCache: LibraryItemsCache,
cycleIndex: number
cycleIndex: number,
forceRefresh = false
): Promise<void> {
const { collectionSyncService } = await import(
'@server/lib/collections/services/CollectionSyncService'
@@ -424,9 +425,10 @@ async function processMultiSourcePreview(
const syncService = await collectionSyncService.createSyncService(
source.type
);
const sourceData = await syncService.fetchSourceData(
// Use cached data unless forceRefresh is true
const sourceData = await syncService.fetchSourceDataWithCache(
sourceConfig,
undefined,
{ useCache: !forceRefresh },
libraryCache
);
@@ -607,6 +609,7 @@ async function processMultiSourcePreview(
year?: number;
mediaType?: 'movie' | 'tv';
posterUrl: string;
backdropPath?: string;
inLibrary: boolean;
originalPosition: number;
overview?: string;
@@ -618,7 +621,6 @@ async function processMultiSourcePreview(
const totalItemsToProcess =
matchedItemsWithPosition.length + limitedMissingItems.length;
let processedItemsCount = 0;
updatePreviewStatus(sessionId, {
currentStage: `Fetching posters (0/${totalItemsToProcess})...`,
@@ -635,6 +637,7 @@ async function processMultiSourcePreview(
maxRetries = 3
): Promise<{
posterUrl: string;
backdropPath?: string;
title: string;
year?: number;
overview?: string;
@@ -647,8 +650,9 @@ async function processMultiSourcePreview(
const movie = await tmdbClient.getMovie({ movieId: tmdbId });
return {
posterUrl: movie.poster_path
? `https://image.tmdb.org/t/p/w500${movie.poster_path}`
? `https://image.tmdb.org/t/p/w300_and_h450_face${movie.poster_path}`
: '',
backdropPath: movie.backdrop_path || undefined,
title: movie.title || fallbackTitle,
year: movie.release_date
? new Date(movie.release_date).getFullYear()
@@ -661,8 +665,9 @@ async function processMultiSourcePreview(
const show = await tmdbClient.getTvShow({ tvId: tmdbId });
return {
posterUrl: show.poster_path
? `https://image.tmdb.org/t/p/w500${show.poster_path}`
? `https://image.tmdb.org/t/p/w300_and_h450_face${show.poster_path}`
: '',
backdropPath: show.backdrop_path || undefined,
title: show.name || fallbackTitle,
year: show.first_air_date
? new Date(show.first_air_date).getFullYear()
@@ -690,6 +695,7 @@ async function processMultiSourcePreview(
}
return {
posterUrl: '',
backdropPath: undefined,
title: fallbackTitle,
year: undefined,
overview: undefined,
@@ -698,29 +704,33 @@ async function processMultiSourcePreview(
};
};
// Fetch all TMDB data in parallel - TMDB client handles rate limiting automatically
updatePreviewStatus(sessionId, {
currentStage: 'Fetching posters...',
progress: 60,
});
// Fetch matched items
const matchedTmdbDataResults = await Promise.all(
matchedItemsWithPosition.map(async (item) => {
if (item.tmdbId && item.tmdbId !== 0 && item.type) {
return fetchTmdbDataWithRetry(item.tmdbId, item.type, item.title);
}
return {
posterUrl: '',
backdropPath: undefined,
title: item.title,
year: item.year,
overview: undefined,
imdbId: undefined,
tmdbRating: undefined,
};
})
);
// Process matched items
for (const item of matchedItemsWithPosition) {
let tmdbData: {
posterUrl: string;
title: string;
year?: number;
overview?: string;
imdbId?: string;
tmdbRating?: number;
} = {
posterUrl: '',
title: item.title,
year: item.year,
};
if (item.tmdbId && item.tmdbId !== 0 && item.type) {
tmdbData = await fetchTmdbDataWithRetry(
item.tmdbId,
item.type,
item.title
);
}
matchedItemsWithPosition.forEach((item, index) => {
const tmdbData = matchedTmdbDataResults[index];
allItemsWithPosition.push({
ratingKey: item.ratingKey,
title: tmdbData.title,
@@ -728,53 +738,44 @@ async function processMultiSourcePreview(
tmdbId: item.tmdbId,
mediaType: item.type,
posterUrl: tmdbData.posterUrl,
backdropPath: tmdbData.backdropPath,
inLibrary: true,
originalPosition: item.originalPosition,
overview: tmdbData.overview,
imdbId: tmdbData.imdbId,
tmdbRating: tmdbData.tmdbRating,
});
});
processedItemsCount++;
const progress =
60 + Math.floor((processedItemsCount / totalItemsToProcess) * 30);
updatePreviewStatus(sessionId, {
currentStage: `Fetching posters (${processedItemsCount}/${totalItemsToProcess})...`,
progress,
processedItems: processedItemsCount,
});
}
updatePreviewStatus(sessionId, {
currentStage: 'Fetching posters...',
progress: 75,
});
// Fetch missing items
const missingTmdbDataResults = await Promise.all(
limitedMissingItems.map((item) =>
fetchTmdbDataWithRetry(item.tmdbId, item.mediaType, item.title)
)
);
// Process missing items
for (const item of limitedMissingItems) {
const tmdbData = await fetchTmdbDataWithRetry(
item.tmdbId,
item.mediaType,
item.title
);
limitedMissingItems.forEach((item, index) => {
const tmdbData = missingTmdbDataResults[index];
allItemsWithPosition.push({
tmdbId: item.tmdbId,
title: tmdbData.title,
year: tmdbData.year,
mediaType: item.mediaType,
posterUrl: tmdbData.posterUrl,
backdropPath: tmdbData.backdropPath,
inLibrary: false,
originalPosition: item.originalPosition,
overview: tmdbData.overview,
imdbId: tmdbData.imdbId,
tmdbRating: tmdbData.tmdbRating,
});
processedItemsCount++;
const progress =
60 + Math.floor((processedItemsCount / totalItemsToProcess) * 30);
updatePreviewStatus(sessionId, {
currentStage: `Fetching posters (${processedItemsCount}/${totalItemsToProcess})...`,
progress,
processedItems: processedItemsCount,
});
}
});
// Sort by original position
const enrichedItems = allItemsWithPosition.sort(
@@ -828,6 +829,7 @@ async function processPreviewAsync(
network?: string;
country?: string;
provider?: string;
forceRefresh?: boolean; // If true, bypass cache and fetch fresh data
// Multi-source specific fields
isMultiSource?: boolean;
sources?: {
@@ -858,6 +860,7 @@ async function processPreviewAsync(
network,
country,
provider,
forceRefresh,
isMultiSource,
sources,
combineMode,
@@ -993,7 +996,8 @@ async function processPreviewAsync(
mediaType,
plexClient,
libraryCache,
cycleIndex || 0
cycleIndex || 0,
forceRefresh || false
);
}
@@ -1004,14 +1008,16 @@ async function processPreviewAsync(
const syncService = await collectionSyncService.createSyncService(type);
updatePreviewStatus(sessionId, {
currentStage: 'Fetching collection items...',
currentStage: forceRefresh
? 'Fetching fresh collection items...'
: 'Loading collection items...',
progress: 20,
});
// Fetch source data
const sourceData = await syncService.fetchSourceData(
// Fetch source data - use cached data unless forceRefresh is true
const sourceData = await syncService.fetchSourceDataWithCache(
previewConfig,
undefined,
{ useCache: !forceRefresh },
libraryCache
);
@@ -1141,6 +1147,7 @@ async function processPreviewAsync(
year?: number;
mediaType?: 'movie' | 'tv';
posterUrl: string;
backdropPath?: string;
inLibrary: boolean;
originalPosition: number;
overview?: string;
@@ -1158,6 +1165,7 @@ async function processPreviewAsync(
maxRetries = 3
): Promise<{
posterUrl: string;
backdropPath?: string;
title: string;
year?: number;
overview?: string;
@@ -1170,8 +1178,9 @@ async function processPreviewAsync(
const movie = await tmdbClient.getMovie({ movieId: tmdbId });
return {
posterUrl: movie.poster_path
? `https://image.tmdb.org/t/p/w500${movie.poster_path}`
? `https://image.tmdb.org/t/p/w300_and_h450_face${movie.poster_path}`
: '',
backdropPath: movie.backdrop_path || undefined,
title: movie.title || fallbackTitle,
year: movie.release_date
? new Date(movie.release_date).getFullYear()
@@ -1184,8 +1193,9 @@ async function processPreviewAsync(
const show = await tmdbClient.getTvShow({ tvId: tmdbId });
return {
posterUrl: show.poster_path
? `https://image.tmdb.org/t/p/w500${show.poster_path}`
? `https://image.tmdb.org/t/p/w300_and_h450_face${show.poster_path}`
: '',
backdropPath: show.backdrop_path || undefined,
title: show.name || fallbackTitle,
year: show.first_air_date
? new Date(show.first_air_date).getFullYear()
@@ -1223,6 +1233,7 @@ async function processPreviewAsync(
}
return {
posterUrl: '',
backdropPath: undefined,
title: fallbackTitle,
year: undefined,
overview: undefined,
@@ -1234,7 +1245,6 @@ async function processPreviewAsync(
const totalItemsToProcess =
matchedItemsWithPosition.length +
(filteredResult.missingItems || []).length;
let processedItemsCount = 0;
updatePreviewStatus(sessionId, {
currentStage: `Fetching posters (0/${totalItemsToProcess})...`,
@@ -1243,38 +1253,32 @@ async function processPreviewAsync(
processedItems: 0,
});
// Process matched items with positions
for (const item of matchedItemsWithPosition) {
let tmdbData: {
posterUrl: string;
title: string;
year?: number;
overview?: string;
imdbId?: string;
tmdbRating?: number;
} = {
posterUrl: '',
title: item.title,
year: item.year,
overview: undefined,
imdbId: undefined,
tmdbRating: undefined,
};
// Fetch all TMDB data in parallel - TMDB client handles rate limiting automatically
updatePreviewStatus(sessionId, {
currentStage: 'Fetching posters...',
progress: 50,
});
if (item.tmdbId && item.tmdbId !== 0 && item.type) {
tmdbData = await fetchTmdbDataWithRetry(
item.tmdbId,
item.type,
item.title
);
} else if (item.tmdbId === 0 || !item.tmdbId) {
logger.debug(`Skipping TMDB fetch for item with invalid tmdbId`, {
label: 'Collections Preview API',
// Fetch matched items
const matchedTmdbDataResults = await Promise.all(
matchedItemsWithPosition.map(async (item) => {
if (item.tmdbId && item.tmdbId !== 0 && item.type) {
return fetchTmdbDataWithRetry(item.tmdbId, item.type, item.title);
}
return {
posterUrl: '',
title: item.title,
tmdbId: item.tmdbId,
});
}
year: item.year,
overview: undefined,
imdbId: undefined,
tmdbRating: undefined,
};
})
);
// Process matched items
matchedItemsWithPosition.forEach((item, index) => {
const tmdbData = matchedTmdbDataResults[index];
allItemsWithPosition.push({
ratingKey: item.ratingKey,
title: tmdbData.title,
@@ -1288,25 +1292,24 @@ async function processPreviewAsync(
imdbId: tmdbData.imdbId,
tmdbRating: tmdbData.tmdbRating,
});
});
processedItemsCount++;
const progress =
50 + Math.floor((processedItemsCount / totalItemsToProcess) * 40);
updatePreviewStatus(sessionId, {
currentStage: `Fetching posters (${processedItemsCount}/${totalItemsToProcess})...`,
progress,
processedItems: processedItemsCount,
});
}
updatePreviewStatus(sessionId, {
currentStage: 'Fetching posters...',
progress: 70,
});
// Fetch missing items
const missingItems = filteredResult.missingItems || [];
const missingTmdbDataResults = await Promise.all(
missingItems.map((item) =>
fetchTmdbDataWithRetry(item.tmdbId, item.mediaType, item.title)
)
);
// Process missing items
for (const item of filteredResult.missingItems || []) {
const tmdbData = await fetchTmdbDataWithRetry(
item.tmdbId,
item.mediaType,
item.title
);
missingItems.forEach((item, index) => {
const tmdbData = missingTmdbDataResults[index];
allItemsWithPosition.push({
tmdbId: item.tmdbId,
title: tmdbData.title,
@@ -1319,16 +1322,7 @@ async function processPreviewAsync(
imdbId: tmdbData.imdbId,
tmdbRating: tmdbData.tmdbRating,
});
processedItemsCount++;
const progress =
50 + Math.floor((processedItemsCount / totalItemsToProcess) * 40);
updatePreviewStatus(sessionId, {
currentStage: `Fetching posters (${processedItemsCount}/${totalItemsToProcess})...`,
progress,
processedItems: processedItemsCount,
});
}
});
updatePreviewStatus(sessionId, {
currentStage: 'Finalizing preview...',
+40
View File
@@ -156,6 +156,46 @@ router.use('/auth', authRoutes);
router.use('/anilist', anilistRoutes);
router.use('/myanimelist', myanimelistRoutes);
router.get<{ id: string }>('/movie/:id', async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const movie = await tmdb.getMovie({ movieId: Number(req.params.id) });
return res.status(200).json(movie);
} catch (e) {
logger.debug('Something went wrong retrieving movie', {
label: 'API',
errorMessage: e.message,
movieId: req.params.id,
});
return next({
status: 500,
message: 'Unable to retrieve movie.',
});
}
});
router.get<{ id: string }>('/tv/:id', async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const tv = await tmdb.getTvShow({ tvId: Number(req.params.id) });
return res.status(200).json(tv);
} catch (e) {
logger.debug('Something went wrong retrieving TV show', {
label: 'API',
errorMessage: e.message,
tvId: req.params.id,
});
return next({
status: 500,
message: 'Unable to retrieve TV show.',
});
}
});
router.get<{ id: string }>('/studio/:id', async (req, res, next) => {
const tmdb = new TheMovieDb();
@@ -8,6 +8,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal';
import { InformationCircleIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import Image from 'next/image';
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { defineMessages, useIntl } from 'react-intl';
@@ -29,6 +30,8 @@ const messages = defineMessages({
viewOnImdb: 'View on IMDb',
noOverview: 'No overview available',
refresh: 'Refresh',
refreshConfirm:
'This will fetch fresh data from the external source and may take some time. Continue?',
excludeItem: 'Exclude from all collections',
itemExcluded: 'Item excluded from all collections',
excludeError: 'Failed to exclude item',
@@ -42,6 +45,7 @@ interface PreviewItem {
year?: number;
mediaType?: 'movie' | 'tv';
posterUrl: string;
backdropPath?: string;
inLibrary: boolean;
overview?: string;
imdbId?: string;
@@ -149,11 +153,13 @@ const PreviewCollectionModal = ({
const [radarrOptionsItem, setRadarrOptionsItem] = useState<{
tmdbId: number;
title: string;
backdropPath?: string;
} | null>(null);
const [seasonSelectionItem, setSeasonSelectionItem] = useState<{
tmdbId: number;
title: string;
service: 'overseerr' | 'sonarr';
backdropPath?: string;
} | null>(null);
const [ratingsCache, setRatingsCache] = useState<Record<number, ItemRatings>>(
{}
@@ -162,6 +168,7 @@ const PreviewCollectionModal = ({
const [cycleIndex, setCycleIndex] = useState(0);
const [excludedItems, setExcludedItems] = useState<Set<number>>(new Set());
const [showExclusionsModal, setShowExclusionsModal] = useState(false);
const [forceRefresh, setForceRefresh] = useState(false);
// Load requested items from localStorage on mount
useEffect(() => {
@@ -184,13 +191,20 @@ const PreviewCollectionModal = ({
subtype: previewConfig.subtype,
libraryId,
customUrl: previewConfig.customUrl,
maxItems: previewConfig.maxItems,
maxItems: previewConfig.maxItems
? Number(previewConfig.maxItems)
: undefined,
timePeriod: previewConfig.timePeriod,
minimumPlays: previewConfig.minimumPlays,
customDays: previewConfig.customDays,
minimumPlays: previewConfig.minimumPlays
? Number(previewConfig.minimumPlays)
: undefined,
customDays: previewConfig.customDays
? Number(previewConfig.customDays)
: undefined,
network: previewConfig.network,
country: previewConfig.country,
provider: previewConfig.provider,
forceRefresh: forceRefresh,
// Multi-source specific fields
isMultiSource: previewConfig.isMultiSource,
sources: previewConfig.sources,
@@ -219,7 +233,7 @@ const PreviewCollectionModal = ({
previewConfig.libraryIds.forEach((libraryId) => {
startPreviewForLibrary(libraryId);
});
}, [previewConfig, cycleIndex]);
}, [previewConfig, cycleIndex, forceRefresh]);
// Poll for status updates
useEffect(() => {
@@ -258,11 +272,12 @@ const PreviewCollectionModal = ({
tmdbId: number,
title: string,
mediaType: 'movie' | 'tv',
service: 'radarr' | 'sonarr' | 'overseerr'
service: 'radarr' | 'sonarr' | 'overseerr',
backdropPath?: string
) => {
// For Radarr (movies), show options modal
if (service === 'radarr' && mediaType === 'movie') {
setRadarrOptionsItem({ tmdbId, title });
setRadarrOptionsItem({ tmdbId, title, backdropPath });
return;
}
@@ -271,7 +286,7 @@ const PreviewCollectionModal = ({
mediaType === 'tv' &&
(service === 'overseerr' || service === 'sonarr')
) {
setSeasonSelectionItem({ tmdbId, title, service });
setSeasonSelectionItem({ tmdbId, title, service, backdropPath });
return;
}
@@ -484,11 +499,23 @@ const PreviewCollectionModal = ({
const currentStage = activeStatus?.currentStage || 'Initializing...';
const progress = activeStatus?.progress || 0;
// Handler to refresh/cycle to next source (for cycle_lists mode)
// Handler to refresh preview data
const handleRefresh = () => {
const { sources } = previewConfig;
if (previewConfig.combineMode === 'cycle_lists' && sources) {
const { sources, combineMode } = previewConfig;
// For cycle_lists mode, just cycle to next source (no confirmation needed)
if (combineMode === 'cycle_lists' && sources) {
setCycleIndex((prev) => (prev + 1) % sources.length);
return;
}
// For all other cases, confirm before forcing a refresh
if (window.confirm(intl.formatMessage(messages.refreshConfirm))) {
// Clear existing sessions and status
setSessionIdsByLibrary({});
setStatusByLibrary({});
// Toggle forceRefresh to trigger the useEffect
setForceRefresh((prev) => !prev);
}
};
@@ -503,17 +530,9 @@ const PreviewCollectionModal = ({
onOk={() => setShowExclusionsModal(true)}
okText={intl.formatMessage(messages.viewExclusions)}
okButtonType="default"
// Show Refresh button for cycle_lists mode
onTertiary={
previewConfig.combineMode === 'cycle_lists'
? handleRefresh
: undefined
}
tertiaryText={
previewConfig.combineMode === 'cycle_lists'
? intl.formatMessage(messages.refresh)
: undefined
}
// Show Refresh button for all scenarios
onTertiary={handleRefresh}
tertiaryText={intl.formatMessage(messages.refresh)}
tertiaryButtonType="default"
>
<div className="w-full">
@@ -609,10 +628,13 @@ const PreviewCollectionModal = ({
{/* Image wrapper with overflow hidden */}
<div className="absolute inset-0 overflow-hidden rounded-lg">
{item.posterUrl ? (
<img
<Image
src={item.posterUrl}
alt={item.title}
className="h-full w-full object-cover"
layout="fill"
objectFit="cover"
loading="eager"
unoptimized
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-gray-800">
@@ -934,7 +956,8 @@ const PreviewCollectionModal = ({
item.tmdbId,
item.title,
'movie',
'radarr'
'radarr',
item.backdropPath
)
}
disabled={downloadingItems.has(item.tmdbId)}
@@ -982,7 +1005,8 @@ const PreviewCollectionModal = ({
item.tmdbId,
item.title,
'movie',
'overseerr'
'overseerr',
item.backdropPath
)
}
disabled={downloadingItems.has(item.tmdbId)}
@@ -1034,7 +1058,8 @@ const PreviewCollectionModal = ({
item.tmdbId,
item.title,
'tv',
'sonarr'
'sonarr',
item.backdropPath
)
}
disabled={downloadingItems.has(item.tmdbId)}
@@ -1082,7 +1107,8 @@ const PreviewCollectionModal = ({
item.tmdbId,
item.title,
'tv',
'overseerr'
'overseerr',
item.backdropPath
)
}
disabled={downloadingItems.has(item.tmdbId)}
@@ -1147,6 +1173,7 @@ const PreviewCollectionModal = ({
<RadarrOptionsModal
tmdbId={radarrOptionsItem.tmdbId}
title={radarrOptionsItem.title}
backdropPath={radarrOptionsItem.backdropPath}
onCancel={() => setRadarrOptionsItem(null)}
onConfirm={handleRadarrOptions}
/>
@@ -1158,6 +1185,7 @@ const PreviewCollectionModal = ({
tmdbId={seasonSelectionItem.tmdbId}
title={seasonSelectionItem.title}
service={seasonSelectionItem.service}
backdropPath={seasonSelectionItem.backdropPath}
onCancel={() => setSeasonSelectionItem(null)}
onConfirm={handleSeasonSelection}
/>
@@ -1,6 +1,6 @@
import Modal from '@app/components/Common/Modal';
import type { RadarrSettings } from '@server/lib/settings';
import type { MovieDetails } from '@server/models/Movie';
import axios from 'axios';
import type React from 'react';
import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@@ -24,6 +24,7 @@ const messages = defineMessages({
interface RadarrOptionsModalProps {
tmdbId: number;
title: string;
backdropPath?: string;
onCancel: () => void;
onConfirm: (serverId: number, profileId: number, rootFolder: string) => void;
}
@@ -31,6 +32,7 @@ interface RadarrOptionsModalProps {
const RadarrOptionsModal: React.FC<RadarrOptionsModalProps> = ({
tmdbId,
title,
backdropPath: cachedBackdropPath,
onCancel,
onConfirm,
}) => {
@@ -42,9 +44,30 @@ const RadarrOptionsModal: React.FC<RadarrOptionsModalProps> = ({
const [selectedRootFolder, setSelectedRootFolder] = useState<string | null>(
null
);
const [backdropPath, setBackdropPath] = useState<string | undefined>(
cachedBackdropPath
);
// Fetch movie details for backdrop
const { data: movieData } = useSWR<MovieDetails>(`/api/v1/movie/${tmdbId}`);
// Fetch movie details for backdrop - only if we don't have cached backdrop
useEffect(() => {
if (cachedBackdropPath) {
return; // Already have cached backdrop
}
const fetchMovieBackdrop = async () => {
try {
const response = await axios.get(`/api/v1/movie/${tmdbId}`);
if (response.data.backdrop_path) {
setBackdropPath(response.data.backdrop_path);
}
} catch (error) {
// Failed to fetch backdrop - will show without backdrop
}
};
fetchMovieBackdrop();
}, [tmdbId, cachedBackdropPath]);
// Fetch Radarr servers
const { data: radarrServers, isLoading: serversLoading } = useSWR<
@@ -117,8 +140,8 @@ const RadarrOptionsModal: React.FC<RadarrOptionsModalProps> = ({
okText={intl.formatMessage(messages.download)}
okDisabled={!isValid}
backdrop={
movieData?.backdropPath
? `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${movieData.backdropPath}`
backdropPath
? `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${backdropPath}`
: undefined
}
>
@@ -130,13 +153,13 @@ const RadarrOptionsModal: React.FC<RadarrOptionsModalProps> = ({
{intl.formatMessage(messages.selectServer)}
</label>
<select
value={selectedServerId || ''}
value={selectedServerId !== null ? String(selectedServerId) : ''}
onChange={(e) => {
setSelectedServerId(Number(e.target.value));
setSelectedProfileId(null);
setSelectedRootFolder(null);
}}
className="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
className="w-full rounded-md border border-stone-500 bg-stone-700 px-3 py-2 text-white focus:border-orange-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
disabled={serversLoading}
>
<option value="">
@@ -160,7 +183,7 @@ const RadarrOptionsModal: React.FC<RadarrOptionsModalProps> = ({
<select
value={selectedProfileId || ''}
onChange={(e) => setSelectedProfileId(Number(e.target.value))}
className="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
className="w-full rounded-md border border-stone-500 bg-stone-700 px-3 py-2 text-white focus:border-orange-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
disabled={selectedServerId === null || profilesLoading}
>
<option value="">
@@ -186,7 +209,7 @@ const RadarrOptionsModal: React.FC<RadarrOptionsModalProps> = ({
<select
value={selectedRootFolder || ''}
onChange={(e) => setSelectedRootFolder(e.target.value)}
className="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
className="w-full rounded-md border border-stone-500 bg-stone-700 px-3 py-2 text-white focus:border-orange-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
disabled={selectedServerId === null || rootFoldersLoading}
>
<option value="">
@@ -33,6 +33,7 @@ interface SeasonSelectionModalProps {
tmdbId: number;
title: string;
service: 'overseerr' | 'sonarr';
backdropPath?: string;
onCancel: () => void;
onConfirm: (
selectedSeasons: number[],
@@ -54,6 +55,7 @@ const SeasonSelectionModal: React.FC<SeasonSelectionModalProps> = ({
tmdbId,
title,
service,
backdropPath: cachedBackdropPath,
onCancel,
onConfirm,
}) => {
@@ -140,14 +142,13 @@ const SeasonSelectionModal: React.FC<SeasonSelectionModalProps> = ({
const fetchSeasons = async () => {
try {
setLoading(true);
const response = await axios.get(
`https://api.themoviedb.org/3/tv/${tmdbId}`,
{
params: {
api_key: 'db55323b8d3e4154498498a75642b381', // Public TMDB key
},
}
);
// Use cached backdrop if available
if (cachedBackdropPath) {
setBackdropPath(cachedBackdropPath);
}
const response = await axios.get(`/api/v1/tv/${tmdbId}`);
// Filter out season 0 (specials) and sort by season number
const filteredSeasons = (response.data.seasons || [])
@@ -155,7 +156,11 @@ const SeasonSelectionModal: React.FC<SeasonSelectionModalProps> = ({
.sort((a: Season, b: Season) => a.season_number - b.season_number);
setSeasons(filteredSeasons);
setBackdropPath(response.data.backdrop_path);
// Only set backdrop from API if we don't have a cached one
if (!cachedBackdropPath && response.data.backdrop_path) {
setBackdropPath(response.data.backdrop_path);
}
// Select all seasons by default
const allSeasonNumbers = new Set<number>(
@@ -170,7 +175,7 @@ const SeasonSelectionModal: React.FC<SeasonSelectionModalProps> = ({
};
fetchSeasons();
}, [tmdbId]);
}, [tmdbId, cachedBackdropPath]);
const handleToggleSeason = (seasonNumber: number) => {
const newSelected = new Set(selectedSeasons);
@@ -254,13 +259,15 @@ const SeasonSelectionModal: React.FC<SeasonSelectionModalProps> = ({
{intl.formatMessage(messages.selectServer)}
</label>
<select
value={selectedServerId || ''}
value={
selectedServerId !== null ? String(selectedServerId) : ''
}
onChange={(e) => {
setSelectedServerId(Number(e.target.value));
setSelectedProfileId(null);
setSelectedRootFolder(null);
}}
className="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white"
className="w-full rounded-md border border-stone-500 bg-stone-700 px-3 py-2 text-white focus:border-orange-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
disabled={serversLoading}
>
<option value="">
@@ -284,7 +291,7 @@ const SeasonSelectionModal: React.FC<SeasonSelectionModalProps> = ({
<select
value={selectedProfileId || ''}
onChange={(e) => setSelectedProfileId(Number(e.target.value))}
className="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white"
className="w-full rounded-md border border-stone-500 bg-stone-700 px-3 py-2 text-white focus:border-orange-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
disabled={selectedServerId === null || profilesLoading}
>
<option value="">
@@ -310,7 +317,7 @@ const SeasonSelectionModal: React.FC<SeasonSelectionModalProps> = ({
<select
value={selectedRootFolder || ''}
onChange={(e) => setSelectedRootFolder(e.target.value)}
className="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white"
className="w-full rounded-md border border-stone-500 bg-stone-700 px-3 py-2 text-white focus:border-orange-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
disabled={selectedServerId === null || rootFoldersLoading}
>
<option value="">
@@ -353,7 +360,7 @@ const SeasonSelectionModal: React.FC<SeasonSelectionModalProps> = ({
<span
aria-hidden="true"
className={`${
isAllSeasons() ? 'bg-indigo-500' : 'bg-gray-800'
isAllSeasons() ? 'bg-orange-500' : 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
/>
<span
@@ -362,7 +369,7 @@ const SeasonSelectionModal: React.FC<SeasonSelectionModalProps> = ({
isAllSeasons()
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-orange-300 group-focus:ring`}
/>
</span>
</th>
@@ -398,7 +405,7 @@ const SeasonSelectionModal: React.FC<SeasonSelectionModalProps> = ({
aria-hidden="true"
className={`${
selectedSeasons.has(season.season_number)
? 'bg-indigo-500'
? 'bg-orange-500'
: 'bg-gray-700'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
/>
@@ -408,7 +415,7 @@ const SeasonSelectionModal: React.FC<SeasonSelectionModalProps> = ({
selectedSeasons.has(season.season_number)
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-orange-300 group-focus:ring`}
/>
</span>
</td>
+2 -1
View File
@@ -141,13 +141,14 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
objectFit="cover"
objectPosition="top"
priority
unoptimized
/>
</div>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(31, 41, 55, 0.75) 0%, rgba(31, 41, 55, 1) 100%)',
'linear-gradient(180deg, rgba(41, 37, 36, 0.75) 0%, rgba(41, 37, 36, 1) 100%)',
}}
/>
</div>