mirror of
https://github.com/agregarr/agregarr.git
synced 2026-05-12 13:48:50 -05:00
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:
@@ -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
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user