feat(preview collections): adds collections preview modal

collections can now be previewed via a new button in the config form, this will show the collection
and matching plex items, with options to download missing items

fix #17
This commit is contained in:
Tom Wheeler
2025-10-05 21:50:33 +13:00
parent d4703cf211
commit a0cd481fe7
18 changed files with 4302 additions and 742 deletions
+455
View File
@@ -4890,6 +4890,336 @@ paths:
error:
type: string
example: "Failed to fetch originals providers"
/collections/preview:
post:
summary: Start a collection preview
description: Starts an asynchronous preview process and returns a session ID immediately. Client should poll /collections/preview/status/{sessionId} for progress and results.
tags:
- collection
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- type
- libraryId
properties:
type:
type: string
description: Collection source type
enum: [trakt, tmdb, imdb, letterboxd, mdblist, tautulli, networks, originals, overseerr, multi-source]
example: imdb
subtype:
type: string
description: Collection sub-type (e.g., 'top_250' for IMDb)
example: top_250
libraryId:
type: string
description: Plex library ID to match items against
example: "1"
customUrl:
type: string
description: Custom list URL (for custom list types)
example: "https://www.imdb.com/list/ls123456789/"
maxItems:
type: number
description: Maximum number of items to fetch
example: 50
timePeriod:
type: string
description: Time period for Tautulli collections
enum: [daily, weekly, monthly, all, custom]
example: weekly
minimumPlays:
type: number
description: Minimum play count for Tautulli collections
example: 1
customDays:
type: number
description: Number of days for custom time period (Tautulli)
example: 30
network:
type: string
description: Network name for network collections
example: "Netflix"
country:
type: string
description: Country code for network collections
example: "us"
provider:
type: string
description: Provider name for originals collections
example: "netflix"
responses:
'202':
description: Preview started successfully, poll /status/{sessionId} for progress
content:
application/json:
schema:
type: object
properties:
sessionId:
type: string
example: "preview-1234567890-abc123"
message:
type: string
example: "Preview started, poll /status/:sessionId for progress"
'400':
description: Bad request - missing required fields
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: "Missing required fields: type and libraryId are required"
'500':
description: Failed to start preview
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: "Failed to start preview"
message:
type: string
/collections/preview/status/{sessionId}:
get:
summary: Get preview progress status
description: Poll this endpoint to get real-time progress updates for a preview session
tags:
- collection
parameters:
- name: sessionId
in: path
required: true
description: Session ID returned from POST /collections/preview
schema:
type: string
example: "preview-1234567890-abc123"
responses:
'200':
description: Preview status
content:
application/json:
schema:
type: object
properties:
running:
type: boolean
description: Whether preview is still running
example: true
currentStage:
type: string
description: Current processing stage
example: "Fetching posters (75/250)..."
totalItems:
type: number
description: Total items to process
example: 250
processedItems:
type: number
description: Items processed so far
example: 75
progress:
type: number
description: Progress percentage (0-100)
example: 65
completed:
type: boolean
description: Whether preview is complete
example: false
error:
type: string
description: Error message if preview failed
example: null
result:
type: object
description: Preview results (only present when completed=true)
properties:
items:
type: array
description: All items in original list order
items:
type: object
properties:
ratingKey:
type: string
example: "12345"
title:
type: string
example: "The Shawshank Redemption"
year:
type: number
example: 1994
tmdbId:
type: number
example: 278
mediaType:
type: string
enum: [movie, tv]
example: movie
posterUrl:
type: string
example: "https://image.tmdb.org/t/p/w500/q6y0Go1tsGEsmtFryDOJo3dEmqu.jpg"
inLibrary:
type: boolean
example: true
totalItems:
type: number
example: 250
matchedCount:
type: number
example: 200
missingCount:
type: number
example: 50
'404':
description: Preview session not found
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: "Preview session not found"
/collections/preview/download:
post:
summary: Download a missing item from preview
description: Sends a download request for a missing item to Radarr, Sonarr, or Overseerr.
tags:
- collection
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- tmdbId
- mediaType
- service
properties:
tmdbId:
type: number
description: TMDB ID of the item to download
example: 680
mediaType:
type: string
description: Media type
enum: [movie, tv]
example: movie
service:
type: string
description: Download service to use
enum: [radarr, sonarr, overseerr]
example: radarr
serverId:
type: number
description: Server ID for Radarr/Sonarr (optional)
example: 1
profileId:
type: number
description: Quality profile ID for Radarr/Sonarr (optional)
example: 1
rootFolder:
type: string
description: Root folder path for Radarr/Sonarr (optional)
example: /media/movies
seasons:
description: Seasons to download for TV shows (optional, array of season numbers or 'all')
oneOf:
- type: array
items:
type: number
example: [1, 2, 3]
- type: string
enum: [all]
example: all
sourceType:
type: string
description: Collection source type for service user determination (optional)
example: trakt
responses:
'200':
description: Download request sent successfully
content:
application/json:
schema:
oneOf:
- type: object
description: Radarr response
properties:
success:
type: boolean
example: true
service:
type: string
example: radarr
autoApproved:
type: number
example: 1
serverId:
type: number
example: 1
- type: object
description: Sonarr response
properties:
success:
type: boolean
example: true
service:
type: string
example: sonarr
autoApproved:
type: number
example: 1
serverId:
type: number
example: 1
- type: object
description: Overseerr response
properties:
success:
type: boolean
example: true
service:
type: string
example: overseerr
requestId:
type: number
example: 12345
status:
type: number
example: 2
'400':
description: Bad request
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: "Missing required fields: tmdbId, mediaType, and service"
'500':
description: Failed to download item
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: "Failed to download item"
message:
type: string
/collections/preexisting:
get:
summary: Get pre-existing Plex collections
@@ -5366,6 +5696,131 @@ paths:
description: Invalid request body
'500':
description: Failed to append pre-existing collection configurations
/ratings/movie/{tmdbId}:
get:
summary: Get movie ratings
description: Returns IMDB and Rotten Tomatoes ratings for a movie
tags:
- other
parameters:
- in: path
name: tmdbId
required: true
schema:
type: integer
description: TMDB movie ID
- in: query
name: title
schema:
type: string
description: Movie title (required for RT rating lookup)
- in: query
name: year
schema:
type: integer
description: Movie release year (required for RT rating lookup)
- in: query
name: imdbId
schema:
type: string
description: IMDB ID (e.g. tt1234567) for IMDB rating lookup
responses:
'200':
description: Movie ratings
content:
application/json:
schema:
type: object
properties:
imdb:
type: object
nullable: true
properties:
title:
type: string
url:
type: string
criticsScore:
type: number
rt:
type: object
nullable: true
properties:
title:
type: string
year:
type: number
criticsRating:
type: string
enum: [Certified Fresh, Fresh, Rotten]
criticsScore:
type: number
audienceRating:
type: string
enum: [Upright, Spilled]
audienceScore:
type: number
url:
type: string
'400':
description: Invalid request
'500':
description: Failed to fetch ratings
/ratings/tv/{tmdbId}:
get:
summary: Get TV show ratings
description: Returns Rotten Tomatoes ratings for a TV show
tags:
- other
parameters:
- in: path
name: tmdbId
required: true
schema:
type: integer
description: TMDB TV show ID
- in: query
name: title
schema:
type: string
description: TV show title (required for RT rating lookup)
- in: query
name: year
schema:
type: integer
description: First air date year (optional for RT rating lookup)
responses:
'200':
description: TV show ratings
content:
application/json:
schema:
type: object
properties:
rt:
type: object
nullable: true
properties:
title:
type: string
year:
type: number
criticsRating:
type: string
enum: [Fresh, Rotten]
criticsScore:
type: number
audienceRating:
type: string
enum: [Upright, Spilled]
audienceScore:
type: number
url:
type: string
'400':
description: Invalid request
'500':
description: Failed to fetch ratings
/reorder:
post:
summary: True unified reordering for mixed collection lists
+37 -14
View File
@@ -105,6 +105,11 @@ class FlixPatrolAPI extends ExternalAPI {
url = `/top10/streaming/${region}/${today}/`;
}
// This ensures movie/tv/both requests are cached separately
if (requestedMediaType) {
url += `#${requestedMediaType}`;
}
logger.debug(
`Fetching FlixPatrol streaming overview for platform: ${platform}`,
{
@@ -1510,6 +1515,12 @@ class FlixPatrolAPI extends ExternalAPI {
group.movies === platformSection || group.tv === platformSection
);
// Determine if this is a movies or TV section
const isMoviesSection =
platformGroups[currentPlatformGroupIndex]?.movies === platformSection;
const isTvSection =
platformGroups[currentPlatformGroupIndex]?.tv === platformSection;
if (currentPlatformGroupIndex >= 0) {
// Each platform gets 2 sequential tables from the global card-table list
// Filter out country breakdown tables (they have many rows, typically >50)
@@ -1518,8 +1529,24 @@ class FlixPatrolAPI extends ExternalAPI {
return rows.length <= 20; // Global platform tables have ~10 rows each
});
const startTableIndex = currentPlatformGroupIndex * 2;
const endTableIndex = startTableIndex + 2;
// Each platform has 2 tables in the HTML: TV table first, Movies table second
// Calculate the base table index for this platform
const platformBaseTableIndex = currentPlatformGroupIndex * 2;
// Determine which specific table to use based on which heading we matched
// TV heading → first table (index 0), Movies heading → second table (index 1)
let tableIndex: number;
if (isTvSection) {
tableIndex = platformBaseTableIndex; // First table = TV
} else if (isMoviesSection) {
tableIndex = platformBaseTableIndex + 1; // Second table = Movies
} else {
// Shouldn't happen, but default to first table
tableIndex = platformBaseTableIndex;
}
const startTableIndex = tableIndex;
const endTableIndex = tableIndex + 1; // Process only one table
logger.debug(
`Platform ${platform} should use tables ${startTableIndex}-${
@@ -1543,14 +1570,10 @@ class FlixPatrolAPI extends ExternalAPI {
const table = globalCardTables[i];
const items = this.parseCardTable(table);
// FIXED: Use the section header to determine content type, not position
// Read what the header actually says instead of assuming table positions
const sectionHeaderText =
platformSection.textContent?.toLowerCase() || '';
const isMovieSection = sectionHeaderText.includes('top movies on');
const isTvSection = sectionHeaderText.includes('top tv shows on');
if (isMovieSection) {
// Use the heading we matched to determine if this is movies or TV
// Each platform gets 2 tables - all tables for a movies heading are movies,
// all tables for a TV heading are TV
if (isMoviesSection) {
result.movies.push(
...items.map((item) => ({ ...item, type: 'movie' as const }))
);
@@ -1560,11 +1583,12 @@ class FlixPatrolAPI extends ExternalAPI {
);
} else {
logger.warn(
`Could not determine content type from header: ${sectionHeaderText}`,
`Could not determine content type - not movies or TV section`,
{
label: 'FlixPatrol API',
platform,
headerText: sectionHeaderText,
isMoviesSection,
isTvSection,
}
);
}
@@ -1574,12 +1598,11 @@ class FlixPatrolAPI extends ExternalAPI {
platform,
tableIndex: i,
itemsCount: items.length,
contentType: isMovieSection
contentType: isMoviesSection
? 'movies'
: isTvSection
? 'tv'
: 'unknown',
headerText: sectionHeaderText.trim(),
});
}
+62
View File
@@ -31,6 +31,15 @@ export interface ImdbList {
totalItems: number;
}
/**
* IMDb Rating interface (from Radarr proxy)
*/
export interface ImdbRating {
title: string;
url: string;
criticsScore: number;
}
/**
* IMDb Top Lists enum for predefined lists
*/
@@ -350,6 +359,59 @@ class ImdbAPI extends ExternalAPI {
return 'IMDb List';
}
}
/**
* Get movie ratings from Radarr IMDB proxy API
*
* This uses the Radarr-hosted public IMDB proxy that aggregates ratings data.
* This is a best-effort API as IMDB's official API requires paid access.
*
* @param imdbId - IMDB ID (e.g., "tt1234567")
* @returns Rating data including title, URL, and critics score, or null if not found
*/
public async getMovieRatings(imdbId: string): Promise<ImdbRating | null> {
try {
const response = await this.axios.get<
{
ImdbId: string;
Title: string;
MovieRatings: {
Imdb: {
Count: number;
Value: number;
Type: string;
};
};
}[]
>(`https://api.radarr.video/v1/movie/imdb/${imdbId}`, {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
if (!response.data?.length || response.data[0].ImdbId !== imdbId) {
return null;
}
return {
title: response.data[0].Title,
url: `https://www.imdb.com/title/${response.data[0].ImdbId}`,
criticsScore: response.data[0].MovieRatings.Imdb.Value,
};
} catch (error) {
logger.error(`Failed to fetch IMDB ratings for ${imdbId}:`, {
error: error instanceof Error ? error.message : 'Unknown error',
imdbId,
stack: error instanceof Error ? error.stack : undefined,
});
throw new Error(
`Failed to retrieve movie ratings: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
}
export default ImdbAPI;
+209
View File
@@ -0,0 +1,209 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
interface RTAlgoliaSearchResponse {
results: {
hits: RTAlgoliaHit[];
index: 'content_rt' | 'people_rt';
}[];
}
interface RTAlgoliaHit {
emsId: string;
emsVersionId: string;
tmsId: string;
type: string;
title: string;
titles: string[];
description: string;
releaseYear: number;
rating: string;
genres: string[];
updateDate: string;
isEmsSearchable: boolean;
rtId: number;
vanity: string;
aka: string[];
posterImageUrl: string;
rottenTomatoes: {
audienceScore: number;
criticsIconUrl: string;
wantToSeeCount: number;
audienceIconUrl: string;
scoreSentiment: string;
certifiedFresh: boolean;
criticsScore: number;
};
}
export interface RTRating {
title: string;
year: number;
criticsRating: 'Certified Fresh' | 'Fresh' | 'Rotten';
criticsScore: number;
audienceRating?: 'Upright' | 'Spilled';
audienceScore?: number;
url: string;
}
/**
* This is a best-effort API. The Rotten Tomatoes API is technically
* private and getting access costs money/requires approval.
*
* They do, however, have a "public" api that they use to request the
* data on their own site. We use this to get ratings for movies/tv shows.
*
* Unfortunately, we need to do it by searching for the movie name, so it's
* not always accurate.
*/
class RottenTomatoes extends ExternalAPI {
constructor() {
const settings = getSettings();
super(
'https://79frdp12pn-dsn.algolia.net/1/indexes/*',
{
'x-algolia-agent':
'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)',
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
'x-algolia-application-id': '79FRDP12PN',
},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-algolia-usertoken': settings.clientId,
},
nodeCache: cacheManager.getCache('rt').data,
}
);
}
/**
* Search the RT algolia api for the movie title
*
* We compare the release date to make sure its the correct
* match. But it's not guaranteed to have results.
*
* @param name Movie name
* @param year Release Year
*/
public async getMovieRatings(
name: string,
year: number
): Promise<RTRating | null> {
try {
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
requests: [
{
indexName: 'content_rt',
query: name,
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
},
],
});
const contentResults = data.results.find((r) => r.index === 'content_rt');
if (!contentResults) {
return null;
}
// First, attempt to match exact name and year
let movie = contentResults.hits.find(
(movie) => movie.releaseYear === year && movie.title === name
);
// If we don't find a movie, try to match partial name and year
if (!movie) {
movie = contentResults.hits.find(
(movie) => movie.releaseYear === year && movie.title.includes(name)
);
}
// If we still dont find a movie, try to match just on year
if (!movie) {
movie = contentResults.hits.find((movie) => movie.releaseYear === year);
}
// One last try, try exact name match only
if (!movie) {
movie = contentResults.hits.find((movie) => movie.title === name);
}
if (!movie) {
return null;
}
return {
title: movie.title,
url: `https://www.rottentomatoes.com/m/${movie.vanity}`,
criticsRating: movie.rottenTomatoes.certifiedFresh
? 'Certified Fresh'
: movie.rottenTomatoes.criticsScore >= 60
? 'Fresh'
: 'Rotten',
criticsScore: movie.rottenTomatoes.criticsScore,
audienceRating:
movie.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
audienceScore: movie.rottenTomatoes.audienceScore,
year: Number(movie.releaseYear),
};
} catch (e) {
throw new Error(
`[RT API] Failed to retrieve movie ratings: ${e.message}`
);
}
}
public async getTVRatings(
name: string,
year?: number
): Promise<RTRating | null> {
try {
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
requests: [
{
indexName: 'content_rt',
query: name,
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
},
],
});
const contentResults = data.results.find((r) => r.index === 'content_rt');
if (!contentResults) {
return null;
}
let tvshow: RTAlgoliaHit | undefined = contentResults.hits[0];
if (year) {
tvshow = contentResults.hits.find(
(series) => series.releaseYear === year
);
}
if (!tvshow) {
return null;
}
return {
title: tvshow.title,
url: `https://www.rottentomatoes.com/tv/${tvshow.vanity}`,
criticsRating:
tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten',
criticsScore: tvshow.rottenTomatoes.criticsScore,
audienceRating:
tvshow.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
audienceScore: tvshow.rottenTomatoes.audienceScore,
year: Number(tvshow.releaseYear),
};
} catch (e) {
throw new Error(`[RT API] Failed to retrieve tv ratings: ${e.message}`);
}
}
}
export default RottenTomatoes;
+18 -13
View File
@@ -190,7 +190,7 @@ export class NetworksCollectionSync extends BaseCollectionSync {
});
}
const country = config.networksCountry || 'world';
const country = config.networksCountry || 'global';
const mediaType = getCollectionMediaType(config);
const platformData = await this.flixpatrolClient.getPlatformTop10(
config.subtype || '', // Pass the full subtype (e.g., "neon-tv_top_10")
@@ -652,21 +652,26 @@ export class NetworksCollectionSync extends BaseCollectionSync {
score: number;
}[] = [];
movieResults.forEach((result) => {
scoredResults.push({
result,
mediaType: 'movie',
score: calculateScore(result, 'movie'),
// Only include results that match the collection media type (unless it's 'both')
if (collectionMediaType === 'movie' || collectionMediaType === 'both') {
movieResults.forEach((result) => {
scoredResults.push({
result,
mediaType: 'movie',
score: calculateScore(result, 'movie'),
});
});
});
}
tvResults.forEach((result) => {
scoredResults.push({
result,
mediaType: 'tv',
score: calculateScore(result, 'tv'),
if (collectionMediaType === 'tv' || collectionMediaType === 'both') {
tvResults.forEach((result) => {
scoredResults.push({
result,
mediaType: 'tv',
score: calculateScore(result, 'tv'),
});
});
});
}
// Sort by score (highest first)
scoredResults.sort((a, b) => b.score - a.score);
@@ -27,6 +27,7 @@ export type ServiceType =
| 'mdblist'
| 'letterboxd'
| 'networks'
| 'originals'
| 'tautulli'
| 'overseerr';
@@ -45,6 +46,7 @@ export function generateServiceUserConfig(
mdblist: { name: 'MDBList', avatar: '/services/mdblist.svg' },
letterboxd: { name: 'Letterboxd', avatar: '/letterboxd-logo.svg' },
networks: { name: 'Networks', avatar: '/networks-logo.svg' },
originals: { name: 'Originals', avatar: '/logo_stacked.svg' },
tautulli: { name: 'Tautulli', avatar: '/tautulli-logo.svg' },
overseerr: { name: 'Overseerr', avatar: '/os_logo_stacked.svg' },
}[serviceType];
+2
View File
@@ -98,8 +98,10 @@ export interface CollectionConfig {
// Direct download server selection (for downloadMode: 'direct')
readonly directDownloadRadarrServerId?: number; // Selected Radarr server ID for movies
readonly directDownloadRadarrProfileId?: number; // Selected Radarr profile ID for movies
readonly directDownloadRadarrRootFolder?: string; // Selected Radarr root folder path for movies
readonly directDownloadSonarrServerId?: number; // Selected Sonarr server ID for TV shows
readonly directDownloadSonarrProfileId?: number; // Selected Sonarr profile ID for TV shows
readonly directDownloadSonarrRootFolder?: string; // Selected Sonarr root folder path for TV shows
// Trakt custom list fields
readonly traktCustomListUrl?: string; // Custom Trakt list URL (e.g., https://trakt.tv/users/username/lists/list-name or https://trakt.tv/lists/official/collection-name)
// TMDB custom list fields
+906
View File
@@ -0,0 +1,906 @@
import PlexAPI from '@server/api/plexapi';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type {
CollectionItem,
FilteringStats,
MissingItem,
NetworksSourceData,
} from '@server/lib/collections/core/types';
import { libraryCacheService } from '@server/lib/collections/services/LibraryCacheService';
import type { CollectionConfig } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
const collectionsPreviewRoutes = Router();
// Preview status tracking
interface PreviewStatus {
running: boolean;
currentStage: string;
totalItems: number;
processedItems: number;
progress: number;
error?: string;
completed: boolean;
result?: {
items: unknown[];
totalItems: number;
matchedCount: number;
missingCount: number;
};
}
// Store preview status by session/request ID
const previewStatuses = new Map<string, PreviewStatus>();
// Cleanup old preview statuses after 5 minutes
setInterval(() => {
const now = Date.now();
const fiveMinutesAgo = now - 5 * 60 * 1000;
for (const [key, status] of previewStatuses.entries()) {
if (
status.completed &&
(status as unknown as { timestamp?: number }).timestamp &&
(status as unknown as { timestamp: number }).timestamp < fiveMinutesAgo
) {
previewStatuses.delete(key);
}
}
}, 60000); // Check every minute
/**
* GET /api/v1/collections/preview/status/:sessionId
* Get preview progress status
*/
collectionsPreviewRoutes.get(
'/status/:sessionId',
isAuthenticated(),
(req, res) => {
const { sessionId } = req.params;
const status = previewStatuses.get(sessionId);
if (!status) {
return res.status(404).json({
error: 'Preview session not found',
});
}
return res.status(200).json(status);
}
);
/**
* Helper function to update preview status
*/
function updatePreviewStatus(
sessionId: string,
update: Partial<PreviewStatus>
): void {
const current = previewStatuses.get(sessionId) || {
running: false,
currentStage: '',
totalItems: 0,
processedItems: 0,
progress: 0,
completed: false,
};
previewStatuses.set(sessionId, {
...current,
...update,
});
}
/**
* Preview a collection before creating it
* Fetches items from the source and matches against Plex library
* Returns session ID immediately, client polls /status/:sessionId for progress
*/
collectionsPreviewRoutes.post('/', isAuthenticated(), async (req, res) => {
try {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { type, libraryId, ...rest } = req.body;
// Validate required fields
if (!type || !libraryId) {
return res.status(400).json({
error: 'Missing required fields: type and libraryId are required',
});
}
// Generate session ID
const sessionId = `preview-${Date.now()}-${Math.random()
.toString(36)
.substr(2, 9)}`;
// Initialize status
updatePreviewStatus(sessionId, {
running: true,
currentStage: 'Initializing...',
totalItems: 0,
processedItems: 0,
progress: 0,
completed: false,
});
// Return session ID immediately
res.status(202).json({
sessionId,
message: 'Preview started, poll /status/:sessionId for progress',
});
// Start preview processing in background
processPreviewAsync(sessionId, req.body).catch((error) => {
logger.error('Preview processing failed', {
label: 'Collections Preview API',
error: error instanceof Error ? error.message : String(error),
});
updatePreviewStatus(sessionId, {
running: false,
completed: true,
error: error instanceof Error ? error.message : 'Unknown error',
});
});
} catch (error) {
logger.error('Failed to start preview', {
label: 'Collections Preview API',
error: error instanceof Error ? error.message : String(error),
});
return res.status(500).json({
error: 'Failed to start preview',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
/**
* Process preview asynchronously with progress updates
*/
async function processPreviewAsync(
sessionId: string,
requestBody: {
type: string;
subtype?: string;
libraryId: string;
customUrl?: string;
maxItems?: number;
timePeriod?: string;
minimumPlays?: number;
customDays?: number;
network?: string;
country?: string;
provider?: string;
}
): Promise<void> {
try {
const {
type,
subtype,
libraryId,
customUrl,
maxItems,
timePeriod,
minimumPlays,
customDays,
network,
country,
provider,
} = requestBody;
logger.info(
`Preview request for ${type} collection${subtype ? ` (${subtype})` : ''}`,
{
label: 'Collections Preview API',
type,
subtype,
libraryId,
}
);
updatePreviewStatus(sessionId, {
currentStage: 'Connecting to Plex...',
progress: 5,
});
// Get Plex client
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: { id: true, plexToken: true },
where: { id: 1 },
});
if (!admin || !admin.plexToken) {
throw new Error('Plex authentication not configured');
}
const plexClient = new PlexAPI({ plexToken: admin.plexToken });
updatePreviewStatus(sessionId, {
currentStage: 'Loading library information...',
progress: 10,
});
// Get library info
const libraries = await plexClient.getLibraries();
const library = libraries.find((lib) => lib.key === libraryId);
if (!library) {
throw new Error(`Library not found: ${libraryId}`);
}
const mediaType = library.type === 'movie' ? 'movie' : 'tv';
// Build a temporary config for preview using Record type for flexibility
const previewConfigRecord: Record<string, unknown> = {
id: String(-1),
type,
subtype: subtype || '',
name: 'Preview',
libraryId,
libraryName: library.title,
isActive: true,
visibilityConfig: {
usersHome: false,
serverOwnerHome: false,
libraryRecommended: false,
},
maxItems: maxItems || 50,
template: '',
isLibraryPromoted: false,
everLibraryPromoted: false,
};
// Add type-specific fields
if (customUrl) {
if (type === 'trakt') previewConfigRecord.traktCustomListUrl = customUrl;
else if (type === 'tmdb')
previewConfigRecord.tmdbCustomListUrl = customUrl;
else if (type === 'imdb')
previewConfigRecord.imdbCustomListUrl = customUrl;
else if (type === 'letterboxd')
previewConfigRecord.letterboxdCustomListUrl = customUrl;
else if (type === 'mdblist')
previewConfigRecord.mdblistCustomListUrl = customUrl;
}
if (type === 'tautulli') {
previewConfigRecord.timePeriod = timePeriod || 'all';
previewConfigRecord.minimumPlays = minimumPlays;
if (timePeriod === 'custom') {
previewConfigRecord.customDays = customDays;
}
}
if (type === 'networks') {
// Extract network from subtype if not explicitly provided
// e.g., "netflix_top_10" -> "netflix"
const extractedNetwork =
network || (subtype ? subtype.replace(/_top_10$/, '') : undefined);
previewConfigRecord.network = extractedNetwork;
previewConfigRecord.networksCountry = country;
}
if (type === 'originals') {
previewConfigRecord.provider = provider;
}
const previewConfig = previewConfigRecord as unknown as CollectionConfig;
logger.debug('Preview config built', {
label: 'Collections Preview API',
config: {
type: previewConfig.type,
subtype: previewConfig.subtype,
libraryId: previewConfig.libraryId,
network: previewConfigRecord.network,
country: previewConfigRecord.country,
},
});
// Get library cache for fast matching
const libraryCache = await libraryCacheService.getCache(plexClient);
// Create sync service and extract items
const { collectionSyncService } = await import(
'@server/lib/collections/services/CollectionSyncService'
);
const syncService = await collectionSyncService.createSyncService(type);
updatePreviewStatus(sessionId, {
currentStage: 'Fetching collection items...',
progress: 20,
});
// Fetch source data
const sourceData = await syncService.fetchSourceData(
previewConfig,
undefined,
libraryCache
);
if (!sourceData || sourceData.length === 0) {
updatePreviewStatus(sessionId, {
running: false,
completed: true,
currentStage: 'Complete',
progress: 100,
result: {
items: [],
totalItems: 0,
matchedCount: 0,
missingCount: 0,
},
});
return;
}
updatePreviewStatus(sessionId, {
currentStage: 'Matching items with Plex library...',
progress: 40,
});
// Map to collection items (this performs the Plex matching)
// Networks collections require mediaType as 5th parameter
type MapSourceDataResult = {
items: CollectionItem[];
missingItems?: MissingItem[];
stats?: FilteringStats;
};
let mappedResult: MapSourceDataResult;
if (type === 'networks') {
// Networks sync service has extended signature with mediaType parameter
const NetworksModule = await import(
'@server/lib/collections/external/networks'
);
mappedResult = await (
syncService as InstanceType<
typeof NetworksModule.NetworksCollectionSync
>
).mapSourceDataToItems(
sourceData as NetworksSourceData[],
previewConfig,
plexClient,
libraryCache,
mediaType
);
} else {
mappedResult = await syncService.mapSourceDataToItems(
sourceData,
previewConfig,
plexClient,
libraryCache
);
}
// Filter items by mediaType BEFORE applying other filtering
// This ensures movies only appear in movie libraries and TV in TV libraries
const mediaFilteredResult = {
...mappedResult,
items: mappedResult.items.filter((item) => item.type === mediaType),
missingItems: (mappedResult.missingItems || []).filter(
(item) => item.mediaType === mediaType
),
};
// Apply filtering
const filteredResult = syncService.applyFilteringToMappedItems(
mediaFilteredResult,
previewConfig
);
// Enrich items with poster URLs and merge in original order
// Missing items have originalPosition, matched items need position inferred
const TmdbAPI = (await import('@server/api/themoviedb')).default;
const tmdbClient = new TmdbAPI();
// Build position map from all items (missing items have explicit positions)
const tmdbToPosition = new Map<number, number>();
(filteredResult.missingItems || []).forEach((item) => {
tmdbToPosition.set(item.tmdbId, item.originalPosition);
});
// Extract tmdbId from metadata if not directly available
const itemsWithTmdbId = filteredResult.items.map((item) => {
const tmdbId = item.tmdbId || (item.metadata?.tmdbId as number) || 0;
return { ...item, tmdbId };
});
// Filter out items with invalid tmdbId (0 or undefined) - these can't have posters fetched
const validMatchedItems = itemsWithTmdbId.filter((item) => {
if (!item.tmdbId || item.tmdbId === 0) {
logger.debug(`Filtering out matched item with invalid tmdbId`, {
label: 'Collections Preview API',
title: item.title,
tmdbId: item.tmdbId,
ratingKey: item.ratingKey,
});
return false;
}
return true;
});
// Assume matched items fill in the gaps - assign them sequential positions
// This is a best-effort approach since CollectionItem doesn't track originalPosition
let nextPosition = 1;
const matchedItemsWithPosition = validMatchedItems.map((item) => {
// If we know the position from missing items map, skip those positions
while (Array.from(tmdbToPosition.values()).includes(nextPosition)) {
nextPosition++;
}
const position =
item.tmdbId && tmdbToPosition.has(item.tmdbId)
? tmdbToPosition.get(item.tmdbId) || nextPosition++
: nextPosition++;
return { ...item, originalPosition: position };
});
type EnrichedItem = {
ratingKey?: string;
tmdbId?: number;
title: string;
year?: number;
mediaType?: 'movie' | 'tv';
posterUrl: string;
inLibrary: boolean;
originalPosition: number;
overview?: string;
imdbId?: string;
tmdbRating?: number;
};
const allItemsWithPosition: EnrichedItem[] = [];
// Helper function to fetch TMDB data (poster, title, year, overview, imdbId, rating) with retry logic
const fetchTmdbDataWithRetry = async (
tmdbId: number,
itemMediaType: 'movie' | 'tv',
fallbackTitle: string,
maxRetries = 3
): Promise<{
posterUrl: string;
title: string;
year?: number;
overview?: string;
imdbId?: string;
tmdbRating?: number;
}> => {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
if (itemMediaType === 'movie') {
const movie = await tmdbClient.getMovie({ movieId: tmdbId });
return {
posterUrl: movie.poster_path
? `https://image.tmdb.org/t/p/w500${movie.poster_path}`
: '',
title: movie.title || fallbackTitle,
year: movie.release_date
? new Date(movie.release_date).getFullYear()
: undefined,
overview: movie.overview,
imdbId: movie.imdb_id,
tmdbRating: movie.vote_average,
};
} else {
const show = await tmdbClient.getTvShow({ tvId: tmdbId });
return {
posterUrl: show.poster_path
? `https://image.tmdb.org/t/p/w500${show.poster_path}`
: '',
title: show.name || fallbackTitle,
year: show.first_air_date
? new Date(show.first_air_date).getFullYear()
: undefined,
overview: show.overview,
imdbId: show.external_ids?.imdb_id,
tmdbRating: show.vote_average,
};
}
} catch (error) {
if (attempt < maxRetries) {
// Exponential backoff: 100ms, 200ms, 400ms
const delay = 100 * Math.pow(2, attempt - 1);
logger.debug(
`Retry ${attempt}/${maxRetries} for TMDB data: ${fallbackTitle} (waiting ${delay}ms)`,
{
label: 'Collections Preview API',
tmdbId,
mediaType: itemMediaType,
}
);
await new Promise((resolve) => setTimeout(resolve, delay));
} else {
logger.warn(
`Failed to fetch TMDB data after ${maxRetries} attempts: ${fallbackTitle}`,
{
label: 'Collections Preview API',
tmdbId,
mediaType: itemMediaType,
error: error instanceof Error ? error.message : String(error),
}
);
}
}
}
return {
posterUrl: '',
title: fallbackTitle,
year: undefined,
overview: undefined,
imdbId: undefined,
tmdbRating: undefined,
};
};
const totalItemsToProcess =
matchedItemsWithPosition.length +
(filteredResult.missingItems || []).length;
let processedItemsCount = 0;
updatePreviewStatus(sessionId, {
currentStage: `Fetching posters (0/${totalItemsToProcess})...`,
progress: 50,
totalItems: totalItemsToProcess,
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,
};
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',
title: item.title,
tmdbId: item.tmdbId,
});
}
allItemsWithPosition.push({
ratingKey: item.ratingKey,
title: tmdbData.title,
year: tmdbData.year,
tmdbId: item.tmdbId,
mediaType: item.type,
posterUrl: tmdbData.posterUrl,
inLibrary: true,
originalPosition: item.originalPosition,
overview: tmdbData.overview,
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,
});
}
// Process missing items
for (const item of filteredResult.missingItems || []) {
const tmdbData = await fetchTmdbDataWithRetry(
item.tmdbId,
item.mediaType,
item.title
);
allItemsWithPosition.push({
tmdbId: item.tmdbId,
title: tmdbData.title,
year: tmdbData.year,
mediaType: item.mediaType,
posterUrl: tmdbData.posterUrl,
inLibrary: false,
originalPosition: item.originalPosition,
overview: tmdbData.overview,
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...',
progress: 95,
});
// Sort all items by original position to maintain source list order
const enrichedItems = allItemsWithPosition.sort(
(a, b) => a.originalPosition - b.originalPosition
);
const matchedCount = enrichedItems.filter((i) => i.inLibrary).length;
const missingCount = enrichedItems.filter((i) => !i.inLibrary).length;
logger.info(
`Preview complete: ${matchedCount} matched, ${missingCount} missing`,
{
label: 'Collections Preview API',
type,
subtype,
}
);
updatePreviewStatus(sessionId, {
running: false,
completed: true,
currentStage: 'Complete',
progress: 100,
result: {
items: enrichedItems,
totalItems: enrichedItems.length,
matchedCount,
missingCount,
},
});
} catch (error) {
logger.error('Failed to preview collection', {
label: 'Collections Preview API',
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
// Error already handled by the catch in POST route
throw error;
}
}
/**
* Download a missing item from preview to Radarr/Sonarr/Overseerr
*/
collectionsPreviewRoutes.post(
'/download',
isAuthenticated(),
async (req, res) => {
try {
const {
tmdbId,
mediaType,
service,
serverId,
profileId,
rootFolder,
seasons,
sourceType,
} = req.body;
if (!tmdbId || !mediaType || !service) {
return res.status(400).json({
error: 'Missing required fields: tmdbId, mediaType, and service',
});
}
if (mediaType !== 'movie' && mediaType !== 'tv') {
return res.status(400).json({
error: 'Invalid mediaType, must be movie or tv',
});
}
if (!['radarr', 'sonarr', 'overseerr'].includes(service)) {
return res.status(400).json({
error: 'Invalid service, must be radarr, sonarr, or overseerr',
});
}
logger.info(
`Download request for ${mediaType} (TMDB: ${tmdbId}) via ${service}`,
{
label: 'Collections Preview Download API',
tmdbId,
mediaType,
service,
serverId,
seasons,
sourceType,
}
);
if (service === 'overseerr') {
const OverseerrAPI = (await import('@server/api/overseerr')).default;
const { ServiceUserManager } = await import(
'@server/lib/collections/services/ServiceUserManager'
);
const settings = getSettings();
if (!settings.overseerr?.hostname || !settings.overseerr?.apiKey) {
return res.status(400).json({
error: 'Overseerr is not configured',
});
}
const overseerrClient = new OverseerrAPI(settings.overseerr);
// Get the appropriate service user based on collection type and settings
// Preview requests are always auto-approved since user is manually selecting items
const serviceUserManager = new ServiceUserManager();
const serviceUser =
await serviceUserManager.getOrCreateServiceUserForRequest(
sourceType || 'imdb', // Fallback to 'imdb' if not provided
undefined, // No specific collection type for preview
true // Always auto-approve for preview requests
);
logger.info('Creating Overseerr request with service user', {
label: 'Collections Preview Download API',
serviceUserId: serviceUser.id,
externalOverseerrId: serviceUser.externalOverseerrId,
serviceUserEmail: serviceUser.email,
tmdbId,
mediaType,
seasons: mediaType === 'tv' ? seasons || 'all' : undefined,
});
const requestResult = await overseerrClient.createRequest({
userId: serviceUser.externalOverseerrId || 1, // Use service user's Overseerr ID, fallback to admin
mediaType,
mediaId: tmdbId,
...(mediaType === 'tv' && { seasons: seasons || 'all' }), // TV shows need seasons
});
logger.info('Overseerr request created successfully', {
label: 'Collections Preview Download API',
requestId: requestResult.id,
status: requestResult.status,
tmdbId,
});
return res.status(200).json({
success: true,
service: 'overseerr',
requestId: requestResult.id,
status: requestResult.status,
});
} else if (service === 'radarr' && mediaType === 'movie') {
const { DirectDownloadService } = await import(
'@server/lib/collections/services/DirectDownloadService'
);
const downloadService = new DirectDownloadService();
const missingItem = {
tmdbId,
mediaType: 'movie' as const,
title: 'Unknown',
originalPosition: 1,
};
const radarrDownloadConfigRecord: Record<string, unknown> = {
id: String(-1),
name: 'Preview Download',
type: 'imdb',
libraryId: '',
libraryName: '',
isActive: true,
visibilityConfig: {
usersHome: false,
serverOwnerHome: false,
libraryRecommended: false,
},
searchMissingMovies: true,
searchMissingTV: false,
radarrServerId: serverId,
radarrProfileId: profileId,
radarrRootFolder: rootFolder,
isLibraryPromoted: false,
everLibraryPromoted: false,
};
const result = await downloadService.processDirectDownloads(
[missingItem],
radarrDownloadConfigRecord as unknown as CollectionConfig,
'imdb'
);
return res.status(200).json({
success: true,
service: 'radarr',
autoApproved: result.autoApproved,
serverId,
});
} else if (service === 'sonarr' && mediaType === 'tv') {
const { DirectDownloadService } = await import(
'@server/lib/collections/services/DirectDownloadService'
);
const downloadService = new DirectDownloadService();
const missingItem = {
tmdbId,
mediaType: 'tv' as const,
title: 'Unknown',
originalPosition: 1,
};
const sonarrDownloadConfigRecord: Record<string, unknown> = {
id: String(-1),
name: 'Preview Download',
type: 'imdb',
libraryId: '',
libraryName: '',
isActive: true,
visibilityConfig: {
usersHome: false,
serverOwnerHome: false,
libraryRecommended: false,
},
searchMissingMovies: false,
searchMissingTV: true,
sonarrServerId: serverId,
sonarrProfileId: profileId,
sonarrRootFolder: rootFolder,
seasonsToRequest: seasons || 'all',
isLibraryPromoted: false,
everLibraryPromoted: false,
};
const result = await downloadService.processDirectDownloads(
[missingItem],
sonarrDownloadConfigRecord as unknown as CollectionConfig,
'imdb'
);
return res.status(200).json({
success: true,
service: 'sonarr',
autoApproved: result.autoApproved,
serverId,
});
} else {
return res.status(400).json({
error: `Service ${service} is not compatible with media type ${mediaType}`,
});
}
} catch (error) {
logger.error('Failed to download preview item', {
label: 'Collections Preview Download API',
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
return res.status(500).json({
error: 'Failed to download item',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
export default collectionsPreviewRoutes;
+4
View File
@@ -2898,4 +2898,8 @@ collectionsRoutes.get('/originals/providers', async (_req, res) => {
}
});
// Mount preview routes
import collectionsPreviewRoutes from './collections-preview';
collectionsRoutes.use('/preview', collectionsPreviewRoutes);
export default collectionsRoutes;
+2
View File
@@ -27,6 +27,7 @@ import mediaRoutes from './media';
import missingItemsRoutes from './missing-items';
import postersRoutes from './posters';
import preExistingRoutes from './preexisting';
import ratingsRoutes from './ratings';
import reorderRoutes from './reorder';
import sourceColorsRoutes from './sourceColors';
@@ -143,6 +144,7 @@ router.use('/fonts', isAuthenticated(), fontsRoutes);
router.use('/hubs', isAuthenticated(), hubsRoutes);
router.use('/posters', isAuthenticated(), postersRoutes);
router.use('/preexisting', isAuthenticated(), preExistingRoutes);
router.use('/ratings', isAuthenticated(), ratingsRoutes);
router.use('/reorder', isAuthenticated(), reorderRoutes);
router.use('/service', isAuthenticated(), serviceRoutes);
router.use('/source-colors', isAuthenticated(), sourceColorsRoutes);
+121
View File
@@ -0,0 +1,121 @@
import type { ImdbRating } from '@server/api/imdb';
import ImdbAPI from '@server/api/imdb';
import type { RTRating } from '@server/api/rottentomatoes';
import RottenTomatoes from '@server/api/rottentomatoes';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
const ratingsRoutes = Router();
/**
* GET /api/v1/ratings/movie/:tmdbId
* Get ratings for a movie (IMDB + RT)
*/
ratingsRoutes.get('/movie/:tmdbId', isAuthenticated(), async (req, res) => {
try {
const tmdbId = Number(req.params.tmdbId);
const title = req.query.title as string;
const year = req.query.year ? Number(req.query.year) : undefined;
const imdbId = req.query.imdbId as string | undefined;
if (!tmdbId) {
return res.status(400).json({ error: 'tmdbId is required' });
}
const imdbClient = new ImdbAPI();
const rtClient = new RottenTomatoes();
let imdbRating: ImdbRating | null = null;
let rtRating: RTRating | null = null;
// Fetch IMDB rating if we have an IMDB ID
if (imdbId) {
try {
imdbRating = await imdbClient.getMovieRatings(imdbId);
} catch (error) {
logger.debug('Failed to fetch IMDB rating', {
label: 'Ratings API',
imdbId,
error: error instanceof Error ? error.message : String(error),
});
}
}
// Fetch RT rating if we have title and year
if (title && year) {
try {
rtRating = await rtClient.getMovieRatings(title, year);
} catch (error) {
logger.debug('Failed to fetch RT rating', {
label: 'Ratings API',
title,
year,
error: error instanceof Error ? error.message : String(error),
});
}
}
return res.status(200).json({
imdb: imdbRating,
rt: rtRating,
});
} catch (error) {
logger.error('Failed to fetch movie ratings', {
label: 'Ratings API',
error: error instanceof Error ? error.message : String(error),
});
return res.status(500).json({
error: 'Failed to fetch ratings',
});
}
});
/**
* GET /api/v1/ratings/tv/:tmdbId
* Get ratings for a TV show (RT only, IMDB proxy doesn't support TV)
*/
ratingsRoutes.get('/tv/:tmdbId', isAuthenticated(), async (req, res) => {
try {
const tmdbId = Number(req.params.tmdbId);
const title = req.query.title as string;
const year = req.query.year ? Number(req.query.year) : undefined;
if (!tmdbId) {
return res.status(400).json({ error: 'tmdbId is required' });
}
const rtClient = new RottenTomatoes();
let rtRating: RTRating | null = null;
// Fetch RT rating if we have title
if (title) {
try {
rtRating = await rtClient.getTVRatings(title, year);
} catch (error) {
logger.debug('Failed to fetch RT rating', {
label: 'Ratings API',
title,
year,
error: error instanceof Error ? error.message : String(error),
});
}
}
return res.status(200).json({
rt: rtRating,
});
} catch (error) {
logger.error('Failed to fetch TV ratings', {
label: 'Ratings API',
error: error instanceof Error ? error.message : String(error),
});
return res.status(500).json({
error: 'Failed to fetch ratings',
});
}
});
export default ratingsRoutes;
@@ -45,10 +45,13 @@ const messages = defineMessages({
directDownloadOptions: 'Direct Download Configuration',
selectRadarrServer: 'Radarr Server (Movies)',
selectRadarrProfile: 'Radarr Quality Profile (Movies)',
selectRadarrRootFolder: 'Radarr Root Folder (Movies)',
selectSonarrServer: 'Sonarr Server (TV Shows)',
selectSonarrProfile: 'Sonarr Quality Profile (TV Shows)',
selectSonarrRootFolder: 'Sonarr Root Folder (TV Shows)',
selectServer: 'Select server...',
selectProfile: 'Select quality profile...',
selectRootFolder: 'Select root folder...',
selectServerFirst: 'Select a server first',
});
@@ -64,8 +67,10 @@ interface AutoRequestSectionProps {
excludedCountries?: string[];
directDownloadRadarrServerId?: number;
directDownloadRadarrProfileId?: number;
directDownloadRadarrRootFolder?: string;
directDownloadSonarrServerId?: number;
directDownloadSonarrProfileId?: number;
directDownloadSonarrRootFolder?: string;
[key: string]: unknown;
};
errors: Record<string, string>;
@@ -117,6 +122,18 @@ const AutoRequestSection = ({
: null
);
// Fetch root folders for selected servers or default/single server
const { data: radarrRootFolders } = useSWR<{ id: number; path: string }[]>(
effectiveRadarrServerId !== undefined
? `/api/v1/settings/radarr/${effectiveRadarrServerId}/rootfolders`
: null
);
const { data: sonarrRootFolders } = useSWR<{ id: number; path: string }[]>(
effectiveSonarrServerId !== undefined
? `/api/v1/settings/sonarr/${effectiveSonarrServerId}/rootfolders`
: null
);
if (!isVisible) return null;
// Only show for external sources (not Overseerr or Tautulli)
@@ -546,6 +563,33 @@ const AutoRequestSection = ({
</Field>
</div>
</div>
{/* Radarr Root Folder Selection - always show */}
<div>
<div className="mb-2 text-sm font-medium text-gray-300">
{intl.formatMessage(messages.selectRadarrRootFolder)}
</div>
<div className="form-input-field">
<Field
as="select"
name="directDownloadRadarrRootFolder"
disabled={radarrLoading || !radarrRootFolders}
>
<option value="">
{radarrLoading
? 'Loading...'
: effectiveRadarrServerId !== undefined
? intl.formatMessage(messages.selectRootFolder)
: intl.formatMessage(messages.selectServerFirst)}
</option>
{radarrRootFolders?.map((folder) => (
<option key={folder.id} value={folder.path}>
{folder.path}
</option>
))}
</Field>
</div>
</div>
</>
)}
@@ -620,6 +664,33 @@ const AutoRequestSection = ({
</Field>
</div>
</div>
{/* Sonarr Root Folder Selection - always show */}
<div>
<div className="mb-2 text-sm font-medium text-gray-300">
{intl.formatMessage(messages.selectSonarrRootFolder)}
</div>
<div className="form-input-field">
<Field
as="select"
name="directDownloadSonarrRootFolder"
disabled={sonarrLoading || !sonarrRootFolders}
>
<option value="">
{sonarrLoading
? 'Loading...'
: effectiveSonarrServerId !== undefined
? intl.formatMessage(messages.selectRootFolder)
: intl.formatMessage(messages.selectServerFirst)}
</option>
{sonarrRootFolders?.map((folder) => (
<option key={folder.id} value={folder.path}>
{folder.path}
</option>
))}
</Field>
</div>
</div>
</>
)}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,960 @@
import RTFresh from '@app/assets/rt_fresh.svg';
import RTRotten from '@app/assets/rt_rotten.svg';
import TmdbLogo from '@app/assets/tmdb_logo.svg';
import RadarrOptionsModal from '@app/components/Collections/RadarrOptionsModal';
import SeasonSelectionModal from '@app/components/Collections/SeasonSelectionModal';
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 { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages({
previewCollection: 'Preview Collection',
loadingPreview: 'Loading preview...',
errorLoadingPreview: 'Failed to load preview',
noItems: 'No items found',
inLibrary: 'In Library',
missing: 'Missing',
downloadViaRadarr: 'Download via Radarr',
downloadViaSonarr: 'Download via Sonarr',
downloadViaOverseerr: 'Request via Overseerr',
downloadSuccess: 'Download request sent successfully',
downloadError: 'Failed to send download request',
close: 'Close',
viewOnImdb: 'View on IMDb',
noOverview: 'No overview available',
});
interface PreviewItem {
ratingKey?: string;
tmdbId: number;
title: string;
year?: number;
mediaType?: 'movie' | 'tv';
posterUrl: string;
inLibrary: boolean;
overview?: string;
imdbId?: string;
tmdbRating?: number;
}
interface ItemRatings {
imdb?: {
title: string;
url: string;
criticsScore: number;
} | null;
rt?: {
title: string;
year: number;
criticsRating: string;
criticsScore: number;
audienceRating?: string;
audienceScore?: number;
url: string;
} | null;
}
interface Library {
id: string;
name: string;
type: string;
}
interface PreviewCollectionModalProps {
onCancel: () => void;
previewConfig: {
type: string;
subtype?: string;
libraryIds: string[];
libraries: Library[];
customUrl?: string;
maxItems?: number;
timePeriod?: string;
minimumPlays?: number;
customDays?: number;
network?: string;
country?: string;
provider?: string;
};
}
const PreviewCollectionModal = ({
onCancel,
previewConfig,
}: PreviewCollectionModalProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const [activeLibraryId, setActiveLibraryId] = useState(
previewConfig.libraryIds[0]
);
const [sessionIdsByLibrary, setSessionIdsByLibrary] = useState<
Record<string, string>
>({});
const [statusByLibrary, setStatusByLibrary] = useState<
Record<
string,
{
running: boolean;
currentStage: string;
progress: number;
error?: string;
completed: boolean;
result?: {
items: PreviewItem[];
totalItems: number;
matchedCount: number;
missingCount: number;
};
}
>
>({});
const [hoveredItem, setHoveredItem] = useState<number | null>(null);
const [infoTooltipItem, setInfoTooltipItem] = useState<number | null>(null);
const [tooltipOpenLeft, setTooltipOpenLeft] = useState(false);
const [tooltipOpenAbove, setTooltipOpenAbove] = useState(false);
const iconRef = useRef<HTMLDivElement>(null);
const tooltipCloseTimer = useRef<NodeJS.Timeout | null>(null);
const [downloadingItems, setDownloadingItems] = useState<Set<number>>(
new Set()
);
const [requestedItems, setRequestedItems] = useState<Set<string>>(new Set());
const [radarrOptionsItem, setRadarrOptionsItem] = useState<{
tmdbId: number;
title: string;
} | null>(null);
const [seasonSelectionItem, setSeasonSelectionItem] = useState<{
tmdbId: number;
title: string;
service: 'overseerr' | 'sonarr';
} | null>(null);
const [ratingsCache, setRatingsCache] = useState<Record<number, ItemRatings>>(
{}
);
const [loadingRatings, setLoadingRatings] = useState<Set<number>>(new Set());
// Load requested items from localStorage on mount
useEffect(() => {
try {
const stored = localStorage.getItem('preview-requested-items');
if (stored) {
setRequestedItems(new Set(JSON.parse(stored)));
}
} catch (err) {
// Ignore localStorage errors
}
}, []);
useEffect(() => {
const startPreviewForLibrary = async (libraryId: string) => {
try {
// Start the preview - this returns immediately with a session ID
const response = await axios.post('/api/v1/collections/preview', {
type: previewConfig.type,
subtype: previewConfig.subtype,
libraryId,
customUrl: previewConfig.customUrl,
maxItems: previewConfig.maxItems,
timePeriod: previewConfig.timePeriod,
minimumPlays: previewConfig.minimumPlays,
customDays: previewConfig.customDays,
network: previewConfig.network,
country: previewConfig.country,
provider: previewConfig.provider,
});
const sessionId = response.data.sessionId;
setSessionIdsByLibrary((prev) => ({ ...prev, [libraryId]: sessionId }));
} catch (err) {
setStatusByLibrary((prev) => ({
...prev,
[libraryId]: {
running: false,
currentStage: 'Error',
progress: 0,
completed: true,
error:
err instanceof Error ? err.message : 'Failed to start preview',
},
}));
}
};
// Start preview for all selected libraries
previewConfig.libraryIds.forEach((libraryId) => {
startPreviewForLibrary(libraryId);
});
}, [previewConfig]);
// Poll for status updates
useEffect(() => {
const intervals: NodeJS.Timeout[] = [];
Object.entries(sessionIdsByLibrary).forEach(([libraryId, sessionId]) => {
const interval = setInterval(async () => {
try {
const response = await axios.get(
`/api/v1/collections/preview/status/${sessionId}`
);
const status = response.data;
setStatusByLibrary((prev) => ({ ...prev, [libraryId]: status }));
// Stop polling if completed
if (status.completed) {
clearInterval(interval);
}
} catch (err) {
// Session might not exist yet or has errored - ignore polling errors
// They will resolve once the session is created
}
}, 1000); // Poll every second
intervals.push(interval);
});
return () => {
intervals.forEach((interval) => clearInterval(interval));
};
}, [sessionIdsByLibrary]);
const handleDownload = useCallback(
async (
tmdbId: number,
title: string,
mediaType: 'movie' | 'tv',
service: 'radarr' | 'sonarr' | 'overseerr'
) => {
// For Radarr (movies), show options modal
if (service === 'radarr' && mediaType === 'movie') {
setRadarrOptionsItem({ tmdbId, title });
return;
}
// For TV shows with Overseerr or Sonarr, show season selection modal
if (
mediaType === 'tv' &&
(service === 'overseerr' || service === 'sonarr')
) {
setSeasonSelectionItem({ tmdbId, title, service });
return;
}
// For Overseerr movies, download directly
try {
setDownloadingItems((prev) => new Set(prev).add(tmdbId));
await axios.post('/api/v1/collections/preview/download', {
tmdbId,
mediaType,
service,
sourceType: previewConfig.type,
});
// Mark as requested and save to localStorage
const requestKey = `${tmdbId}-${service}`;
setRequestedItems((prev) => {
const next = new Set(prev).add(requestKey);
try {
localStorage.setItem(
'preview-requested-items',
JSON.stringify(Array.from(next))
);
} catch (err) {
// Ignore localStorage errors
}
return next;
});
addToast(intl.formatMessage(messages.downloadSuccess), {
appearance: 'success',
autoDismiss: true,
});
} catch (err) {
const errorMessage =
err instanceof Error && err.message
? err.message
: intl.formatMessage(messages.downloadError);
addToast(errorMessage, {
appearance: 'error',
autoDismiss: true,
});
} finally {
setDownloadingItems((prev) => {
const next = new Set(prev);
next.delete(tmdbId);
return next;
});
}
},
[addToast, intl, previewConfig.type]
);
const handleSeasonSelection = useCallback(
async (
selectedSeasons: number[],
serverId?: number,
profileId?: number,
rootFolder?: string
) => {
if (!seasonSelectionItem) return;
const { tmdbId, service } = seasonSelectionItem;
try {
setDownloadingItems((prev) => new Set(prev).add(tmdbId));
setSeasonSelectionItem(null); // Close modal
await axios.post('/api/v1/collections/preview/download', {
tmdbId,
mediaType: 'tv',
service,
seasons: selectedSeasons,
serverId,
profileId,
rootFolder,
sourceType: previewConfig.type,
});
// Mark as requested and save to localStorage
const requestKey = `${tmdbId}-${service}`;
setRequestedItems((prev) => {
const next = new Set(prev).add(requestKey);
try {
localStorage.setItem(
'preview-requested-items',
JSON.stringify(Array.from(next))
);
} catch (err) {
// Ignore localStorage errors
}
return next;
});
addToast(intl.formatMessage(messages.downloadSuccess), {
appearance: 'success',
autoDismiss: true,
});
} catch (err) {
const errorMessage =
err instanceof Error && err.message
? err.message
: intl.formatMessage(messages.downloadError);
addToast(errorMessage, {
appearance: 'error',
autoDismiss: true,
});
} finally {
setDownloadingItems((prev) => {
const next = new Set(prev);
next.delete(tmdbId);
return next;
});
}
},
[seasonSelectionItem, addToast, intl, previewConfig.type]
);
const handleRadarrOptions = useCallback(
async (serverId: number, profileId: number, rootFolder: string) => {
if (!radarrOptionsItem) return;
const { tmdbId } = radarrOptionsItem;
try {
setDownloadingItems((prev) => new Set(prev).add(tmdbId));
setRadarrOptionsItem(null); // Close modal
await axios.post('/api/v1/collections/preview/download', {
tmdbId,
mediaType: 'movie',
service: 'radarr',
serverId,
profileId,
rootFolder,
sourceType: previewConfig.type,
});
// Mark as requested and save to localStorage
const requestKey = `${tmdbId}-radarr`;
setRequestedItems((prev) => {
const next = new Set(prev).add(requestKey);
try {
localStorage.setItem(
'preview-requested-items',
JSON.stringify(Array.from(next))
);
} catch (err) {
// Ignore localStorage errors
}
return next;
});
addToast(intl.formatMessage(messages.downloadSuccess), {
appearance: 'success',
autoDismiss: true,
});
} catch (err) {
const errorMessage =
err instanceof Error && err.message
? err.message
: intl.formatMessage(messages.downloadError);
addToast(errorMessage, {
appearance: 'error',
autoDismiss: true,
});
} finally {
setDownloadingItems((prev) => {
const next = new Set(prev);
next.delete(tmdbId);
return next;
});
}
},
[radarrOptionsItem, addToast, intl, previewConfig.type]
);
const activeStatus = statusByLibrary[activeLibraryId];
const activeItems = activeStatus?.result?.items || [];
const isLoading = activeStatus?.running || !activeStatus;
const error = activeStatus?.error;
const currentStage = activeStatus?.currentStage || 'Initializing...';
const progress = activeStatus?.progress || 0;
return (
<Modal
title={intl.formatMessage(messages.previewCollection)}
onCancel={onCancel}
cancelText={intl.formatMessage(messages.close)}
customMaxWidth="sm:max-w-6xl"
>
<div className="w-full">
{/* Library tabs - only show if multiple libraries */}
{previewConfig.libraryIds.length > 1 && (
<div className="mb-4 border-b border-gray-700">
<nav className="-mb-px flex space-x-4">
{previewConfig.libraries.map((library) => (
<button
key={library.id}
onClick={() => setActiveLibraryId(library.id)}
className={`whitespace-nowrap border-b-2 px-4 py-2 text-sm font-medium transition ${
activeLibraryId === library.id
? 'border-orange-500 text-orange-500'
: 'border-transparent text-gray-400 hover:border-gray-500 hover:text-gray-300'
}`}
>
{library.name}
{statusByLibrary[library.id]?.running && (
<span className="ml-2 inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent"></span>
)}
</button>
))}
</nav>
</div>
)}
{isLoading && (
<div className="flex h-96 flex-col items-center justify-center">
<LoadingSpinner />
<div className="mt-4 text-center">
<div className="text-sm font-medium text-gray-300">
{currentStage}
</div>
<div className="mt-2 w-64">
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-700">
<div
className="h-full bg-orange-500 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<div className="mt-1 text-xs text-gray-400">{progress}%</div>
</div>
</div>
</div>
)}
{error && (
<div className="flex h-96 items-center justify-center">
<div className="text-red-500">
{intl.formatMessage(messages.errorLoadingPreview)}: {error}
</div>
</div>
)}
{!isLoading && !error && activeItems.length === 0 && (
<div className="flex h-96 items-center justify-center">
<div className="text-gray-400">
{intl.formatMessage(messages.noItems)}
</div>
</div>
)}
{!isLoading && !error && activeItems.length > 0 && (
<div
className="max-h-[70vh] overflow-y-auto"
style={{ scrollbarGutter: 'stable' }}
>
<div className="grid grid-cols-2 gap-4 p-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7">
{activeItems.map((item, index) => (
<div
key={`${item.tmdbId}-${index}`}
className="relative"
onMouseEnter={() => setHoveredItem(item.tmdbId)}
onMouseLeave={() => setHoveredItem(null)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
}
}}
role="button"
tabIndex={0}
>
<div
className={`relative rounded-lg ${
item.inLibrary
? 'ring-2 ring-orange-500'
: 'ring-2 ring-gray-500'
}`}
style={{ aspectRatio: '2/3' }}
>
{/* Image wrapper with overflow hidden */}
<div className="absolute inset-0 overflow-hidden rounded-lg">
{item.posterUrl ? (
<img
src={item.posterUrl}
alt={item.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-gray-800">
<span className="text-xs text-gray-500">
No Poster
</span>
</div>
)}
</div>
{/* Info Icon - Shows on hover */}
{hoveredItem === item.tmdbId && (
<div
ref={iconRef}
className="absolute right-2 top-2 z-40"
>
<button
className="rounded-full bg-black bg-opacity-70 p-1 transition hover:bg-opacity-90"
onMouseEnter={async (e) => {
// Cancel any pending close
if (tooltipCloseTimer.current) {
clearTimeout(tooltipCloseTimer.current);
tooltipCloseTimer.current = null;
}
setInfoTooltipItem(item.tmdbId);
// Check if tooltip would clip on right/bottom of the modal container
const buttonRect =
e.currentTarget.getBoundingClientRect();
const container =
e.currentTarget.closest('.max-h-\\[70vh\\]');
if (container) {
const containerRect =
container.getBoundingClientRect();
const spaceOnRight =
containerRect.right - buttonRect.right;
const spaceBelow =
containerRect.bottom - buttonRect.bottom;
setTooltipOpenLeft(spaceOnRight < 280); // 280px = 256px tooltip + 24px margin
setTooltipOpenAbove(spaceBelow < 200); // Approximate tooltip height
}
// Fetch ratings if not already cached
if (
!ratingsCache[item.tmdbId] &&
!loadingRatings.has(item.tmdbId)
) {
setLoadingRatings((prev) =>
new Set(prev).add(item.tmdbId)
);
try {
const endpoint =
item.mediaType === 'movie'
? `/api/v1/ratings/movie/${item.tmdbId}`
: `/api/v1/ratings/tv/${item.tmdbId}`;
// Build query string with proper encoding
const queryParams = new URLSearchParams();
if (item.title)
queryParams.append(
'title',
encodeURIComponent(item.title)
);
if (item.year)
queryParams.append(
'year',
item.year.toString()
);
if (item.imdbId && item.mediaType === 'movie')
queryParams.append('imdbId', item.imdbId);
const response = await axios.get(
`${endpoint}?${queryParams.toString()}`
);
setRatingsCache((prev) => ({
...prev,
[item.tmdbId]: response.data,
}));
} catch (err) {
// Silently fail - ratings are optional
} finally {
setLoadingRatings((prev) => {
const next = new Set(prev);
next.delete(item.tmdbId);
return next;
});
}
}
}}
onMouseLeave={() => {
// Delay closing to allow moving to tooltip
tooltipCloseTimer.current = setTimeout(() => {
setInfoTooltipItem(null);
}, 200);
}}
onClick={(e) => e.stopPropagation()}
title="Show info"
>
<InformationCircleIcon className="h-5 w-5 text-white" />
</button>
{/* Info Tooltip - Small popup */}
{infoTooltipItem === item.tmdbId && (
<div
className={`absolute z-50 w-80 rounded-lg border border-gray-700 bg-gray-900 p-4 shadow-xl ${
tooltipOpenLeft ? 'right-0' : 'left-0'
} ${tooltipOpenAbove ? 'bottom-8' : 'top-8'}`}
onMouseEnter={() => {
// Cancel close when hovering tooltip
if (tooltipCloseTimer.current) {
clearTimeout(tooltipCloseTimer.current);
tooltipCloseTimer.current = null;
}
}}
onMouseLeave={() => {
// Close when leaving tooltip
setInfoTooltipItem(null);
}}
>
<div className="text-sm text-white">
<div className="mb-3 text-base font-semibold">
{item.title}
{item.year && (
<span className="block text-sm text-gray-400">
({item.year})
</span>
)}
</div>
<div className="mb-4 max-h-40 overflow-y-auto text-gray-300">
{item.overview ||
intl.formatMessage(messages.noOverview)}
</div>
{/* Ratings with logos */}
<div className="mb-4 flex flex-col gap-2.5">
{item.tmdbRating && (
<div className="flex items-center gap-2.5">
<TmdbLogo className="h-5 w-auto" />
<span className="text-base font-medium text-white">
{Math.round(item.tmdbRating * 10)}%
</span>
</div>
)}
{ratingsCache[item.tmdbId]?.imdb && (
<div className="flex items-center gap-2.5">
<img
src="/services/imdb.svg"
alt="IMDB"
className="h-5 w-auto"
/>
<span className="text-base font-medium text-white">
{
ratingsCache[item.tmdbId]?.imdb
?.criticsScore
}
/10
</span>
</div>
)}
{ratingsCache[item.tmdbId]?.rt && (
<div className="flex items-center gap-2.5">
{(ratingsCache[item.tmdbId]?.rt
?.criticsScore ?? 0) >= 60 ? (
<RTFresh className="h-5 w-auto" />
) : (
<RTRotten className="h-5 w-auto" />
)}
<span className="text-base font-medium text-white">
{
ratingsCache[item.tmdbId]?.rt
?.criticsScore
}
%
</span>
</div>
)}
{loadingRatings.has(item.tmdbId) &&
!ratingsCache[item.tmdbId] && (
<div className="text-sm text-gray-400">
Loading ratings...
</div>
)}
</div>
{item.imdbId && (
<a
href={`https://www.imdb.com/title/${item.imdbId}`}
target="_blank"
rel="noopener noreferrer"
className="inline-block rounded bg-yellow-600 px-2 py-1 text-xs font-medium text-black transition hover:bg-yellow-500"
onClick={(e) => e.stopPropagation()}
>
{intl.formatMessage(messages.viewOnImdb)}
</a>
)}
</div>
</div>
)}
</div>
)}
{/* Download Buttons - Bottom of poster with logos */}
{!item.inLibrary && hoveredItem === item.tmdbId && (
<div className="absolute bottom-2 left-2 right-2 z-10 flex justify-center gap-2">
{item.mediaType === 'movie' && (
<>
<button
onClick={() =>
handleDownload(
item.tmdbId,
item.title,
'movie',
'radarr'
)
}
disabled={downloadingItems.has(item.tmdbId)}
className="relative flex h-10 w-10 items-center justify-center rounded-lg bg-black bg-opacity-70 p-2 transition hover:bg-opacity-90 disabled:opacity-50"
title={intl.formatMessage(
messages.downloadViaRadarr
)}
>
{downloadingItems.has(item.tmdbId) ? (
<span className="text-xs text-white">...</span>
) : (
<>
<img
src="/services/radarr.svg"
alt="Radarr"
className="h-full w-full"
/>
{requestedItems.has(
`${item.tmdbId}-radarr`
) && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-green-600 bg-opacity-80">
<svg
className="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
)}
</>
)}
</button>
<button
onClick={() =>
handleDownload(
item.tmdbId,
item.title,
'movie',
'overseerr'
)
}
disabled={downloadingItems.has(item.tmdbId)}
className="relative flex h-10 w-10 items-center justify-center rounded-lg bg-black bg-opacity-70 p-2 transition hover:bg-opacity-90 disabled:opacity-50"
title={intl.formatMessage(
messages.downloadViaOverseerr
)}
>
{downloadingItems.has(item.tmdbId) ? (
<span className="text-xs text-white">...</span>
) : (
<>
<img
src="/services/overseerr.svg"
alt="Overseerr"
className="h-full w-full"
/>
{requestedItems.has(
`${item.tmdbId}-overseerr`
) && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-green-600 bg-opacity-80">
<svg
className="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
)}
</>
)}
</button>
</>
)}
{item.mediaType === 'tv' && (
<>
<button
onClick={() =>
handleDownload(
item.tmdbId,
item.title,
'tv',
'sonarr'
)
}
disabled={downloadingItems.has(item.tmdbId)}
className="relative flex h-10 w-10 items-center justify-center rounded-lg bg-black bg-opacity-70 p-2 transition hover:bg-opacity-90 disabled:opacity-50"
title={intl.formatMessage(
messages.downloadViaSonarr
)}
>
{downloadingItems.has(item.tmdbId) ? (
<span className="text-xs text-white">...</span>
) : (
<>
<img
src="/services/sonarr.svg"
alt="Sonarr"
className="h-full w-full"
/>
{requestedItems.has(
`${item.tmdbId}-sonarr`
) && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-green-600 bg-opacity-80">
<svg
className="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
)}
</>
)}
</button>
<button
onClick={() =>
handleDownload(
item.tmdbId,
item.title,
'tv',
'overseerr'
)
}
disabled={downloadingItems.has(item.tmdbId)}
className="relative flex h-10 w-10 items-center justify-center rounded-lg bg-black bg-opacity-70 p-2 transition hover:bg-opacity-90 disabled:opacity-50"
title={intl.formatMessage(
messages.downloadViaOverseerr
)}
>
{downloadingItems.has(item.tmdbId) ? (
<span className="text-xs text-white">...</span>
) : (
<>
<img
src="/services/overseerr.svg"
alt="Overseerr"
className="h-full w-full"
/>
{requestedItems.has(
`${item.tmdbId}-overseerr`
) && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-green-600 bg-opacity-80">
<svg
className="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
)}
</>
)}
</button>
</>
)}
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Radarr Options Modal */}
{radarrOptionsItem && (
<RadarrOptionsModal
tmdbId={radarrOptionsItem.tmdbId}
title={radarrOptionsItem.title}
onCancel={() => setRadarrOptionsItem(null)}
onConfirm={handleRadarrOptions}
/>
)}
{/* Season Selection Modal */}
{seasonSelectionItem && (
<SeasonSelectionModal
tmdbId={seasonSelectionItem.tmdbId}
title={seasonSelectionItem.title}
service={seasonSelectionItem.service}
onCancel={() => setSeasonSelectionItem(null)}
onConfirm={handleSeasonSelection}
/>
)}
</Modal>
);
};
export default PreviewCollectionModal;
@@ -0,0 +1,207 @@
import Modal from '@app/components/Common/Modal';
import type { RadarrSettings } from '@server/lib/settings';
import type React from 'react';
import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages({
radarrOptions: 'Radarr Download Options',
radarrOptionsDescription: 'Configure download settings for this movie',
selectServer: 'Select Server',
selectProfile: 'Select Quality Profile',
selectRootFolder: 'Select Root Folder',
download: 'Download',
cancel: 'Cancel',
loading: 'Loading...',
serverPlaceholder: 'Choose a Radarr server...',
profilePlaceholder: 'Choose a quality profile...',
rootFolderPlaceholder: 'Choose a root folder...',
selectServerFirst: 'Select a server first',
});
interface RadarrOptionsModalProps {
tmdbId: number;
title: string;
onCancel: () => void;
onConfirm: (serverId: number, profileId: number, rootFolder: string) => void;
}
const RadarrOptionsModal: React.FC<RadarrOptionsModalProps> = ({
title,
onCancel,
onConfirm,
}) => {
const intl = useIntl();
const [selectedServerId, setSelectedServerId] = useState<number | null>(null);
const [selectedProfileId, setSelectedProfileId] = useState<number | null>(
null
);
const [selectedRootFolder, setSelectedRootFolder] = useState<string | null>(
null
);
// Fetch Radarr servers
const { data: radarrServers, isLoading: serversLoading } = useSWR<
RadarrSettings[]
>('/api/v1/settings/radarr');
// Set default server (if only one or if one is marked as default)
useEffect(() => {
if (radarrServers && !selectedServerId) {
if (radarrServers.length === 1) {
setSelectedServerId(radarrServers[0].id);
} else {
const defaultServer = radarrServers.find((s) => s.isDefault);
if (defaultServer) {
setSelectedServerId(defaultServer.id);
}
}
}
}, [radarrServers, selectedServerId]);
// Fetch profiles for selected server
const { data: profiles, isLoading: profilesLoading } = useSWR<
{ id: number; name: string }[]
>(
selectedServerId
? `/api/v1/settings/radarr/${selectedServerId}/profiles`
: null
);
// Fetch root folders for selected server
const { data: rootFolders, isLoading: rootFoldersLoading } = useSWR<
{ id: number; path: string }[]
>(
selectedServerId
? `/api/v1/settings/radarr/${selectedServerId}/rootfolders`
: null
);
// Auto-select first profile and root folder when they load
useEffect(() => {
if (profiles && profiles.length > 0 && !selectedProfileId) {
setSelectedProfileId(profiles[0].id);
}
}, [profiles, selectedProfileId]);
useEffect(() => {
if (rootFolders && rootFolders.length > 0 && !selectedRootFolder) {
setSelectedRootFolder(rootFolders[0].path);
}
}, [rootFolders, selectedRootFolder]);
const handleConfirm = () => {
if (selectedServerId && selectedProfileId && selectedRootFolder) {
onConfirm(selectedServerId, selectedProfileId, selectedRootFolder);
}
};
const isValid =
selectedServerId !== null &&
selectedProfileId !== null &&
selectedRootFolder !== null;
return (
<Modal
title={intl.formatMessage(messages.radarrOptions)}
onCancel={onCancel}
cancelText={intl.formatMessage(messages.cancel)}
onOk={handleConfirm}
okText={intl.formatMessage(messages.download)}
okDisabled={!isValid}
>
<div className="w-full max-w-md">
<div className="mb-4 text-sm text-gray-400">
{intl.formatMessage(messages.radarrOptionsDescription)}
</div>
<div className="mb-4">
<div className="font-semibold text-white">{title}</div>
</div>
{/* Server Selection - only show if multiple servers */}
{radarrServers && radarrServers.length > 1 && (
<div className="mb-4">
<label className="mb-2 block text-sm font-medium text-gray-300">
{intl.formatMessage(messages.selectServer)}
</label>
<select
value={selectedServerId || ''}
onChange={(e) => {
setSelectedServerId(Number(e.target.value));
setSelectedProfileId(null); // Reset profile when server changes
setSelectedRootFolder(null); // Reset root folder when server changes
}}
className="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white"
disabled={serversLoading}
>
<option value="">
{intl.formatMessage(messages.serverPlaceholder)}
</option>
{radarrServers.map((server) => (
<option key={server.id} value={server.id}>
{server.name || `${server.hostname}:${server.port}`}
{server.isDefault && ' (Default)'}
</option>
))}
</select>
</div>
)}
{/* Quality Profile Selection */}
<div className="mb-4">
<label className="mb-2 block text-sm font-medium text-gray-300">
{intl.formatMessage(messages.selectProfile)}
</label>
<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"
disabled={!selectedServerId || profilesLoading}
>
<option value="">
{!selectedServerId
? intl.formatMessage(messages.selectServerFirst)
: profilesLoading
? intl.formatMessage(messages.loading)
: intl.formatMessage(messages.profilePlaceholder)}
</option>
{profiles?.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</select>
</div>
{/* Root Folder Selection */}
<div className="mb-4">
<label className="mb-2 block text-sm font-medium text-gray-300">
{intl.formatMessage(messages.selectRootFolder)}
</label>
<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"
disabled={!selectedServerId || rootFoldersLoading}
>
<option value="">
{!selectedServerId
? intl.formatMessage(messages.selectServerFirst)
: rootFoldersLoading
? intl.formatMessage(messages.loading)
: intl.formatMessage(messages.rootFolderPlaceholder)}
</option>
{rootFolders?.map((folder) => (
<option key={folder.id} value={folder.path}>
{folder.path}
</option>
))}
</select>
</div>
</div>
</Modal>
);
};
export default RadarrOptionsModal;
@@ -0,0 +1,380 @@
import Modal from '@app/components/Common/Modal';
import type { SonarrSettings } from '@server/lib/settings';
import axios from 'axios';
import type React from 'react';
import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages({
selectSeasons: 'Select Seasons',
selectSeasonsDescription: 'Choose which seasons to download',
season: 'Season {seasonNumber}',
download: 'Download',
cancel: 'Cancel',
loadingSeasons: 'Loading seasons...',
selectAll: 'Select All',
deselectAll: 'Deselect All',
selectServer: 'Select Server',
selectProfile: 'Select Quality Profile',
selectRootFolder: 'Select Root Folder',
loading: 'Loading...',
serverPlaceholder: 'Choose a Sonarr server...',
profilePlaceholder: 'Choose a quality profile...',
rootFolderPlaceholder: 'Choose a root folder...',
selectServerFirst: 'Select a server first',
sonarrOptions: 'Sonarr Download Options',
});
interface SeasonSelectionModalProps {
tmdbId: number;
title: string;
service: 'overseerr' | 'sonarr';
onCancel: () => void;
onConfirm: (
selectedSeasons: number[],
serverId?: number,
profileId?: number,
rootFolder?: string
) => void;
}
interface Season {
id: number;
season_number: number;
name: string;
episode_count: number;
air_date?: string;
}
const SeasonSelectionModal: React.FC<SeasonSelectionModalProps> = ({
tmdbId,
title,
service,
onCancel,
onConfirm,
}) => {
const intl = useIntl();
const [seasons, setSeasons] = useState<Season[]>([]);
const [selectedSeasons, setSelectedSeasons] = useState<Set<number>>(
new Set()
);
const [loading, setLoading] = useState(true);
// Sonarr options (only for sonarr service)
const [selectedServerId, setSelectedServerId] = useState<number | null>(null);
const [selectedProfileId, setSelectedProfileId] = useState<number | null>(
null
);
const [selectedRootFolder, setSelectedRootFolder] = useState<string | null>(
null
);
// Fetch Sonarr servers (only if service is sonarr)
const { data: sonarrServers, isLoading: serversLoading } = useSWR<
SonarrSettings[]
>(service === 'sonarr' ? '/api/v1/settings/sonarr' : null);
// Set default server for Sonarr
useEffect(() => {
if (service === 'sonarr' && sonarrServers && !selectedServerId) {
if (sonarrServers.length === 1) {
setSelectedServerId(sonarrServers[0].id);
} else {
const defaultServer = sonarrServers.find((s) => s.isDefault);
if (defaultServer) {
setSelectedServerId(defaultServer.id);
}
}
}
}, [service, sonarrServers, selectedServerId]);
// Fetch profiles for selected Sonarr server
const { data: sonarrProfiles, isLoading: profilesLoading } = useSWR<
{ id: number; name: string }[]
>(
service === 'sonarr' && selectedServerId
? `/api/v1/settings/sonarr/${selectedServerId}/profiles`
: null
);
// Fetch root folders for selected Sonarr server
const { data: sonarrRootFolders, isLoading: rootFoldersLoading } = useSWR<
{ id: number; path: string }[]
>(
service === 'sonarr' && selectedServerId
? `/api/v1/settings/sonarr/${selectedServerId}/rootfolders`
: null
);
// Auto-select first profile and root folder for Sonarr
useEffect(() => {
if (
service === 'sonarr' &&
sonarrProfiles &&
sonarrProfiles.length > 0 &&
!selectedProfileId
) {
setSelectedProfileId(sonarrProfiles[0].id);
}
}, [service, sonarrProfiles, selectedProfileId]);
useEffect(() => {
if (
service === 'sonarr' &&
sonarrRootFolders &&
sonarrRootFolders.length > 0 &&
!selectedRootFolder
) {
setSelectedRootFolder(sonarrRootFolders[0].path);
}
}, [service, sonarrRootFolders, selectedRootFolder]);
useEffect(() => {
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
},
}
);
// Filter out season 0 (specials) and sort by season number
const filteredSeasons = (response.data.seasons || [])
.filter((s: Season) => s.season_number > 0)
.sort((a: Season, b: Season) => a.season_number - b.season_number);
setSeasons(filteredSeasons);
// Select all seasons by default
const allSeasonNumbers = new Set<number>(
filteredSeasons.map((s: Season) => s.season_number)
);
setSelectedSeasons(allSeasonNumbers);
} catch (error) {
// Failed to fetch seasons - will show empty list
} finally {
setLoading(false);
}
};
fetchSeasons();
}, [tmdbId]);
const handleToggleSeason = (seasonNumber: number) => {
const newSelected = new Set(selectedSeasons);
if (newSelected.has(seasonNumber)) {
newSelected.delete(seasonNumber);
} else {
newSelected.add(seasonNumber);
}
setSelectedSeasons(newSelected);
};
const handleSelectAll = () => {
const allSeasonNumbers = new Set<number>(
seasons.map((s) => s.season_number)
);
setSelectedSeasons(allSeasonNumbers);
};
const handleDeselectAll = () => {
setSelectedSeasons(new Set());
};
const handleConfirm = () => {
const selectedArray = Array.from(selectedSeasons).sort((a, b) => a - b);
if (service === 'sonarr') {
onConfirm(
selectedArray,
selectedServerId || undefined,
selectedProfileId || undefined,
selectedRootFolder || undefined
);
} else {
onConfirm(selectedArray);
}
};
const isSonarrValid =
service !== 'sonarr' ||
(selectedServerId !== null &&
selectedProfileId !== null &&
selectedRootFolder !== null);
return (
<Modal
title={
service === 'sonarr'
? intl.formatMessage(messages.sonarrOptions)
: intl.formatMessage(messages.selectSeasons)
}
subTitle={title}
onCancel={onCancel}
onOk={handleConfirm}
okText={intl.formatMessage(messages.download)}
cancelText={intl.formatMessage(messages.cancel)}
okDisabled={selectedSeasons.size === 0 || !isSonarrValid}
>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="text-gray-400">
{intl.formatMessage(messages.loadingSeasons)}
</div>
</div>
) : (
<div className="space-y-4">
{/* Sonarr Options - only show for sonarr service */}
{service === 'sonarr' && (
<div className="space-y-4 border-b border-gray-700 pb-4">
{/* Server Selection - only show if multiple servers */}
{sonarrServers && sonarrServers.length > 1 && (
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
{intl.formatMessage(messages.selectServer)}
</label>
<select
value={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"
disabled={serversLoading}
>
<option value="">
{intl.formatMessage(messages.serverPlaceholder)}
</option>
{sonarrServers.map((server) => (
<option key={server.id} value={server.id}>
{server.name || `${server.hostname}:${server.port}`}
{server.isDefault && ' (Default)'}
</option>
))}
</select>
</div>
)}
{/* Quality Profile Selection */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
{intl.formatMessage(messages.selectProfile)}
</label>
<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"
disabled={!selectedServerId || profilesLoading}
>
<option value="">
{!selectedServerId
? intl.formatMessage(messages.selectServerFirst)
: profilesLoading
? intl.formatMessage(messages.loading)
: intl.formatMessage(messages.profilePlaceholder)}
</option>
{sonarrProfiles?.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</select>
</div>
{/* Root Folder Selection */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
{intl.formatMessage(messages.selectRootFolder)}
</label>
<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"
disabled={!selectedServerId || rootFoldersLoading}
>
<option value="">
{!selectedServerId
? intl.formatMessage(messages.selectServerFirst)
: rootFoldersLoading
? intl.formatMessage(messages.loading)
: intl.formatMessage(messages.rootFolderPlaceholder)}
</option>
{sonarrRootFolders?.map((folder) => (
<option key={folder.id} value={folder.path}>
{folder.path}
</option>
))}
</select>
</div>
</div>
)}
<div className="text-sm text-gray-400">
{intl.formatMessage(messages.selectSeasonsDescription)}
</div>
{/* Select All / Deselect All buttons */}
<div className="flex gap-2">
<button
onClick={handleSelectAll}
className="rounded-md bg-gray-700 px-3 py-1 text-sm text-gray-300 transition hover:bg-gray-600"
>
{intl.formatMessage(messages.selectAll)}
</button>
<button
onClick={handleDeselectAll}
className="rounded-md bg-gray-700 px-3 py-1 text-sm text-gray-300 transition hover:bg-gray-600"
>
{intl.formatMessage(messages.deselectAll)}
</button>
</div>
{/* Season checkboxes */}
<div className="max-h-96 space-y-2 overflow-y-auto">
{seasons.map((season) => (
<label
key={season.id}
className="flex cursor-pointer items-center space-x-3 rounded-md p-2 transition hover:bg-gray-800"
>
<input
type="checkbox"
checked={selectedSeasons.has(season.season_number)}
onChange={() => handleToggleSeason(season.season_number)}
className="h-4 w-4 rounded border-gray-600 bg-gray-700 text-orange-500 focus:ring-orange-500 focus:ring-offset-gray-900"
/>
<div className="flex-1">
<div className="text-sm font-medium text-gray-200">
{season.name ||
intl.formatMessage(messages.season, {
seasonNumber: season.season_number,
})}
</div>
{season.episode_count > 0 && (
<div className="text-xs text-gray-500">
{season.episode_count} episode
{season.episode_count !== 1 ? 's' : ''}
</div>
)}
</div>
</label>
))}
</div>
{selectedSeasons.size > 0 && (
<div className="rounded-md bg-gray-800 p-3 text-sm text-gray-300">
Selected: {selectedSeasons.size} season
{selectedSeasons.size !== 1 ? 's' : ''}
</div>
)}
</div>
)}
</Modal>
);
};
export default SeasonSelectionModal;
+17 -14
View File
@@ -1,7 +1,6 @@
import type { ButtonType } from '@app/components/Common/Button';
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import useClickOutside from '@app/hooks/useClickOutside';
import { useLockBodyScroll } from '@app/hooks/useLockBodyScroll';
import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react';
@@ -34,6 +33,7 @@ interface ModalProps {
loading?: boolean;
backdrop?: string;
footerMessage?: string;
customMaxWidth?: string;
children?: React.ReactNode;
}
@@ -63,26 +63,20 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
onTertiary,
backdrop,
footerMessage,
customMaxWidth,
},
parentRef
) => {
const intl = useIntl();
const modalRef = useRef<HTMLDivElement>(null);
useClickOutside(modalRef, (e) => {
// Don't close modal if clicking on react-select dropdown
const target = e.target as Element;
if (
target.closest('.react-select__menu') ||
target.closest('.react-select__menu-portal')
) {
return;
}
useLockBodyScroll(true, disableScrollLock);
if (onCancel && backgroundClickable) {
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
// Only close if clicking directly on the backdrop (not on children)
if (e.target === e.currentTarget && onCancel && backgroundClickable) {
onCancel();
}
});
useLockBodyScroll(true, disableScrollLock);
};
// Don't render portal during SSR
if (typeof document === 'undefined') {
@@ -93,6 +87,13 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
<div
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex h-full w-full items-center justify-center bg-stone-800 bg-opacity-70"
ref={parentRef}
onClick={handleBackdropClick}
onKeyDown={(e) => {
if (e.key === 'Escape' && onCancel && backgroundClickable) {
onCancel();
}
}}
role="presentation"
>
<Transition
appear
@@ -110,7 +111,9 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
</div>
</Transition>
<Transition
className="hide-scrollbar relative inline-block w-full overflow-auto bg-stone-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-2xl sm:rounded-lg sm:align-middle"
className={`hide-scrollbar relative inline-block w-full overflow-auto bg-stone-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 ${
customMaxWidth || 'sm:max-w-2xl'
} sm:rounded-lg sm:align-middle`}
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
+3 -1
View File
@@ -22,7 +22,9 @@ const useClickOutside = (
document.body.addEventListener('click', handleBodyClick, { capture: true });
return () => {
document.body.removeEventListener('click', handleBodyClick);
document.body.removeEventListener('click', handleBodyClick, {
capture: true,
});
};
}, [ref, callback]);
};