mirror of
https://github.com/agregarr/agregarr.git
synced 2026-05-03 00:20:31 -05:00
chore(preview collections): adds caching for preview collections optimisation
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user